API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com
Python 60.9%
HTML 21%
TypeScript 7.3%
JavaScript 5.2%
CSS 4.8%
Shell 0.8%
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