SPB Git forge

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%
48.1 KB · 1,407 lines python
Raw Blame History
1# ============================================2# Projet   : API-KA3# Fichier  : src/api/routes/stats.py4# Node     : m3u96b5# Author   : Simon-Pierre Boucher6# Contact  : contact@spboucher.ai7# Date     : 2026-08-198# ============================================9"""Routes /api/stats : tableau de bord analytique de la plateforme + rapports PDF.1011Contrat commun Groupe KA v2 (src/api/web/ka/stats/SPEC.md) :12- ``GET /api/stats/dashboard?period=…`` → JSON (KPI + sparklines, jauges,13  séries, multi-séries, empilées, répartitions avec deltas, distributions,14  heatmap calendrier + horaire 7×24, tableaux, records) calculé depuis les15  données réelles : table ``api_requests`` (journal du middleware),16  ``collection_runs`` et ``connector_health``.17- ``GET /api/stats/report?period=…&mode=complet|synthese|tendances|18  repartitions|donnees`` → PDF Groupe-KA (moteur kapdf v2, 5 rapports —19  un mode inconnu retombe sur ``complet``).20- ``GET /api/stats/ecosystem-report?period=…&mode=…`` → UN SEUL PDF21  consolidant les 12 plateformes de l'écosystème : les dashboards des22  plateformes sœurs sont lus en parallèle (httpx, 15 s), le sien en local ;23  une plateforme injoignable devient une section « données indisponibles ».24  ``mode`` optionnel (mêmes 5 modes, inconnu ou absent → complet).2526Cache serveur : 5 minutes par période. Aucune stat inventée : une section27sans données est simplement absente (le front affiche « Pas encore mesuré »).28"""2930from __future__ import annotations3132import asyncio33import datetime34import json35import threading36import time37from pathlib import Path38from typing import Any39from zoneinfo import ZoneInfo4041import httpx42from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response43from sqlalchemy import func, select44from sqlalchemy.orm import Session4546from src.api import ecopdf, kapdf47from src.api.routes import envelope48from src.database.db import get_db49from src.database.models import ApiRequest, CollectionRun, ConnectorHealth5051router = APIRouter(prefix="/api/stats", tags=["stats"])5253TZ = ZoneInfo("America/Toronto")54CACHE_TTL = 300.0  # ≥ 5 min (SPEC)55PLATFORM_ID = "api-ka"5657SITE = {58    "wordmark": "API·Ka",59    "accent": "#3b5bdb",60    "domain": "www.api-ka.com",61    "tagline": "Plateforme centrale de l'écosystème Groupe KA",62}6364SERVICE_NAMES = {65    "louka": "Lou·Ka",66    "immoka": "Immo·Ka",67    "foodka": "Food·Ka",68    "autoka": "Auto·Ka",69    "fabrika": "Fabri·Ka",70    "restoka": "Resto·Ka",71    "sortika": "Sorti·Ka",72    "creaka": "Créa·Ka",73    "jobka": "Job·Ka",74}7576PERIOD_LABELS = {77    "auj": "aujourd'hui",78    "7j": "7 jours",79    "30j": "30 jours",80    "3m": "3 mois",81    "6m": "6 mois",82    "12m": "12 mois",83    "annee": "année en cours",84    "tout": "toute la période",85}86PERIOD_DAYS = {"7j": 7, "30j": 30, "3m": 91, "6m": 182, "12m": 365}8788RUN_STATUS_FR = {"success": "succès", "failed": "échec", "retried": "relancé"}89HEALTH_FR = {"ok": "OK", "degraded": "dégradé", "broken": "en panne", "stale": "périmé"}90LAT_BINS = [91    (0, 25, "< 25 ms"),92    (25, 50, "25-50 ms"),93    (50, 100, "50-100 ms"),94    (100, 200, "100-200 ms"),95    (200, 500, "200-500 ms"),96    (500, 1000, "0,5-1 s"),97    (1000, 2000, "1-2 s"),98    (2000, float("inf"), "> 2 s"),99]100DUR_BINS = [101    (0, 1, "< 1 s"),102    (1, 5, "1-5 s"),103    (5, 15, "5-15 s"),104    (15, 30, "15-30 s"),105    (30, 60, "30-60 s"),106    (60, float("inf"), "> 60 s"),107]108109_cache: dict[tuple, tuple[float, dict]] = {}110_cache_lock = threading.Lock()111112# ------------------------------------------------------ rapport écosystème113ECO_CACHE_TTL = 600.0  # 10 min par (period, mode)114ECO_FETCH_TIMEOUT = 15.0115_eco_cache: dict[tuple[str, str], tuple[float, bytes, str]] = {}116_eco_lock = threading.Lock()117118_ECOSYSTEM_JSON = Path(__file__).resolve().parents[1] / "web" / "ka" / "ecosystem.json"119# Les 11 plateformes de données (le hub groupe-ka n'expose pas de dashboard).120ECO_SITES: list[dict] = [121    s122    for s in json.loads(_ECOSYSTEM_JSON.read_text(encoding="utf-8"))["sites"]123    if s["id"] != "groupe-ka"124]125126127# ---------------------------------------------------------------- périodes128def _resolve_period(129    period: str,130    date_from: datetime.date | None,131    date_to: datetime.date | None,132    db: Session,133) -> tuple[datetime.date, datetime.date, str]:134    """Résout (from, to, label) en dates locales America/Toronto."""135    today = datetime.datetime.now(tz=TZ).date()136    if date_from and date_to:137        if date_from > date_to:138            raise HTTPException(status_code=422, detail="from doit précéder to")139        return date_from, date_to, f"du {date_from} au {date_to}"140    if period == "auj":141        return today, today, PERIOD_LABELS["auj"]142    if period == "annee":143        return datetime.date(today.year, 1, 1), today, PERIOD_LABELS["annee"]144    if period == "tout":145        firsts = [146            db.execute(select(func.min(ApiRequest.ts))).scalar(),147            db.execute(select(func.min(CollectionRun.started_at))).scalar(),148        ]149        dates = []150        for f in firsts:151            if f is not None:152                if f.tzinfo is None:153                    f = f.replace(tzinfo=datetime.UTC)154                dates.append(f.astimezone(TZ).date())155        return (min(dates) if dates else today), today, PERIOD_LABELS["tout"]156    if period in PERIOD_DAYS:157        return (158            today - datetime.timedelta(days=PERIOD_DAYS[period] - 1),159            today,160            PERIOD_LABELS[period],161        )162    raise HTTPException(163        status_code=422,164        detail=f"Période invalide : {period}. Valides : {', '.join(PERIOD_LABELS)}",165    )166167168def _utc_bounds(169    d_from: datetime.date, d_to: datetime.date170) -> tuple[datetime.datetime, datetime.datetime]:171    """Bornes UTC [00:00 from, 24:00 to] exprimées depuis les dates locales."""172    start = datetime.datetime.combine(d_from, datetime.time.min, tzinfo=TZ)173    end = datetime.datetime.combine(174        d_to + datetime.timedelta(days=1), datetime.time.min, tzinfo=TZ175    )176    return start.astimezone(datetime.UTC), end.astimezone(datetime.UTC)177178179# ---------------------------------------------------------------- agrégats180# Endpoints « bruit » exclus des métriques du tableau de bord : chemins181# inconnus sondés par les scanners/bots (404, .env, etc.). Ils restent182# journalisés dans api_requests pour la visibilité sécurité, mais ne doivent183# pas polluer le taux d'erreur qui mesure la santé réelle de l'API.184NOISE_ENDPOINTS = ("(autre)", "/api/v1/(autre)")185186187def _fetch_requests(188    db: Session, d_from: datetime.date, d_to: datetime.date189) -> list[tuple[datetime.datetime, str, str, int, float]]:190    """Requêtes API de la fenêtre : (ts local, méthode, endpoint, statut, ms)."""191    lo, hi = _utc_bounds(d_from, d_to)192    rows = db.execute(193        select(194            ApiRequest.ts,195            ApiRequest.method,196            ApiRequest.endpoint,197            ApiRequest.status,198            ApiRequest.duration_ms,199        ).where(200            ApiRequest.ts >= lo,201            ApiRequest.ts < hi,202            ApiRequest.endpoint.notin_(NOISE_ENDPOINTS),203        )204    ).all()205    out = []206    for ts, method, endpoint, status, duration in rows:207        if ts.tzinfo is None:208            ts = ts.replace(tzinfo=datetime.UTC)209        out.append((ts.astimezone(TZ), method or "GET", endpoint, status, duration or 0.0))210    return out211212213def _fetch_runs(214    db: Session, d_from: datetime.date, d_to: datetime.date215) -> list[CollectionRun]:216    """Runs de collecte de la fenêtre (par date logique ``date_key``)."""217    return (218        db.execute(219            select(CollectionRun)220            .where(CollectionRun.date_key >= d_from, CollectionRun.date_key <= d_to)221            .order_by(CollectionRun.started_at.desc())222        )223        .scalars()224        .all()225    )226227228def _fetch_health(db: Session) -> list[ConnectorHealth]:229    """État courant des connecteurs supervisés (hors ligne virtuelle _app)."""230    return (231        db.execute(232            select(ConnectorHealth)233            .where(ConnectorHealth.source != "_app")234            .order_by(ConnectorHealth.service, ConnectorHealth.source)235        )236        .scalars()237        .all()238    )239240241def _p95(values: list[float]) -> float:242    if not values:243        return 0.0244    vs = sorted(values)245    idx = max(0, int(round(0.95 * (len(vs) - 1))))246    return vs[idx]247248249def _delta(cur: float, prev: float | None) -> float | None:250    if prev is None or prev == 0:251        return None252    return round((cur - prev) / prev * 100, 1)253254255def _daterange(d_from: datetime.date, d_to: datetime.date) -> list[datetime.date]:256    n = (d_to - d_from).days + 1257    return [d_from + datetime.timedelta(days=i) for i in range(n)]258259260def _fr_int(n: float) -> str:261    return f"{int(n):,}".replace(",", " ")262263264def _fr_num(n: float) -> str:265    if isinstance(n, float) and not float(n).is_integer():266        return f"{n:,.1f}".replace(",", " ").replace(".", ",")267    return _fr_int(n)268269270def _spark(points: list[dict], max_pts: int = 40) -> list[dict]:271    """Sous-échantillonne une série pour la sparkline d'un KPI (≤ max_pts)."""272    if len(points) <= max_pts:273        return points274    step = len(points) / max_pts275    out = [points[min(len(points) - 1, int(i * step))] for i in range(max_pts)]276    if out[-1] is not points[-1]:277        out[-1] = points[-1]278    return out279280281def _hist(values: list[float], bins) -> list[dict]:282    out = []283    for lo, hi, label in bins:284        c = sum(1 for v in values if lo <= v < hi)285        out.append({"label": label, "value": c})286    while out and out[-1]["value"] == 0:287        out.pop()288    return out289290291# ---------------------------------------------------------------- dashboard292def _build_dashboard(db: Session, period: str, d_from, d_to, label) -> dict[str, Any]:293    """Calcule le JSON complet du contrat SPEC v2 depuis les données réelles."""294    hourly = period == "auj" or d_from == d_to295    span = (d_to - d_from).days + 1296    prev_to = d_from - datetime.timedelta(days=1)297    prev_from = prev_to - datetime.timedelta(days=span - 1)298299    reqs = _fetch_requests(db, d_from, d_to)300    prev_reqs = _fetch_requests(db, prev_from, prev_to)301    runs = _fetch_runs(db, d_from, d_to)302    prev_runs = _fetch_runs(db, prev_from, prev_to)303    health = _fetch_health(db)304305    days = _daterange(d_from, d_to)306307    # -------- seaux temporels (jour, ou heure quand la fenêtre = 1 jour)308    if hourly:309        bucket_keys: list = list(range(24))310        bucket_labels = [f"{h:02d}h" for h in range(24)]311        req_bucket = {i: [] for i in bucket_keys}312        for r in reqs:313            req_bucket[r[0].hour].append(r)314    else:315        bucket_keys = days316        bucket_labels = [d.isoformat() for d in days]317        req_bucket = {d: [] for d in days}318        for r in reqs:319            d = r[0].date()320            if d in req_bucket:321                req_bucket[d].append(r)322323    def per_bucket(fn) -> list[dict]:324        return [325            {"t": bucket_labels[i], "v": fn(req_bucket[k])}326            for i, k in enumerate(bucket_keys)327        ]328329    calls_pts = per_bucket(len)330    err_pts = per_bucket(lambda rs: sum(1 for r in rs if r[3] >= 400))331    avg_pts = per_bucket(332        lambda rs: round(sum(r[4] for r in rs) / len(rs), 1) if rs else 0333    )334    p95_pts = per_bucket(lambda rs: round(_p95([r[4] for r in rs]), 1) if rs else 0)335    errate_pts = per_bucket(336        lambda rs: round(sum(1 for r in rs if r[3] >= 400) / len(rs) * 100, 1)337        if rs338        else 0339    )340341    # -------- agrégats requêtes API (période courante)342    total_calls = len(reqs)343    durations = [r[4] for r in reqs]344    avg_ms = round(sum(durations) / total_calls, 2) if total_calls else 0.0345    p95_ms = round(_p95(durations), 2)346    errors = sum(1 for r in reqs if r[3] >= 400)347    err5xx = sum(1 for r in reqs if r[3] >= 500)348    err_rate = round(errors / total_calls * 100, 2) if total_calls else 0.0349    success = total_calls - errors350    fast200 = sum(1 for d in durations if d < 200)351    active_endpoints = len({r[2] for r in reqs})352    active_days = sum(1 for k in bucket_keys if req_bucket[k]) if not hourly else None353354    # -------- agrégats requêtes API (période précédente, pour les deltas)355    prev_calls = len(prev_reqs)356    prev_durs = [r[4] for r in prev_reqs]357    prev_avg = (sum(prev_durs) / prev_calls) if prev_calls else None358    prev_p95 = _p95(prev_durs) if prev_calls else None359    prev_errors = sum(1 for r in prev_reqs if r[3] >= 400)360    prev_err5xx = sum(1 for r in prev_reqs if r[3] >= 500)361    prev_err = (prev_errors / prev_calls * 100) if prev_calls else None362    prev_endpoints = len({r[2] for r in prev_reqs})363364    # -------- agrégats runs de collecte365    ok_runs = [r for r in runs if r.status in ("success", "retried")]366    failed_runs = [r for r in runs if r.status == "failed"]367    records_total = sum(r.records_count for r in ok_runs)368    active_services = len({r.service for r in runs})369    prev_ok = [r for r in prev_runs if r.status in ("success", "retried")]370    prev_failed = [r for r in prev_runs if r.status == "failed"]371    prev_records = sum(r.records_count for r in prev_ok)372373    rec_day: dict[datetime.date, int] = {d: 0 for d in days}374    okrun_day: dict[datetime.date, int] = {d: 0 for d in days}375    for r in ok_runs:376        if r.date_key in rec_day:377            rec_day[r.date_key] += r.records_count378            okrun_day[r.date_key] += 1379380    # -------- KPI (uniquement des mesures réelles) + sparklines381    kpis: list[dict[str, Any]] = []382383    def kpi(id_, label_, value, unit="", delta=None, invert=False, spark=None):384        d: dict[str, Any] = {"id": id_, "label": label_, "value": value, "unit": unit}385        if delta is not None:386            d["delta_pct"] = delta387            good = (delta <= 0) if invert else (delta >= 0)388            d["direction"] = "up" if good else "down"389            if invert:390                d["invert"] = True391        if spark and len(spark) > 1:392            d["spark"] = _spark(spark)393        kpis.append(d)394395    kpi(396        "calls",397        "Appels API",398        total_calls,399        "",400        _delta(total_calls, prev_calls),401        spark=calls_pts,402    )403    if not hourly and span > 1:404        kpi(405            "calls_day",406            "Appels par jour (moyenne)",407            round(total_calls / span, 1),408            "",409            _delta(total_calls / span, prev_calls / span if prev_calls else None),410        )411    kpi(412        "latency",413        "Latence moyenne",414        avg_ms,415        "ms",416        _delta(avg_ms, round(prev_avg, 2) if prev_avg else None),417        invert=True,418        spark=avg_pts,419    )420    kpi(421        "p95",422        "Latence p95",423        p95_ms,424        "ms",425        _delta(p95_ms, round(prev_p95, 2) if prev_p95 else None),426        invert=True,427        spark=p95_pts,428    )429    kpi(430        "errors",431        "Taux d'erreur (HTTP ≥ 400)",432        err_rate,433        "%",434        _delta(err_rate, round(prev_err, 2) if prev_err else None),435        invert=True,436        spark=errate_pts,437    )438    kpi(439        "err5xx",440        "Erreurs serveur (5xx)",441        err5xx,442        "",443        _delta(err5xx, prev_err5xx or None),444        invert=True,445    )446    kpi(447        "endpoints",448        "Endpoints actifs",449        active_endpoints,450        "",451        _delta(active_endpoints, prev_endpoints or None),452    )453    kpi(454        "runs_ok",455        "Collectes réussies",456        len(ok_runs),457        "",458        _delta(len(ok_runs), len(prev_ok) or None),459        spark=None460        if hourly461        else [{"t": d.isoformat(), "v": okrun_day[d]} for d in days],462    )463    kpi(464        "runs_failed",465        "Collectes échouées",466        len(failed_runs),467        "",468        _delta(len(failed_runs), len(prev_failed) or None),469        invert=True,470    )471    kpi(472        "records",473        "Enregistrements collectés",474        records_total,475        "",476        _delta(records_total, prev_records or None),477        spark=None478        if hourly479        else [{"t": d.isoformat(), "v": rec_day[d]} for d in days],480    )481    if active_services:482        kpi("services", "Services KA collectés", active_services)483484    # -------- jauges : taux & couvertures (données réelles seulement)485    gauges: list[dict[str, Any]] = []486    if total_calls:487        gauges.append(488            {489                "id": "success",490                "label": "Taux de succès HTTP (statut < 400)",491                "value": round(success / total_calls * 100, 1),492                "max": 100,493                "unit": "%",494            }495        )496        gauges.append(497            {498                "id": "fast",499                "label": "Réponses servies en moins de 200 ms",500                "value": round(fast200 / total_calls * 100, 1),501                "max": 100,502                "unit": "%",503            }504        )505    if runs:506        gauges.append(507            {508                "id": "runs",509                "label": "Taux de réussite des collectes",510                "value": round(len(ok_runs) / len(runs) * 100, 1),511                "max": 100,512                "unit": "%",513            }514        )515    if health:516        ok_h = sum(1 for h in health if h.status == "ok")517        gauges.append(518            {519                "id": "connectors",520                "label": f"Connecteurs de l'écosystème en santé ({ok_h}/{len(health)})",521                "value": round(ok_h / len(health) * 100, 1),522                "max": 100,523                "unit": "%",524            }525        )526    if active_days is not None and span > 1 and total_calls:527        gauges.append(528            {529                "id": "activedays",530                "label": f"Jours avec trafic API journalisé ({active_days}/{span})",531                "value": round(active_days / span * 100, 1),532                "max": 100,533                "unit": "%",534            }535        )536537    # -------- séries temporelles538    series: list[dict[str, Any]] = []539    suffix = "par heure" if hourly else "par jour"540541    calls_serie: dict[str, Any] = {542        "id": "calls",543        "title": f"Appels API {suffix}",544        "unit": "appels",545        "kind": "line",546        "points": calls_pts,547    }548    if prev_reqs and not hourly:549        prev_days = _daterange(prev_from, prev_to)550        prev_by = {d: 0 for d in prev_days}551        for r in prev_reqs:552            d = r[0].date()553            if d in prev_by:554                prev_by[d] += 1555        calls_serie["compare"] = [556            {"t": d.isoformat(), "v": prev_by[d]} for d in prev_days557        ]558    if reqs:559        series.append(calls_serie)560        series.append(561            {562                "id": "errors",563                "title": f"Erreurs HTTP (≥ 400) {suffix}",564                "unit": "erreurs",565                "kind": "bar",566                "points": err_pts,567            }568        )569        series.append(570            {571                "id": "latency",572                "title": f"Latence moyenne {suffix} (ms)",573                "unit": "ms",574                "kind": "line",575                "points": avg_pts,576            }577        )578    if runs and not hourly:579        series.append(580            {581                "id": "records",582                "title": "Enregistrements collectés par jour",583                "unit": "enregistrements",584                "kind": "line",585                "points": [{"t": d.isoformat(), "v": rec_day[d]} for d in days],586            }587        )588589    # -------- multi-séries (≤ 4 séries chacune)590    multiseries: list[dict[str, Any]] = []591    if reqs and len(bucket_keys) > 1:592        multiseries.append(593            {594                "id": "lat",595                "title": f"Latence {suffix} — moyenne vs p95 (ms)",596                "unit": "ms",597                "series": [598                    {"label": "Moyenne", "points": avg_pts},599                    {"label": "p95", "points": p95_pts},600                ],601            }602        )603    ep_stats: dict[str, dict[str, Any]] = {}604    for _, _, endpoint, status, dur in reqs:605        s = ep_stats.setdefault(endpoint, {"calls": 0, "durs": [], "errors": 0})606        s["calls"] += 1607        s["durs"].append(dur)608        if status >= 400:609            s["errors"] += 1610    top_eps = sorted(ep_stats.items(), key=lambda kv: kv[1]["calls"], reverse=True)611    if len(top_eps) >= 2 and len(bucket_keys) > 1:612        top4 = [ep for ep, _ in top_eps[:4]]613        ep_bucket = {614            ep: {k: 0 for k in bucket_keys} for ep in top4615        }616        for r in reqs:617            if r[2] in ep_bucket:618                key = r[0].hour if hourly else r[0].date()619                if key in ep_bucket[r[2]]:620                    ep_bucket[r[2]][key] += 1621        multiseries.append(622            {623                "id": "top_eps",624                "title": f"Appels {suffix} — top {len(top4)} endpoints",625                "unit": "appels",626                "series": [627                    {628                        "label": ep,629                        "points": [630                            {"t": bucket_labels[i], "v": ep_bucket[ep][k]}631                            for i, k in enumerate(bucket_keys)632                        ],633                    }634                    for ep in top4635                ],636            }637        )638639    # -------- barres empilées : composition dans le temps640    stacked: list[dict[str, Any]] = []641    classes = [("2xx", 200, 300), ("3xx", 300, 400), ("4xx", 400, 500), ("5xx", 500, 600)]642    if reqs:643        present = [644            (name, lo, hi)645            for name, lo, hi in classes646            if any(lo <= r[3] < hi for r in reqs)647        ]648        if present:649            stacked.append(650                {651                    "id": "status",652                    "title": f"Appels {suffix} par classe de statut HTTP",653                    "unit": "appels",654                    "keys": [c[0] for c in present],655                    "points": [656                        {657                            "t": bucket_labels[i],658                            "values": [659                                sum(1 for r in req_bucket[k] if lo <= r[3] < hi)660                                for _, lo, hi in present661                            ],662                        }663                        for i, k in enumerate(bucket_keys)664                    ],665                }666            )667    svc_records: dict[str, int] = {}668    for r in ok_runs:669        svc_records[r.service] = svc_records.get(r.service, 0) + r.records_count670    if svc_records and not hourly and len(days) > 1:671        top_svcs = [672            s673            for s, _ in sorted(svc_records.items(), key=lambda kv: kv[1], reverse=True)674        ][:6]675        svc_day = {s: {d: 0 for d in days} for s in top_svcs}676        for r in ok_runs:677            if r.service in svc_day and r.date_key in svc_day[r.service]:678                svc_day[r.service][r.date_key] += r.records_count679        stacked.append(680            {681                "id": "services_day",682                "title": "Enregistrements collectés par jour et par service",683                "unit": "enregistrements",684                "keys": [SERVICE_NAMES.get(s, s) for s in top_svcs],685                "points": [686                    {687                        "t": d.isoformat(),688                        "values": [svc_day[s][d] for s in top_svcs],689                    }690                    for d in days691                ],692            }693        )694695    # -------- répartitions (avec deltas honnêtes vs période précédente)696    prev_ep_calls: dict[str, int] = {}697    prev_status_cls: dict[str, int] = {}698    prev_methods: dict[str, int] = {}699    for _, method, endpoint, status, _dur in prev_reqs:700        prev_ep_calls[endpoint] = prev_ep_calls.get(endpoint, 0) + 1701        cls = f"{status // 100}xx"702        prev_status_cls[cls] = prev_status_cls.get(cls, 0) + 1703        prev_methods[method] = prev_methods.get(method, 0) + 1704705    breakdowns: list[dict[str, Any]] = []706    if top_eps:707        items = []708        for ep, s in top_eps[:12]:709            it = {"label": ep, "value": s["calls"]}710            d = _delta(s["calls"], prev_ep_calls.get(ep))711            if d is not None:712                it["delta_pct"] = d713            items.append(it)714        breakdowns.append(715            {716                "id": "top_endpoints",717                "title": "Top endpoints (appels)",718                "kind": "bars",719                "items": items,720            }721        )722    if reqs:723        status_counts: dict[int, int] = {}724        for r in reqs:725            status_counts[r[3]] = status_counts.get(r[3], 0) + 1726        cls_counts: dict[str, int] = {}727        for st, c in status_counts.items():728            cls = f"{st // 100}xx"729            cls_counts[cls] = cls_counts.get(cls, 0) + c730        items = []731        for cls in sorted(cls_counts):732            it = {"label": cls, "value": cls_counts[cls]}733            d = _delta(cls_counts[cls], prev_status_cls.get(cls))734            if d is not None:735                it["delta_pct"] = d736            items.append(it)737        breakdowns.append(738            {739                "id": "status",740                "title": "Appels par classe de statut HTTP",741                "kind": "donut",742                "items": items,743            }744        )745        meth_counts: dict[str, int] = {}746        for r in reqs:747            meth_counts[r[1]] = meth_counts.get(r[1], 0) + 1748        items = []749        for m, c in sorted(meth_counts.items(), key=lambda kv: kv[1], reverse=True):750            it = {"label": m, "value": c}751            d = _delta(c, prev_methods.get(m))752            if d is not None:753                it["delta_pct"] = d754            items.append(it)755        breakdowns.append(756            {757                "id": "methods",758                "title": "Appels par méthode HTTP",759                "kind": "bars",760                "items": items,761            }762        )763    if svc_records:764        prev_svc: dict[str, int] = {}765        for r in prev_ok:766            prev_svc[r.service] = prev_svc.get(r.service, 0) + r.records_count767        items = []768        for s, v in sorted(svc_records.items(), key=lambda kv: kv[1], reverse=True):769            it = {"label": SERVICE_NAMES.get(s, s), "value": v}770            d = _delta(v, prev_svc.get(s))771            if d is not None:772                it["delta_pct"] = d773            items.append(it)774        breakdowns.append(775            {776                "id": "services",777                "title": "Enregistrements collectés par service",778                "kind": "donut",779                "items": items,780            }781        )782783    # -------- distributions (histogrammes)784    distributions: list[dict[str, Any]] = []785    if durations:786        bins = _hist(durations, LAT_BINS)787        if bins:788            distributions.append(789                {790                    "id": "latency_hist",791                    "title": "Distribution des latences (temps de réponse)",792                    "unit": "appels",793                    "bins": bins,794                }795            )796    run_durs = [r.duration_seconds for r in runs]797    if run_durs:798        bins = _hist(run_durs, DUR_BINS)799        if bins:800            distributions.append(801                {802                    "id": "rundur_hist",803                    "title": "Distribution des durées de collecte",804                    "unit": "runs",805                    "bins": bins,806                }807            )808809    # -------- heatmap calendrier : appels API par jour810    heatmap = None811    if reqs and not hourly:812        cells = [813            {"date": k.isoformat(), "value": len(req_bucket[k])}814            for k in bucket_keys815            if req_bucket[k]816        ]817        if cells:818            heatmap = {"title": "Appels API par jour", "cells": cells}819820    # -------- heatmap horaire 7×24 : appels par jour de semaine × heure821    hourly_map = None822    if reqs:823        grid: dict[tuple[int, int], int] = {}824        for r in reqs:825            key = (r[0].weekday(), r[0].hour)  # 0 = lundi (contrat SPEC)826            grid[key] = grid.get(key, 0) + 1827        if grid:828            hourly_map = {829                "title": "Appels API par jour de semaine et heure",830                "cells": [831                    {"dow": dw, "hour": h, "value": v}832                    for (dw, h), v in sorted(grid.items())833                ],834            }835836    # -------- tableaux837    tables: list[dict[str, Any]] = []838    if top_eps:839        tables.append(840            {841                "id": "endpoints",842                "title": "Endpoints — appels, latence et erreurs",843                "columns": [844                    "Endpoint",845                    "Appels",846                    "Latence moy. (ms)",847                    "p95 (ms)",848                    "Erreurs",849                    "Taux d'erreur",850                ],851                "rows": [852                    [853                        ep,854                        s["calls"],855                        round(sum(s["durs"]) / len(s["durs"]), 1),856                        round(_p95(s["durs"]), 1),857                        s["errors"],858                        f"{s['errors'] / s['calls'] * 100:.1f} %".replace(".", ","),859                    ]860                    for ep, s in top_eps861                ],862            }863        )864    if reqs and len(bucket_keys) > 1:865        rows = []866        for i, k in enumerate(bucket_keys):867            rs = req_bucket[k]868            if not rs and hourly:869                continue870            durs = [r[4] for r in rs]871            rows.append(872                [873                    bucket_labels[i],874                    len(rs),875                    sum(1 for r in rs if r[3] >= 400),876                    round(sum(durs) / len(durs), 1) if durs else 0,877                    round(_p95(durs), 1) if durs else 0,878                ]879            )880        tables.append(881            {882                "id": "daily",883                "title": "Activité par heure" if hourly else "Activité par jour",884                "columns": [885                    "Heure" if hourly else "Date",886                    "Appels",887                    "Erreurs",888                    "Latence moy. (ms)",889                    "p95 (ms)",890                ],891                "rows": rows,892            }893        )894    if runs:895        svc_agg: dict[str, dict[str, Any]] = {}896        for r in runs:897            a = svc_agg.setdefault(898                r.service, {"runs": 0, "ok": 0, "failed": 0, "records": 0, "durs": []}899            )900            a["runs"] += 1901            if r.status in ("success", "retried"):902                a["ok"] += 1903                a["records"] += r.records_count904            else:905                a["failed"] += 1906            a["durs"].append(r.duration_seconds)907        tables.append(908            {909                "id": "services",910                "title": "Services KA — collectes de la période",911                "columns": [912                    "Service",913                    "Runs",914                    "Réussis",915                    "Échoués",916                    "Enregistrements",917                    "Durée moy. (s)",918                ],919                "rows": [920                    [921                        SERVICE_NAMES.get(s, s),922                        a["runs"],923                        a["ok"],924                        a["failed"],925                        a["records"],926                        round(sum(a["durs"]) / len(a["durs"]), 1),927                    ]928                    for s, a in sorted(929                        svc_agg.items(), key=lambda kv: kv[1]["records"], reverse=True930                    )931                ],932            }933        )934        tables.append(935            {936                "id": "runs",937                "title": "Derniers runs de collecte",938                "columns": ["Service", "Date", "Statut", "Enregistrements", "Durée (s)"],939                "rows": [940                    [941                        SERVICE_NAMES.get(r.service, r.service),942                        r.date_key.isoformat(),943                        RUN_STATUS_FR.get(r.status, r.status),944                        r.records_count,945                        round(r.duration_seconds, 1),946                    ]947                    for r in runs[:80]948                ],949            }950        )951    if health:952        tables.append(953            {954                "id": "connectors",955                "title": "État des connecteurs supervisés (temps réel)",956                "columns": [957                    "Service",958                    "Source",959                    "Statut",960                    "Dernier succès",961                    "Échecs consécutifs",962                ],963                "rows": [964                    [965                        SERVICE_NAMES.get(h.service, h.service),966                        h.source,967                        HEALTH_FR.get(h.status, h.status),968                        (969                            h.last_success.astimezone(TZ).strftime("%Y-%m-%d %H:%M")970                            if h.last_success971                            else "—"972                        ),973                        h.consecutive_failures,974                    ]975                    for h in sorted(976                        health,977                        key=lambda h: (h.status == "ok", h.service, h.source),978                    )979                ],980            }981        )982983    # -------- records & faits marquants (6-12, générés depuis les données)984    records: list[dict[str, Any]] = []985    if reqs and not hourly:986        best_day = max(bucket_keys, key=lambda k: len(req_bucket[k]))987        if req_bucket[best_day]:988            records.append(989                {990                    "label": "Jour record d'appels API",991                    "value": f"{_fr_int(len(req_bucket[best_day]))} appels",992                    "date": best_day.isoformat(),993                }994            )995    if hourly_map:996        top_cell = max(hourly_map["cells"], key=lambda c: c["value"])997        dows = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]998        records.append(999            {1000                "label": "Créneau horaire le plus actif",1001                "value": (1002                    f"{dows[top_cell['dow']]} {top_cell['hour']:02d}h — "1003                    f"{_fr_int(top_cell['value'])} appels"1004                ),1005            }1006        )1007    if top_eps:1008        records.append(1009            {1010                "label": "Endpoint le plus sollicité",1011                "value": f"{top_eps[0][0]} — {_fr_int(top_eps[0][1]['calls'])} appels",1012            }1013        )1014        grow = [1015            (ep, _delta(s["calls"], prev_ep_calls.get(ep)))1016            for ep, s in top_eps1017            if prev_ep_calls.get(ep, 0) >= 51018        ]1019        grow = [(ep, d) for ep, d in grow if d is not None and d > 0]1020        if grow:1021            ep, d = max(grow, key=lambda kv: kv[1])1022            records.append(1023                {1024                    "label": "Plus forte croissance d'endpoint",1025                    "value": f"{ep} — +{_fr_num(d)} %",1026                }1027            )1028    if durations:1029        records.append(1030            {1031                "label": "Réponse la plus rapide de la période",1032                "value": f"{_fr_num(round(min(durations), 1))} ms",1033            }1034        )1035        records.append(1036            {1037                "label": "Réponse la plus lente de la période",1038                "value": f"{_fr_num(round(max(durations), 1))} ms",1039            }1040        )1041    if reqs and not hourly:1042        clean_days = [1043            k1044            for k in bucket_keys1045            if req_bucket[k] and not any(r[3] >= 400 for r in req_bucket[k])1046        ]1047        if clean_days:1048            best = max(clean_days, key=lambda k: len(req_bucket[k]))1049            records.append(1050                {1051                    "label": "Meilleure journée sans erreur HTTP",1052                    "value": f"{_fr_int(len(req_bucket[best]))} appels, 0 erreur",1053                    "date": best.isoformat(),1054                }1055            )1056    if ok_runs:1057        biggest = max(ok_runs, key=lambda r: r.records_count)1058        records.append(1059            {1060                "label": "Run de collecte le plus volumineux",1061                "value": (1062                    f"{_fr_int(biggest.records_count)} enregistrements "1063                    f"({SERVICE_NAMES.get(biggest.service, biggest.service)})"1064                ),1065                "date": biggest.date_key.isoformat(),1066            }1067        )1068        fastest = min(ok_runs, key=lambda r: r.duration_seconds)1069        records.append(1070            {1071                "label": "Collecte la plus rapide",1072                "value": (1073                    f"{fastest.duration_seconds:.1f} s "1074                    f"({SERVICE_NAMES.get(fastest.service, fastest.service)})"1075                ).replace(".", ","),1076                "date": fastest.date_key.isoformat(),1077            }1078        )1079    total_logged = db.execute(select(func.count(ApiRequest.id))).scalar() or 01080    if total_logged:1081        records.append(1082            {1083                "label": "Appels journalisés au total (90 jours de rétention)",1084                "value": f"{_fr_int(total_logged)} appels",1085            }1086        )10871088    # -------- couverture de mesure (depuis quand les appels sont journalisés)1089    first_req = db.execute(select(func.min(ApiRequest.ts))).scalar()1090    if first_req is not None and first_req.tzinfo is None:1091        first_req = first_req.replace(tzinfo=datetime.UTC)10921093    dash: dict[str, Any] = {1094        "updated": datetime.datetime.now(tz=TZ).isoformat(timespec="seconds"),1095        "period": {"from": d_from.isoformat(), "to": d_to.isoformat(), "label": label},1096        "kpis": kpis,1097        "series": series,1098        "breakdowns": breakdowns,1099        "tables": tables,1100        "records": records,1101        "coverage": {1102            "api_requests_since": (1103                first_req.astimezone(TZ).isoformat(timespec="seconds")1104                if first_req1105                else None1106            )1107        },1108    }1109    if gauges:1110        dash["gauges"] = gauges1111    if multiseries:1112        dash["multiseries"] = multiseries1113    if stacked:1114        dash["stacked"] = stacked1115    if distributions:1116        dash["distributions"] = distributions1117    if heatmap:1118        dash["heatmap"] = heatmap1119    if hourly_map:1120        dash["hourly"] = hourly_map1121    return dash112211231124def _dashboard_cached(1125    db: Session,1126    period: str,1127    date_from: datetime.date | None,1128    date_to: datetime.date | None,1129) -> dict[str, Any]:1130    d_from, d_to, label = _resolve_period(period, date_from, date_to, db)1131    key = (period, d_from.isoformat(), d_to.isoformat())1132    now = time.monotonic()1133    with _cache_lock:1134        hit = _cache.get(key)1135        if hit and now - hit[0] < CACHE_TTL:1136            return hit[1]1137    dash = _build_dashboard(db, period, d_from, d_to, label)1138    with _cache_lock:1139        if len(_cache) > 64:1140            _cache.clear()1141        _cache[key] = (now, dash)1142    return dash114311441145def _normalize_mode(mode: str | None) -> str:1146    """SPEC v2 : un mode inconnu ou absent retombe sur ``complet``."""1147    m = (mode or "").strip().lower()1148    return m if m in kapdf.REPORT_MODES else "complet"114911501151# ---------------------------------------------------------------- routes1152@router.get("")1153def stats_summary(db: Session = Depends(get_db)) -> dict[str, Any]:1154    """Résumé compact de la plateforme (contrat léger /api/stats commun KA).11551156    Version allégée du tableau de bord pour les moniteurs et les apps sœurs :1157    activité API 7 jours, runs de collecte, dernier run par service et santé1158    des connecteurs supervisés.1159    """1160    today = datetime.datetime.now(TZ).date()1161    d_from = today - datetime.timedelta(days=6)1162    reqs = _fetch_requests(db, d_from, today)1163    runs = _fetch_runs(db, d_from, today)1164    health = _fetch_health(db)1165    errors = sum(1 for r in reqs if r[3] >= 400)1166    ok_runs = [r for r in runs if r.status in ("success", "retried")]1167    last_run: dict[str, Any] = {}1168    for r in runs:  # triés par started_at décroissant1169        if r.service not in last_run:1170            last_run[r.service] = {1171                "date": r.date_key.isoformat(),1172                "status": r.status,1173                "records": r.records_count,1174            }1175    connectors = dict.fromkeys(("ok", "degraded", "broken", "stale"), 0)1176    for h in health:1177        if h.status in connectors:1178            connectors[h.status] += 11179    return envelope(1180        {1181            "platform": "API-KA",1182            "period": {"from": d_from.isoformat(), "to": today.isoformat()},1183            "api": {1184                "calls_7d": len(reqs),1185                "error_rate_7d": round(errors / len(reqs) * 100, 2) if reqs else 0.0,1186            },1187            "runs_7d": {1188                "total": len(runs),1189                "ok": len(ok_runs),1190                "failed": sum(1 for r in runs if r.status == "failed"),1191                "records": sum(r.records_count for r in ok_runs),1192            },1193            "last_run": last_run,1194            "connectors": connectors,1195        }1196    )119711981199@router.get("/dashboard")1200def stats_dashboard(1201    period: str = Query("30j", description="auj, 7j, 30j, 3m, 6m, 12m, annee, tout"),1202    date_from: datetime.date | None = Query(None, alias="from"),1203    date_to: datetime.date | None = Query(None, alias="to"),1204    db: Session = Depends(get_db),1205) -> dict[str, Any]:1206    """Tableau de bord analytique de la plateforme (contrat commun Groupe KA)."""1207    return envelope(_dashboard_cached(db, period, date_from, date_to))120812091210@router.get("/report")1211def stats_report(1212    period: str = Query("30j", description="auj, 7j, 30j, 3m, 6m, 12m, annee, tout"),1213    date_from: datetime.date | None = Query(None, alias="from"),1214    date_to: datetime.date | None = Query(None, alias="to"),1215    mode: str = Query(1216        "complet",1217        description="complet, synthese, tendances, repartitions ou donnees",1218    ),1219    db: Session = Depends(get_db),1220) -> Response:1221    """Rapport statistique PDF estampillé Groupe-KA (5 rapports, SPEC v2)."""1222    mode = _normalize_mode(mode)1223    dash = _dashboard_cached(db, period, date_from, date_to)1224    pdf_bytes = kapdf.GroupeKAReport(site=SITE, dashboard=dash, mode=mode).build()1225    fname = kapdf.filename(PLATFORM_ID, period, mode)1226    return Response(1227        content=pdf_bytes,1228        media_type="application/pdf",1229        headers={"Content-Disposition": f'attachment; filename="{fname}"'},1230    )123112321233@router.get("/catalog")1234def stats_catalog(1235    period: str = Query("30j", description="auj, 7j, 30j, 3m, 6m, 12m, annee, tout"),1236    date_from: datetime.date | None = Query(None, alias="from"),1237    date_to: datetime.date | None = Query(None, alias="to"),1238    db: Session = Depends(get_db),1239) -> dict[str, Any]:1240    """v3 — blocs composables pour le constructeur de rapports personnalisés."""1241    dash = _dashboard_cached(db, period, date_from, date_to)1242    return {1243        "updated": dash.get("updated"),1244        "period": dash.get("period"),1245        "blocks": kapdf.catalog(dash),1246    }124712481249@router.post("/report/custom")1250def stats_report_custom(1251    spec: dict = Body(...),1252    db: Session = Depends(get_db),1253) -> Response:1254    """v3 — rapport PDF personnalisé : ``{"title", "period", "from", "to",1255    "blocks": [{"key": "series:…", "render": "bar"}, …]}`` (SPEC.md §3bis)."""1256    period = str(spec.get("period") or "30j")1257    if period not in PERIOD_LABELS:1258        period = "30j"12591260    def _date(v: Any) -> datetime.date | None:1261        try:1262            return datetime.date.fromisoformat(str(v)) if v else None1263        except ValueError:1264            return None12651266    dash = _dashboard_cached(db, period, _date(spec.get("from")), _date(spec.get("to")))1267    known = {b["key"] for b in kapdf.catalog(dash)}1268    blocks = [1269        b for b in (spec.get("blocks") or []) if isinstance(b, dict) and b.get("key") in known1270    ][:40]1271    if not blocks:1272        raise HTTPException(400, "Aucun bloc valide dans la composition")1273    pdf_bytes = kapdf.GroupeKAReport(1274        site=SITE,1275        dashboard=dash,1276        mode=kapdf.CUSTOM_MODE,1277        spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},1278    ).build()1279    fname = kapdf.filename(PLATFORM_ID, period, kapdf.CUSTOM_MODE)1280    return Response(1281        content=pdf_bytes,1282        media_type="application/pdf",1283        headers={"Content-Disposition": f'attachment; filename="{fname}"'},1284    )128512861287# ------------------------------------------------- rapport écosystème (PDF)1288async def _fetch_satellite_dashboard(1289    client: httpx.AsyncClient, site: dict, period: str1290) -> dict[str, Any]:1291    """Dashboard d'une plateforme sœur — jamais d'exception : une plateforme1292    injoignable est retournée avec ``dashboard=None`` et un motif lisible."""1293    url = f"https://{site['domain']}/api/stats/dashboard"1294    try:1295        resp = await client.get(url, params={"period": period})1296        resp.raise_for_status()1297        payload = resp.json()1298        # certaines plateformes enveloppent la réponse ({success, data, meta})1299        if (1300            isinstance(payload, dict)1301            and "kpis" not in payload1302            and isinstance(payload.get("data"), dict)1303        ):1304            payload = payload["data"]1305        if not isinstance(payload, dict) or not payload.get("kpis"):1306            raise ValueError("réponse hors contrat (kpis manquants)")1307        return {"site": site, "dashboard": payload, "error": None}1308    except httpx.TimeoutException:1309        return {"site": site, "dashboard": None, "error": "délai dépassé (15 s)"}1310    except httpx.HTTPStatusError as exc:1311        return {1312            "site": site,1313            "dashboard": None,1314            "error": f"HTTP {exc.response.status_code}",1315        }1316    except Exception as exc:  # noqa: BLE001 — motif affiché dans le PDF1317        return {1318            "site": site,1319            "dashboard": None,1320            "error": (str(exc) or exc.__class__.__name__)[:80],1321        }132213231324@router.get("/ecosystem-report")1325async def stats_ecosystem_report(1326    period: str = Query("30j", description="auj, 7j, 30j, 3m, 6m, 12m, annee, tout"),1327    mode: str | None = Query(1328        None,1329        description=(1330            "complet, synthese, tendances, repartitions ou donnees "1331            "(optionnel — inconnu ou absent = complet)"1332        ),1333    ),1334    db: Session = Depends(get_db),1335) -> Response:1336    """Rapport écosystème Groupe-KA : UN SEUL PDF consolidant les 12 plateformes.13371338    Les dashboards des plateformes sœurs sont récupérés en parallèle1339    (httpx, délai 15 s), celui d'API-KA est calculé en local. Le ``mode``1340    (5 rapports du contrat v2) est passé au moteur ; un mode inconnu ou1341    absent retombe sur ``complet``. Cache 10 min par (période, mode)."""1342    mode = _normalize_mode(mode)1343    if period not in PERIOD_LABELS:1344        raise HTTPException(1345            status_code=422,1346            detail=f"Période invalide : {period}. Valides : {', '.join(PERIOD_LABELS)}",1347        )13481349    fname = kapdf.filename("ecosysteme", period, mode)13501351    def _pdf_response(pdf_bytes: bytes, fname: str) -> Response:1352        return Response(1353            content=pdf_bytes,1354            media_type="application/pdf",1355            headers={"Content-Disposition": f'attachment; filename="{fname}"'},1356        )13571358    key = (period, mode)1359    now = time.monotonic()1360    with _eco_lock:1361        hit = _eco_cache.get(key)1362        if hit and now - hit[0] < ECO_CACHE_TTL:1363            return _pdf_response(hit[1], hit[2])13641365    satellites = [s for s in ECO_SITES if s["id"] != PLATFORM_ID]1366    own_site = next(s for s in ECO_SITES if s["id"] == PLATFORM_ID)1367    async with httpx.AsyncClient(1368        timeout=ECO_FETCH_TIMEOUT,1369        follow_redirects=True,1370        headers={1371            "accept": "application/json",1372            "user-agent": "apika-ecosystem-report/1.0 (+https://www.api-ka.com)",1373        },1374    ) as client:1375        results = await asyncio.gather(1376            *(_fetch_satellite_dashboard(client, s, period) for s in satellites)1377        )13781379    def _build() -> bytes:1380        try:1381            own = {1382                "site": own_site,1383                "dashboard": _dashboard_cached(db, period, None, None),1384                "error": None,1385            }1386        except Exception as exc:  # noqa: BLE0011387            own = {"site": own_site, "dashboard": None, "error": str(exc)[:80]}1388        platforms = [*results, own]1389        eco_period = next(1390            (1391                pl["dashboard"]["period"]1392                for pl in platforms1393                if pl["dashboard"] and pl["dashboard"].get("period")1394            ),1395            {"label": PERIOD_LABELS[period]},1396        )1397        return ecopdf.EcosystemReport(1398            platforms=platforms, period=eco_period, mode=mode1399        ).build()14001401    pdf_bytes = await asyncio.to_thread(_build)1402    with _eco_lock:1403        if len(_eco_cache) > 32:1404            _eco_cache.clear()1405        _eco_cache[key] = (now, pdf_bytes, fname)1406    return _pdf_response(pdf_bytes, fname)1407