From 25ec80c8e471dcf74a772bb54bd6cd2f33a624c3 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 25 Jul 2026 15:09:34 +0000 Subject: [PATCH] feat(statistik): Hauptreiter Statistik mit kompletter Betriebsstatistik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Reiter zwischen Audit-Log und Verbrauchsrechner, Anlass war die Dashboard-Kachel API-Kosten gesamt, die nirgendwo hinführte (sie springt jetzt hierher). Kennzahlen-Set nach Betreiber-Best-Practices für nutzungsbasierte KI-Produkte (Kostenverlauf, Marge je Kunde, Betrieb). - GET /api/statistik?months=N liefert alles in einem JSON. Kosten und Läufe je Monat und Lagentyp (führende Quelle refresh_log mit lückenlosem incidents-Join), Quellen-Split aus token_usage_monthly, Erfolgsquote, normalisiertes Fehler-Ranking, Dauer je Typ, Wachstum, MAU/DAU aus user_activity_days und je Organisation Ist-Kosten plus Margen-Schätzung zu heutigen Sätzen (Recherche-Anlage zählt dreifach, weil der Multi-Pass nur eine Log-Zeile erzeugt). Aggregation als pure, unit-getestete Funktionen, Monatsachse lückenlos gefüllt, Mediane nur über Läufe mit Kosten (Erfassung begann erst 03/2026). - statistik.js injiziert sich selbst nach dem rechner.js-Muster, Platzierung doppelt gesichert (Ladereihenfolge mit async=false plus insertBefore). Charts als Inline-SVG ohne Bibliothek, eine Achse je Chart, Hover-Titel mit großen Trefferflächen, Legenden, Serienfarben je Theme mit dem dataviz-Palette-Validator geprüft (alle Checks PASS). Zeitraum 6/12/Alles, Datenstand-Hinweis, ehrliche Fußnote unter der Margen-Tabelle, Leerzustand für die junge Aktivitäts-Erfassung. - Migration user_activity_days für die Portal-Staging-DB (auf Live legt sie der Monitor an, dort läuft die Erfassung in get_current_user). - 10 neue Unit-Tests plus Auth-Smoke für den Endpoint, 96 Tests grün (die zwei bekannten shared-Import-Fehlschläge bestehen unverändert). Gegenstück im Monitor: Commit d367c60 (Aktivitäts-Erfassung). Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 3 + migrations/2026-07-25_user_activity.py | 46 +++ src/main.py | 3 +- src/routers/statistik.py | 413 +++++++++++++++++++++++ src/static/dashboard.html | 2 +- src/static/js/app.js | 26 +- src/static/js/statistik.js | 440 +++++++++++++++++++++++++ tests/test_api_smoke.py | 1 + tests/test_statistik.py | 93 ++++++ 9 files changed, 1020 insertions(+), 7 deletions(-) create mode 100644 migrations/2026-07-25_user_activity.py create mode 100644 src/routers/statistik.py create mode 100644 src/static/js/statistik.js create mode 100644 tests/test_statistik.py diff --git a/CLAUDE.md b/CLAUDE.md index 7f20cf1..96c4363 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ src/: dashboard.py: "Aggregat-Endpoints für Übersichts-Tab" token_usage.py: "Token-Verbrauch pro Org/Monat, Credits-Stellwerte je Lizenz (PUT /budget). Laufzeitfelder (credits_used-Reset, credits_carried, credits_period_start, budget_warning_sent) verwaltet der MONITOR, das Portal setzt nur Stellwerte" pricing.py: "Preis-Empfehlung aus Ist-Kosten (GET /cost-basis) und pflegbare Preistabelle billing_tariff (GET/PUT /tariff). Die Tabelle ist die Quelle der Buchung im Monitor, der Rechner kann sie speichern" + statistik.py: "Betriebsstatistik (GET /api/statistik?months=N). Kosten/Marge je Monat und Org, Betrieb, Fehler-Ranking, MAU/DAU aus user_activity_days, Wachstum. Aggregation als pure Funktionen (unit-testbar)" audit.py: "Audit-Log-Abfrage, Filter" email_utils/: @@ -66,12 +67,14 @@ src/: js/: app.js: "Hauptlogik, Login, Tab-Switching, Dashboard-Render, ThemeManager (Dark/Light, localStorage portal_theme)" a11y.js: "Barrierefreiheits-Panel (Kontrast, Focus, Schrift, Animationen). IDENTISCHE Datei wie im Monitor-Repo, bei Aenderungen dort mitziehen" + statistik.js: "Statistik-Tab, selbst-injizierend wie rechner.js. Inline-SVG-Charts (keine Bibliothek), Serienfarben mit dem dataviz-Validator gegen beide Themes geprüft" sources.js: "Grundquellen + Kundenquellen Management" source-health.js: "Quellen-Health & KI-Vorschläge" audit.js: "Audit-Log Tab" migrations/: einmal_migrationen: "Backfill-Skripte (DE-Übersetzungen, Umlaute, HTML-Strip etc.)" + 2026-07-25_user_activity.py: "Tabelle user_activity_days anlegen (MAU/DAU). Nötig für die Portal-Staging-DB, auf Live legt sie der Monitor an." 2026-07-25_billing_tariff.py: "Preistabelle billing_tariff anlegen und mit den beschlossenen Sätzen befüllen (idempotent, überschreibt nichts). Nötig für die Portal-Staging-DB, auf Live legt sie der Monitor an." 2026-07-25_credits_period.py: "Credits-Perioden-Spalten in licenses (idempotent, identisch zur Monitor-Migration). Nötig für die Portal-Staging-DB, auf Live legt sie normalerweise der Monitor an. Reihenfolge: Monitor VOR Portal promoten." ``` diff --git a/migrations/2026-07-25_user_activity.py b/migrations/2026-07-25_user_activity.py new file mode 100644 index 0000000..e7177d5 --- /dev/null +++ b/migrations/2026-07-25_user_activity.py @@ -0,0 +1,46 @@ +"""Migration 2026-07-25: Tabelle user_activity_days anlegen (MAU/DAU). + +Der Monitor legt die Tabelle beim Start selbst an und schreibt je Nutzer und +Kalendertag einen Eintrag (auth.get_current_user). Die Portal-Staging-Umgebung +nutzt eine EIGENE Datenbank ohne laufenden Monitor, deshalb dieses Skript. +Idempotent (CREATE TABLE IF NOT EXISTS), es werden keine Daten eingefügt. + +Ausführung: + DB_PATH=/home/claude-dev/AegisSight-Monitor-staging/data/osint.db python3 migrations/2026-07-25_user_activity.py + DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-07-25_user_activity.py +""" +import os +import sqlite3 +import sys + + +def main(db_path: str) -> int: + if not os.path.exists(db_path): + print(f"FEHLER: DB nicht gefunden: {db_path}", file=sys.stderr) + return 1 + + conn = sqlite3.connect(db_path, timeout=60) + conn.execute("PRAGMA busy_timeout = 60000") + conn.execute("PRAGMA journal_mode = WAL") + + print(f"Migration auf {db_path}") + + conn.execute(""" + CREATE TABLE IF NOT EXISTS user_activity_days ( + user_id INTEGER NOT NULL, + day TEXT NOT NULL, + tenant_id INTEGER, + PRIMARY KEY (user_id, day) + ) + """) + print(" + user_activity_days angelegt (oder vorhanden)") + + conn.commit() + conn.close() + print("Migration abgeschlossen.") + return 0 + + +if __name__ == "__main__": + db_path = os.environ.get("DB_PATH", "/home/claude-dev/osint-data/osint.db") + sys.exit(main(db_path)) diff --git a/src/main.py b/src/main.py index b5e20fb..0eca26d 100644 --- a/src/main.py +++ b/src/main.py @@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from config import STATIC_DIR, PORT -from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit, translation, x_scraper, pricing +from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit, translation, x_scraper, pricing, statistik logging.basicConfig( level=logging.INFO, @@ -65,6 +65,7 @@ app.include_router(audit.router) app.include_router(translation.router) app.include_router(x_scraper.router) app.include_router(pricing.router) +app.include_router(statistik.router) # --- Statische Dateien --- app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") diff --git a/src/routers/statistik.py b/src/routers/statistik.py new file mode 100644 index 0000000..29cc30e --- /dev/null +++ b/src/routers/statistik.py @@ -0,0 +1,413 @@ +"""Betriebsstatistik für den Statistik-Reiter. + +Ein Endpoint liefert alle Serien und Kennzahlen in einem JSON. Die +Aggregation passiert in puren Funktionen (ohne DB-Zugriff), damit sie sich +als Unit-Tests prüfen lässt. Führende Quelle für Monitor-Läufe ist +refresh_log (hat Lagentyp, Kosten, Dauer je Lauf), token_usage_monthly +ergänzt die übrigen Quellen (globe, chat, enhance). + +Ehrlichkeits-Regeln der Margen-Schätzung (keine historisierten Buchungen): +- Bewertet wird zu den HEUTIGEN Sätzen aus billing_tariff. +- Die Anlage einer Recherche zählt als 3 Durchläufe (Preismodell), erscheint + im refresh_log aber nur als eine Zeile. Deshalb zählt der erste + abgeschlossene Lauf je Recherche-Lage dreifach. +- analysis/factcheck haben keine Nutzungsdaten und fehlen (Schätzung eher + zu niedrig). Ist-Kosten enthalten auch Fehlläufe. +""" +import logging +import re +import statistics +from collections import defaultdict +from datetime import datetime, timedelta + +from fastapi import APIRouter, Depends, Query + +from auth import get_current_admin +from database import get_db +from routers.pricing import TARIFF_DEFAULTS, USD_PER_CREDIT + +logger = logging.getLogger("verwaltung.statistik") +router = APIRouter(prefix="/api/statistik", tags=["Statistik"]) + +_RETRY_PREFIX = re.compile(r"^Endgültig fehlgeschlagen nach \d+ Versuchen:\s*") + + +# -------------------------------------------------------------------------- +# Pure Funktionen (ohne DB, unit-testbar) +# -------------------------------------------------------------------------- + +def baue_monatsachse(von_ym: str, bis_ym: str) -> list[str]: + """Lückenlose Liste 'YYYY-MM' von von_ym bis bis_ym (einschließlich).""" + if not von_ym or not bis_ym or von_ym > bis_ym: + return [] + y, m = int(von_ym[:4]), int(von_ym[5:7]) + ey, em = int(bis_ym[:4]), int(bis_ym[5:7]) + achse = [] + while (y, m) <= (ey, em): + achse.append(f"{y:04d}-{m:02d}") + m += 1 + if m == 13: + y, m = y + 1, 1 + return achse + + +def normalisiere_fehler(msg: str | None) -> str: + """Fehlermeldungen für das Ranking zusammenfassen.""" + if not msg: + return "Unbekannt" + msg = _RETRY_PREFIX.sub("", msg).strip() + if msg.startswith("Verwaist"): + return "Verwaist (Neustart, automatisch bereinigt)" + return msg[:120] + + +def aggregiere_refresh(rows: list[dict], achse: list[str]) -> dict: + """Aggregiert refresh_log-Detailzeilen zu KPI, Monat×Typ, Betrieb, Fehlern. + + Erwartete Row-Schlüssel: monat, typ, status, trigger_type, total_cost_usd, + input_tokens, output_tokens, articles_found, error_message, dauer_s. + Mediane und Durchschnittskosten nur über Läufe mit Kosten > 0 (die + Kostenerfassung begann erst später, Nullen würden verzerren). + """ + typen = ("adhoc", "research") + mt = {(m, t): {"laeufe": 0, "kosten": 0.0, "tokens_in": 0, "tokens_out": 0, + "artikel": 0, "_kosten_liste": [], "_dauern": []} + for m in achse for t in typen} + betrieb = {m: {"completed": 0, "error": 0, "cancelled": 0, "auto": 0, "manual": 0} + for m in achse} + fehler: dict[str, int] = defaultdict(int) + dauer_typ: dict[str, list] = {t: [] for t in typen} + kpi = {"kosten_usd": 0.0, "laeufe_ok": 0, "laeufe_gesamt": 0, + "artikel": 0, "tokens_in": 0, "tokens_out": 0, "_kosten_liste": []} + + for r in rows: + monat = r.get("monat") + typ = r.get("typ") if r.get("typ") in typen else "adhoc" + status = r.get("status") + kosten = r.get("total_cost_usd") or 0.0 + + kpi["laeufe_gesamt"] += 1 + kpi["kosten_usd"] += kosten + kpi["tokens_in"] += r.get("input_tokens") or 0 + kpi["tokens_out"] += r.get("output_tokens") or 0 + + if monat in betrieb: + b = betrieb[monat] + if status in ("completed", "error", "cancelled"): + b[status] += 1 + if status == "completed": + if r.get("trigger_type") == "auto": + b["auto"] += 1 + else: + b["manual"] += 1 + + if status == "error": + fehler[normalisiere_fehler(r.get("error_message"))] += 1 + + if status != "completed": + continue + + kpi["laeufe_ok"] += 1 + kpi["artikel"] += r.get("articles_found") or 0 + if kosten > 0: + kpi["_kosten_liste"].append(kosten) + + dauer = r.get("dauer_s") + if dauer is not None and 0 < dauer < 86400: + dauer_typ[typ].append(dauer) + + key = (monat, typ) + if key in mt: + z = mt[key] + z["laeufe"] += 1 + z["kosten"] += kosten + z["tokens_in"] += r.get("input_tokens") or 0 + z["tokens_out"] += r.get("output_tokens") or 0 + z["artikel"] += r.get("articles_found") or 0 + if kosten > 0: + z["_kosten_liste"].append(kosten) + if dauer is not None and 0 < dauer < 86400: + z["_dauern"].append(dauer) + + monat_typ = [] + for m in achse: + for t in typen: + z = mt[(m, t)] + monat_typ.append({ + "monat": m, "typ": t, + "laeufe": z["laeufe"], + "kosten": round(z["kosten"], 2), + "kosten_median": round(statistics.median(z["_kosten_liste"]), 2) if z["_kosten_liste"] else None, + "tokens_in": z["tokens_in"], "tokens_out": z["tokens_out"], + "artikel": z["artikel"], + "dauer_avg_s": round(sum(z["_dauern"]) / len(z["_dauern"])) if z["_dauern"] else None, + }) + + gesamt = kpi["laeufe_gesamt"] + ergebnis_kpi = { + "kosten_usd": round(kpi["kosten_usd"], 2), + "laeufe_ok": kpi["laeufe_ok"], + "laeufe_gesamt": gesamt, + "erfolgsquote": round(kpi["laeufe_ok"] / gesamt * 100, 1) if gesamt else None, + "median_kosten_lauf": round(statistics.median(kpi["_kosten_liste"]), 2) if kpi["_kosten_liste"] else None, + "artikel": kpi["artikel"], + "tokens_in": kpi["tokens_in"], + "tokens_out": kpi["tokens_out"], + } + fehler_top = [{"grund": g, "n": n} + for g, n in sorted(fehler.items(), key=lambda x: -x[1])[:5]] + dauer_je_typ = {t: (round(sum(v) / len(v)) if v else None) for t, v in dauer_typ.items()} + + return { + "kpi": ergebnis_kpi, + "monat_typ": monat_typ, + "monat_betrieb": [{"monat": m, **betrieb[m]} for m in achse], + "fehler_top": fehler_top, + "dauer_typ": dauer_je_typ, + } + + +def berechne_credits(adhoc_ok: int, research_ok: int, research_erstlaeufe: int, + neben_aufrufe: dict, tarif: dict) -> float: + """Geschätzter Credits-Gegenwert zu heutigen Sätzen. + + Die Anlage einer Recherche (= erster abgeschlossener Lauf) zählt dreifach, + deshalb +2 zusätzliche Durchläufe je Erstlauf. neben_aufrufe ist + {quelle: api_calls} für chat/enhance/globe. + """ + credits = adhoc_ok * tarif.get("monitor_adhoc", 0) + credits += (research_ok + 2 * research_erstlaeufe) * tarif.get("monitor_research", 0) + for quelle, aufrufe in (neben_aufrufe or {}).items(): + credits += (aufrufe or 0) * tarif.get(quelle, 0) + return round(credits, 2) + + +def aggregiere_orgs(rows_orgs: list[dict], rows_neben: list[dict], + org_namen: dict, tarif: dict, usd_per_credit: float) -> list[dict]: + """Baut die Organisations-Tabelle mit Ist-Kosten und Margen-Schätzung.""" + neben: dict[int, dict] = defaultdict(lambda: {"aufrufe": {}, "kosten": 0.0}) + for r in rows_neben: + org = neben[r["organization_id"]] + org["aufrufe"][r["source"]] = (org["aufrufe"].get(r["source"], 0) or 0) + (r["api_calls"] or 0) + org["kosten"] += r["kosten"] or 0.0 + + ergebnis = [] + alle_ids = set(org_namen) | {r["tenant_id"] for r in rows_orgs} | set(neben) + orgs_map = {r["tenant_id"]: r for r in rows_orgs} + for org_id in sorted(alle_ids): + o = orgs_map.get(org_id, {}) + n = neben.get(org_id, {"aufrufe": {}, "kosten": 0.0}) + ist_kosten = (o.get("ist_kosten") or 0.0) + n["kosten"] + credits = berechne_credits( + o.get("adhoc_ok") or 0, o.get("research_ok") or 0, + o.get("research_erstlaeufe") or 0, n["aufrufe"], tarif, + ) + gegenwert = round(credits * usd_per_credit, 2) + ergebnis.append({ + "org_id": org_id, + "name": org_namen.get(org_id, f"Organisation {org_id}"), + "kosten_usd": round(ist_kosten, 2), + "laeufe_ok": o.get("laeufe_ok") or 0, + "laeufe_gesamt": o.get("laeufe_gesamt") or 0, + "credits_geschaetzt": credits, + "gegenwert_usd": gegenwert, + "marge_usd": round(gegenwert - ist_kosten, 2), + }) + ergebnis.sort(key=lambda x: -x["kosten_usd"]) + return ergebnis + + +def fuelle_monatsserie(rows: list[dict], achse: list[str], schluessel: str, + felder: list[str]) -> list[dict]: + """Füllt eine Monatsserie lückenlos mit Nullwerten auf.""" + vorhanden = {r["monat"]: r for r in rows} + out = [] + for m in achse: + r = vorhanden.get(m, {}) + out.append({"monat": m, **{f: (r.get(f) or 0) for f in felder}}) + return out + + +# -------------------------------------------------------------------------- +# Endpoint +# -------------------------------------------------------------------------- + +@router.get("") +async def statistik(months: int = Query(12, ge=0, le=120), + admin=Depends(get_current_admin)): + """Komplette Betriebsstatistik in einem Aufruf. months=0 heißt alles.""" + heute = datetime.now() + heute_ym = heute.strftime("%Y-%m") + start = None + start_ym = None + if months > 0: + jahr, monat = heute.year, heute.month - (months - 1) + while monat <= 0: + monat += 12 + jahr -= 1 + start_ym = f"{jahr:04d}-{monat:02d}" + start = start_ym + "-01" + + db = await get_db() + try: + # Q1: Detailzeilen der Monitor-Läufe + q1 = ("SELECT strftime('%Y-%m', r.started_at) AS monat, i.type AS typ, " + "r.status, r.trigger_type, r.total_cost_usd, r.input_tokens, " + "r.output_tokens, r.articles_found, r.error_message, " + "CASE WHEN r.completed_at IS NOT NULL THEN " + "(julianday(r.completed_at) - julianday(r.started_at)) * 86400.0 END AS dauer_s " + "FROM refresh_log r JOIN incidents i ON i.id = r.incident_id") + p1: tuple = () + if start: + q1 += " WHERE r.started_at >= ?" + p1 = (start,) + rows_refresh = [dict(r) for r in await (await db.execute(q1, p1)).fetchall()] + + # Q2: Monatsserie je Quelle + q2 = ("SELECT year_month AS monat, source AS quelle, " + "SUM(total_cost_usd) AS kosten, SUM(api_calls) AS aufrufe " + "FROM token_usage_monthly") + p2: tuple = () + if start_ym: + q2 += " WHERE year_month >= ?" + p2 = (start_ym,) + q2 += " GROUP BY year_month, source" + rows_quelle = [dict(r) for r in await (await db.execute(q2, p2)).fetchall()] + + # Q3: je Organisation (Erstlauf-Erkennung über ALLE Zeiten, Fenster außen) + q3 = ("WITH research_first AS (" + " SELECT r.id, ROW_NUMBER() OVER (PARTITION BY r.incident_id ORDER BY r.started_at) AS rn" + " FROM refresh_log r JOIN incidents i ON i.id = r.incident_id" + " WHERE r.status = 'completed' AND i.type = 'research') " + "SELECT r.tenant_id, SUM(r.total_cost_usd) AS ist_kosten, " + "SUM(CASE WHEN r.status='completed' THEN 1 ELSE 0 END) AS laeufe_ok, " + "COUNT(*) AS laeufe_gesamt, " + "SUM(CASE WHEN r.status='completed' AND i.type='adhoc' THEN 1 ELSE 0 END) AS adhoc_ok, " + "SUM(CASE WHEN r.status='completed' AND i.type='research' THEN 1 ELSE 0 END) AS research_ok, " + "SUM(CASE WHEN rf.rn = 1 THEN 1 ELSE 0 END) AS research_erstlaeufe " + "FROM refresh_log r " + "JOIN incidents i ON i.id = r.incident_id " + "LEFT JOIN research_first rf ON rf.id = r.id") + p3: tuple = () + if start: + q3 += " WHERE r.started_at >= ?" + p3 = (start,) + q3 += " GROUP BY r.tenant_id" + rows_orgs = [dict(r) for r in await (await db.execute(q3, p3)).fetchall()] + + # Q4: Nicht-Monitor-Verbrauch je Organisation + q4 = ("SELECT organization_id, source, SUM(api_calls) AS api_calls, " + "SUM(total_cost_usd) AS kosten FROM token_usage_monthly " + "WHERE source != 'monitor'") + p4: tuple = () + if start_ym: + q4 += " AND year_month >= ?" + p4 = (start_ym,) + q4 += " GROUP BY organization_id, source" + rows_neben = [dict(r) for r in await (await db.execute(q4, p4)).fetchall()] + + # Q5: Wachstum (drei kleine Abfragen) + wachstum_rows: dict[str, list[dict]] = {} + for art, tabelle in (("orgs", "organizations"), ("users", "users"), ("lagen", "incidents")): + q5 = f"SELECT strftime('%Y-%m', created_at) AS monat, COUNT(*) AS n FROM {tabelle}" + p5: tuple = () + if start: + q5 += " WHERE created_at >= ?" + p5 = (start,) + q5 += " GROUP BY 1" + wachstum_rows[art] = [dict(r) for r in await (await db.execute(q5, p5)).fetchall()] + + # Q6: Meta, Tarif, Org-Namen + letzter_lauf = (await (await db.execute("SELECT MAX(started_at) FROM refresh_log")).fetchone())[0] + letzter_tum = (await (await db.execute("SELECT MAX(year_month) FROM token_usage_monthly")).fetchone())[0] + tarif = dict(TARIFF_DEFAULTS) + tarif_quelle = "defaults" + try: + for r in await (await db.execute("SELECT tariff_key, credits FROM billing_tariff")).fetchall(): + tarif[r["tariff_key"]] = r["credits"] + tarif_quelle = "billing_tariff" + except Exception: + pass + org_namen = {r["id"]: r["name"] for r in + await (await db.execute("SELECT id, name FROM organizations")).fetchall()} + + # Q7: Aktivität (Tabelle kann auf alten Ständen fehlen) + mau_rows: list[dict] = [] + dau_rows: list[dict] = [] + erfassung_seit = None + try: + qm = "SELECT strftime('%Y-%m', day) AS monat, COUNT(DISTINCT user_id) AS nutzer FROM user_activity_days" + pm: tuple = () + if start: + qm += " WHERE day >= ?" + pm = (start[:10],) + qm += " GROUP BY 1" + mau_rows = [dict(r) for r in await (await db.execute(qm, pm)).fetchall()] + dau_start = (heute - timedelta(days=30)).strftime("%Y-%m-%d") + dau_rows = [dict(r) for r in await (await db.execute( + "SELECT day AS tag, COUNT(DISTINCT user_id) AS nutzer FROM user_activity_days " + "WHERE day >= ? GROUP BY day ORDER BY day", (dau_start,))).fetchall()] + erfassung_seit = (await (await db.execute("SELECT MIN(day) FROM user_activity_days")).fetchone())[0] + except Exception: + logger.warning("user_activity_days fehlt noch, MAU/DAU bleiben leer") + + # Momentaufnahme aus last_login_at (verdaut beide Zeitstempelformate) + nutzer_30d = (await (await db.execute( + "SELECT COUNT(*) FROM users WHERE is_active = 1 AND last_login_at IS NOT NULL " + "AND datetime(last_login_at) >= datetime('now', '-30 days')")).fetchone())[0] + finally: + await db.close() + + # Monatsachse: vom Fensterstart (oder ältestem Datenmonat) bis heute + daten_monate = ([r["monat"] for r in rows_refresh if r.get("monat")] + + [r["monat"] for r in rows_quelle] + + [r["monat"] for art in wachstum_rows.values() for r in art if r.get("monat")]) + von_ym = start_ym or (min(daten_monate) if daten_monate else heute_ym) + bis_ym = max([heute_ym] + daten_monate) + achse = baue_monatsachse(von_ym, bis_ym) + + agg = aggregiere_refresh(rows_refresh, achse) + + # Monatsserie je Quelle in stabile Form bringen + quellen = sorted({r["quelle"] for r in rows_quelle}) or [] + monat_quelle = [] + q_map = {(r["monat"], r["quelle"]): r for r in rows_quelle} + for m in achse: + for q in quellen: + r = q_map.get((m, q), {}) + monat_quelle.append({"monat": m, "quelle": q, + "kosten": round(r.get("kosten") or 0.0, 2), + "aufrufe": r.get("aufrufe") or 0}) + + wachstum = [] + w_maps = {art: {r["monat"]: r["n"] for r in rows} for art, rows in wachstum_rows.items()} + for m in achse: + wachstum.append({"monat": m, + "orgs": w_maps["orgs"].get(m, 0), + "users": w_maps["users"].get(m, 0), + "lagen": w_maps["lagen"].get(m, 0)}) + + mau_map = {r["monat"]: r["nutzer"] for r in mau_rows} + aktivitaet = { + "mau": [{"monat": m, "nutzer": mau_map.get(m, 0)} for m in achse], + "dau": dau_rows, + "erfassung_seit": erfassung_seit, + "nutzer_30d": nutzer_30d, + } + + return { + "window": {"months": months, "monate": achse}, + "datenstand": {"letzter_lauf": letzter_lauf, "letzter_tum_monat": letzter_tum, + "kosten_ab": "2026-03", + "erzeugt": heute.strftime("%Y-%m-%d %H:%M")}, + "tarif": {**tarif, "usd_per_credit": USD_PER_CREDIT, "quelle": tarif_quelle}, + "kpi": agg["kpi"], + "monat_typ": agg["monat_typ"], + "monat_quelle": monat_quelle, + "monat_betrieb": agg["monat_betrieb"], + "fehler_top": agg["fehler_top"], + "dauer_typ": agg["dauer_typ"], + "orgs": aggregiere_orgs(rows_orgs, rows_neben, org_namen, tarif, USD_PER_CREDIT), + "wachstum": wachstum, + "aktivitaet": aktivitaet, + } diff --git a/src/static/dashboard.html b/src/static/dashboard.html index e79cc2c..41170a7 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -1217,7 +1217,7 @@ - + diff --git a/src/static/js/app.js b/src/static/js/app.js index 6f50ccd..f70b32e 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -1180,11 +1180,22 @@ async function loadDashboardTokenStats() { }; }, { cost: 0, refreshes: 0 }); + // Sprung-Kachel zum Statistik-Tab. Handler direkt anhaengen, denn die + // Kachel entsteht NACH wireStatCardLinks() (ein erneuter Aufruf dort + // wuerde die anderen Kacheln doppelt binden). const card = document.createElement('div'); - card.className = 'stat-card'; + card.className = 'stat-card stat-card-link'; + card.setAttribute('role', 'link'); + card.setAttribute('tabindex', '0'); + card.title = 'Zur Statistik'; card.innerHTML = '
API-Kosten gesamt
' + '
$' + totals.cost.toFixed(2) + '
' + '
' + totals.refreshes + ' Refreshes
'; + const springe = () => gotoSection('statistik'); + card.addEventListener('click', springe); + card.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); springe(); } + }); grid.appendChild(card); } } catch (err) { @@ -1304,9 +1315,14 @@ document.addEventListener("DOMContentLoaded", () => { } }); -// --- Verbrauchsrechner nachladen (eigenstaendiges Modul, siehe rechner.js) --- +// --- Statistik und Verbrauchsrechner nachladen (eigenstaendige Module) --- +// async=false erzwingt die Ausfuehrungs-Reihenfolge (dynamische Scripts sind +// sonst async), damit der Statistik-Tab links vom Verbrauchsrechner landet. (function () { - var s = document.createElement("script"); - s.src = "/static/js/rechner.js?v=20260725b"; - document.head.appendChild(s); + ["/static/js/statistik.js?v=20260725a", "/static/js/rechner.js?v=20260725b"].forEach(function (src) { + var s = document.createElement("script"); + s.src = src; + s.async = false; + document.head.appendChild(s); + }); })(); diff --git a/src/static/js/statistik.js b/src/static/js/statistik.js new file mode 100644 index 0000000..7a3d79d --- /dev/null +++ b/src/static/js/statistik.js @@ -0,0 +1,440 @@ +"use strict"; + +// Statistik-Tab für das Verwaltungsportal. Selbst-injizierend nach dem +// Muster von rechner.js, damit dashboard.html unverändert bleibt. +// Charts sind handgerendertes Inline-SVG ohne Bibliothek. Die Serienfarben +// sind mit dem dataviz-Palette-Validator gegen beide Themes geprüft +// (Light #96791A/#2a78d6/#199e70, Dark #a8863a/#3987e5/#199e70, alle Checks PASS). + +(function () { + var geladen = false; + var aktMonate = 12; + + // ---------- Format-Helfer ---------- + + function fmt(n) { return Number(n || 0).toLocaleString("de-DE"); } + function fmtUsd(n) { + return "$" + Number(n || 0).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + function fmtMonat(ym) { + if (!ym) return ""; + var namen = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]; + return namen[parseInt(ym.slice(5, 7), 10) - 1] + " " + ym.slice(2, 4); + } + function fmtDatum(s) { + if (!s) return "-"; + var d = String(s).slice(0, 10).split("-"); + return d.length === 3 ? d[2] + "." + d[1] + "." + d[0] : s; + } + function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + + // ---------- SVG-Renderer ---------- + // Eine Achse pro Chart, rezessives Grid, 2px-Lücken zwischen Stapel- + // Segmenten, Hover über grosszügige unsichtbare Trefferflächen mit . + + var W = 720, H = 230, ML = 52, MR = 8, MT = 10, MB = 26; + + function niceMax(v) { + if (v <= 0) return 1; + var mag = Math.pow(10, Math.floor(Math.log10(v))); + var norm = v / mag; + var step = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10; + return step * mag; + } + + function achsen(maxY, einheit) { + var plotH = H - MT - MB; + var out = ""; + for (var i = 0; i <= 3; i++) { + var y = MT + plotH - (plotH * i) / 3; + var wert = (maxY * i) / 3; + out += '<line x1="' + ML + '" y1="' + y + '" x2="' + (W - MR) + '" y2="' + y + + '" stroke="var(--border)" stroke-width="1"/>'; + out += '<text x="' + (ML - 6) + '" y="' + (y + 3) + '" text-anchor="end" font-size="10" fill="var(--text-muted)">' + + esc(kurz(wert, einheit)) + "</text>"; + } + return out; + } + + function kurz(v, einheit) { + var s; + if (v >= 1000000) s = (v / 1000000).toLocaleString("de-DE", { maximumFractionDigits: 1 }) + " Mio"; + else if (v >= 10000) s = Math.round(v / 1000).toLocaleString("de-DE") + "k"; + else s = v.toLocaleString("de-DE", { maximumFractionDigits: v < 10 ? 1 : 0 }); + return einheit === "usd" ? s + " $" : s; + } + + function xLabels(labels) { + var n = labels.length; + if (!n) return ""; + var plotW = W - ML - MR; + var schritt = n > 8 ? 2 : 1; + var out = ""; + for (var i = 0; i < n; i++) { + if (i % schritt !== 0) continue; + var x = ML + (plotW * (i + 0.5)) / n; + out += '<text x="' + x + '" y="' + (H - 8) + '" text-anchor="middle" font-size="10" fill="var(--text-muted)">' + + esc(labels[i]) + "</text>"; + } + return out; + } + + // serien: [{name, farbe, werte[]}], gestapelt. einheit: 'usd' | '' + function chartBalken(labels, serien, einheit) { + var n = labels.length; + if (!n) return '<div class="st-leer">Keine Daten im Zeitraum</div>'; + var summen = labels.map(function (_, i) { + return serien.reduce(function (a, s) { return a + (s.werte[i] || 0); }, 0); + }); + var maxY = niceMax(Math.max.apply(null, summen.concat([0]))); + var plotW = W - ML - MR, plotH = H - MT - MB; + var breite = Math.min(40, (plotW / n) * 0.62); + + var svg = achsen(maxY, einheit); + for (var i = 0; i < n; i++) { + var x = ML + (plotW * (i + 0.5)) / n - breite / 2; + var yCursor = MT + plotH; + var titel = [fmtMonat(labels[i]) || labels[i]]; + for (var s = 0; s < serien.length; s++) { + var wert = serien[s].werte[i] || 0; + titel.push(serien[s].name + ": " + (einheit === "usd" ? fmtUsd(wert) : fmt(wert))); + if (wert <= 0) continue; + var hoehe = (wert / maxY) * plotH; + yCursor -= hoehe; + // 2px-Lücke zwischen Segmenten, oberstes Segment leicht gerundet + var segH = Math.max(0, hoehe - 2); + if (segH > 0) { + svg += '<rect x="' + x + '" y="' + yCursor + '" width="' + breite + '" height="' + segH + + '" rx="2" fill="' + serien[s].farbe + '"/>'; + } + } + if (summen[i] <= 0) titel.push("Keine Daten"); + svg += '<rect x="' + (ML + (plotW * i) / n) + '" y="' + MT + '" width="' + (plotW / n) + + '" height="' + plotH + '" fill="transparent"><title>' + esc(titel.join("\n")) + ""; + } + svg += xLabels(labels.map(fmtMonat)); + return '' + svg + ""; + } + + // serien: [{name, farbe, werte[]}] mit null-Lücken. xIstTag = Tageslabels + function chartLinie(labels, serien, einheit, xIstTag) { + var n = labels.length; + if (!n) return '
Keine Daten im Zeitraum
'; + var alle = []; + serien.forEach(function (s) { s.werte.forEach(function (v) { if (v != null) alle.push(v); }); }); + if (!alle.length) return '
Keine Daten im Zeitraum
'; + var maxY = niceMax(Math.max.apply(null, alle)); + var plotW = W - ML - MR, plotH = H - MT - MB; + + function px(i) { return ML + (plotW * (i + 0.5)) / n; } + function py(v) { return MT + plotH - (v / maxY) * plotH; } + + var svg = achsen(maxY, einheit); + serien.forEach(function (s) { + var punkte = []; + for (var i = 0; i < n; i++) { + if (s.werte[i] == null) continue; + punkte.push(px(i).toFixed(1) + "," + py(s.werte[i]).toFixed(1)); + } + if (punkte.length > 1) { + svg += ''; + } + for (var j = 0; j < n; j++) { + if (s.werte[j] == null) continue; + svg += ''; + svg += '' + + esc((xIstTag ? fmtDatum(labels[j]) : fmtMonat(labels[j])) + "\n" + s.name + ": " + + (einheit === "usd" ? fmtUsd(s.werte[j]) : fmt(s.werte[j]))) + ""; + } + }); + svg += xLabels(xIstTag ? labels.map(function (t, i) { return i % Math.ceil(n / 8) === 0 ? String(t).slice(8, 10) + "." + String(t).slice(5, 7) + "." : ""; }) : labels.map(fmtMonat)); + return '' + svg + ""; + } + + function legende(eintraege) { + return '
' + eintraege.map(function (e) { + return '' + esc(e.name) + ""; + }).join("") + "
"; + } + + function kpiKachel(label, wert, sub) { + return '
' + esc(label) + '
' + + '
' + wert + "
" + + (sub ? '
' + sub + "
" : "") + "
"; + } + + function chartCard(titel, inhalt, legendeHtml) { + return '

' + esc(titel) + "

" + + (legendeHtml || "") + '
' + inhalt + "
"; + } + + // ---------- Rendering ---------- + + function C1() { return "var(--st-c1)"; } + function C2() { return "var(--st-c2)"; } + function C3() { return "var(--st-c3)"; } + + function render(d) { + var el = document.getElementById("st-inhalt"); + if (!el) return; + var monate = d.window.monate || []; + var mt = {}; + (d.monat_typ || []).forEach(function (r) { mt[r.monat + "|" + r.typ] = r; }); + function reihe(typ, feld) { + return monate.map(function (m) { var r = mt[m + "|" + typ]; return r ? r[feld] : 0; }); + } + function reiheNull(typ, feld) { + return monate.map(function (m) { var r = mt[m + "|" + typ]; return r && r[feld] != null ? r[feld] : null; }); + } + + // Übrige Quellen (globe/chat/enhance) je Monat zusammenfassen + var nebenKosten = {}; + (d.monat_quelle || []).forEach(function (r) { + if (r.quelle === "monitor") return; + nebenKosten[r.monat] = (nebenKosten[r.monat] || 0) + (r.kosten || 0); + }); + + var kpi = d.kpi || {}; + var html = ""; + + // --- Kosten & Marge --- + html += '

Kosten & Marge

'; + html += '
' + + kpiKachel("API-Kosten im Zeitraum", fmtUsd(kpi.kosten_usd), fmt(kpi.laeufe_gesamt) + " Läufe gesamt") + + kpiKachel("Erfolgreiche Läufe", fmt(kpi.laeufe_ok), kpi.erfolgsquote != null ? kpi.erfolgsquote.toLocaleString("de-DE") + "% Erfolgsquote" : "") + + kpiKachel("Median je Lauf", kpi.median_kosten_lauf != null ? fmtUsd(kpi.median_kosten_lauf) : "-", "nur Läufe mit Kosten") + + kpiKachel("Artikel gefunden", fmt(kpi.artikel), fmt(kpi.tokens_in) + " Tokens ein / " + fmt(kpi.tokens_out) + " aus") + + "
"; + + html += chartCard("API-Kosten je Monat (USD)", + chartBalken(monate, [ + { name: "Live-Monitoring", farbe: C1(), werte: reihe("adhoc", "kosten") }, + { name: "Recherche", farbe: C2(), werte: reihe("research", "kosten") }, + { name: "Übrige Quellen", farbe: C3(), werte: monate.map(function (m) { return Math.round((nebenKosten[m] || 0) * 100) / 100; }) }, + ], "usd"), + legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }, { name: "Übrige Quellen", farbe: C3() }])); + + html += chartCard("Median-Kosten je Lauf (USD), der Frühwarner für Kosten-Anstieg", + chartLinie(monate, [ + { name: "Live-Monitoring", farbe: C1(), werte: reiheNull("adhoc", "kosten_median") }, + { name: "Recherche", farbe: C2(), werte: reiheNull("research", "kosten_median") }, + ], "usd"), + legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }])); + + // Orgs-Tabelle + var orgZeilen = (d.orgs || []).map(function (o) { + var margeCls = o.marge_usd >= 0 ? "st-pos" : "st-neg"; + return "" + esc(o.name) + "" + fmt(o.laeufe_ok) + " / " + fmt(o.laeufe_gesamt) + "" + + "" + fmtUsd(o.kosten_usd) + "" + fmt(o.credits_geschaetzt) + "" + + "" + fmtUsd(o.gegenwert_usd) + '' + fmtUsd(o.marge_usd) + ""; + }).join(""); + html += '

Je Organisation (Zeitraum)

' + + '
' + + "" + + "" + (orgZeilen || '') + "
OrganisationLäufe ok/gesamtIst-KostenCredits (geschätzt)GegenwertMarge
Keine Daten
" + + '
Schätzung zu den heutigen Sätzen (billing_tariff), es gibt keine historisierten Buchungen. ' + + "Recherche-Anlage zählt als 3 Durchläufe (erster abgeschlossener Lauf je Recherche-Lage). " + + "Studio-Bausteine (analysis/factcheck) fehlen mangels Nutzungsdaten, der Gegenwert ist eher zu niedrig. " + + "Ist-Kosten enthalten auch Fehlläufe. 1 Credit = " + fmtUsd(d.tarif.usd_per_credit) + ".
"; + + // --- Nutzung --- + html += '

Nutzung

'; + var mb = {}; + (d.monat_betrieb || []).forEach(function (r) { mb[r.monat] = r; }); + function bReihe(feld) { return monate.map(function (m) { return mb[m] ? mb[m][feld] : 0; }); } + + html += chartCard("Erfolgreiche Läufe je Monat", + chartBalken(monate, [ + { name: "Automatisch", farbe: C1(), werte: bReihe("auto") }, + { name: "Manuell", farbe: C2(), werte: bReihe("manual") }, + ], ""), + legende([{ name: "Automatisch", farbe: C1() }, { name: "Manuell", farbe: C2() }])); + + html += chartCard("Gefundene Artikel je Monat", + chartBalken(monate, [ + { name: "Live-Monitoring", farbe: C1(), werte: reihe("adhoc", "artikel") }, + { name: "Recherche", farbe: C2(), werte: reihe("research", "artikel") }, + ], ""), + legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }])); + + // --- Betrieb --- + html += '

Betrieb

'; + var dauerA = d.dauer_typ && d.dauer_typ.adhoc != null ? Math.round(d.dauer_typ.adhoc / 60) + " Min" : "-"; + var dauerR = d.dauer_typ && d.dauer_typ.research != null ? Math.round(d.dauer_typ.research / 60) + " Min" : "-"; + html += '
' + + kpiKachel("Erfolgsquote", kpi.erfolgsquote != null ? kpi.erfolgsquote.toLocaleString("de-DE") + "%" : "-", + fmt(kpi.laeufe_ok) + " von " + fmt(kpi.laeufe_gesamt) + " Läufen") + + kpiKachel("Dauer Live-Lauf (ø)", dauerA, "") + + kpiKachel("Dauer Recherche-Durchlauf (ø)", dauerR, "") + + "
"; + + html += chartCard("Läufe nach Ausgang je Monat", + chartBalken(monate, [ + { name: "Erfolgreich", farbe: "var(--success)", werte: bReihe("completed") }, + { name: "Fehler", farbe: "var(--danger)", werte: bReihe("error") }, + { name: "Abgebrochen", farbe: "var(--text-muted)", werte: bReihe("cancelled") }, + ], ""), + legende([{ name: "Erfolgreich", farbe: "var(--success)" }, { name: "Fehler", farbe: "var(--danger)" }, { name: "Abgebrochen", farbe: "var(--text-muted)" }])); + + var fehlerZeilen = (d.fehler_top || []).map(function (f) { + return "" + esc(f.grund) + "" + fmt(f.n) + ""; + }).join(""); + html += '

Häufigste Fehlerursachen

' + + '
' + + (fehlerZeilen || '') + + "
UrsacheAnzahl
Keine Fehler im Zeitraum
"; + + // --- Aktivität --- + html += '

Aktivität

'; + var akt = d.aktivitaet || {}; + html += '
' + + kpiKachel("Nutzer mit Login (30 Tage)", fmt(akt.nutzer_30d), "Momentaufnahme aus dem letzten Login") + + kpiKachel("Aktivitäts-Erfassung", akt.erfassung_seit ? "seit " + fmtDatum(akt.erfassung_seit) : "startet jetzt", + akt.erfassung_seit ? "je Nutzer und Tag ein Eintrag" : "MAU/DAU wachsen ab diesem Update") + + "
"; + var mauWerte = (akt.mau || []).map(function (r) { return r.nutzer; }); + var mauMax = Math.max.apply(null, mauWerte.concat([0])); + if (mauMax > 0) { + html += chartCard("Aktive Nutzer je Monat (MAU)", + chartBalken(monate, [{ name: "Aktive Nutzer", farbe: C1(), werte: mauWerte }], ""), ""); + var dauLabels = (akt.dau || []).map(function (r) { return r.tag; }); + var dauWerte = (akt.dau || []).map(function (r) { return r.nutzer; }); + html += chartCard("Aktive Nutzer je Tag (DAU, letzte 30 Tage)", + dauLabels.length + ? chartLinie(dauLabels, [{ name: "Aktive Nutzer", farbe: C1(), werte: dauWerte }], "", true) + : '
Noch keine Tageswerte
', ""); + } else { + html += '
Noch keine Aktivitätsdaten. ' + + "Die Erfassung (ein Eintrag je Nutzer und Tag) beginnt mit diesem Update, " + + "die MAU/DAU-Kurven erscheinen hier, sobald sich Nutzer im Monitor bewegen.
"; + } + + // --- Wachstum --- + html += '

Wachstum

'; + var w = {}; + (d.wachstum || []).forEach(function (r) { w[r.monat] = r; }); + function wReihe(feld) { return monate.map(function (m) { return w[m] ? w[m][feld] : 0; }); } + html += chartCard("Neuzugänge je Monat", + chartBalken(monate, [ + { name: "Organisationen", farbe: C1(), werte: wReihe("orgs") }, + { name: "Nutzer", farbe: C2(), werte: wReihe("users") }, + { name: "Lagen", farbe: C3(), werte: wReihe("lagen") }, + ], ""), + legende([{ name: "Organisationen", farbe: C1() }, { name: "Nutzer", farbe: C2() }, { name: "Lagen", farbe: C3() }])); + + el.innerHTML = html; + + // Datenstand + var ds = document.getElementById("st-datenstand"); + if (ds) { + var teile = []; + if (d.datenstand.letzter_lauf) teile.push("Letzter Lauf " + fmtDatum(d.datenstand.letzter_lauf)); + teile.push("Kostenerfassung ab 03/2026"); + if (d.tarif && d.tarif.quelle === "defaults") teile.push("Sätze aus Rückfallwerten"); + ds.textContent = "Datenstand: " + teile.join(" · "); + } + } + + // ---------- Laden ---------- + + function lade() { + var el = document.getElementById("st-inhalt"); + if (el) el.innerHTML = '
Lade Statistik …
'; + API.get("/api/statistik?months=" + aktMonate).then(function (d) { + render(d); + }).catch(function (err) { + if (el) el.innerHTML = '
Statistik konnte nicht geladen werden: ' + esc(err.message) + "
"; + }); + } + + function setzeZeitraum(monate, btn) { + aktMonate = monate; + document.querySelectorAll("#st-zeitraum button").forEach(function (b) { b.classList.remove("btn-primary"); b.classList.add("btn-secondary"); }); + btn.classList.remove("btn-secondary"); + btn.classList.add("btn-primary"); + lade(); + } + + // ---------- Injektion (Muster rechner.js) ---------- + + var STYLES = "\n" + + "#sec-statistik { --st-c1:#a8863a; --st-c2:#3987e5; --st-c3:#199e70; }\n" + + '[data-theme="light"] #sec-statistik { --st-c1:#96791A; --st-c2:#2a78d6; --st-c3:#199e70; }\n' + + "#sec-statistik .st-gruppe { margin:26px 0 12px; font-size:16px; border-bottom:1px solid var(--border); padding-bottom:6px; }\n" + + "#sec-statistik .st-kopf { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }\n" + + "#sec-statistik .st-kopf h2 { font-size:20px; margin-right:auto; }\n" + + "#sec-statistik .st-datenstand { font-size:12px; color:var(--text-muted); margin-top:6px; }\n" + + "#sec-statistik .st-legende { display:flex; gap:14px; font-size:12px; color:var(--text-secondary); flex-wrap:wrap; }\n" + + "#sec-statistik .st-dot { display:inline-block; width:10px; height:10px; border-radius:3px; margin-right:5px; vertical-align:-1px; }\n" + + "#sec-statistik .st-leer { padding:22px; text-align:center; color:var(--text-muted); font-size:13px; }\n" + + "#sec-statistik .st-fussnote { font-size:12px; color:var(--text-muted); border-top:1px solid var(--border); }\n" + + "#sec-statistik .st-pos { color:var(--success); }\n" + + "#sec-statistik .st-neg { color:var(--danger); }\n" + + "#sec-statistik .card { margin-bottom:16px; }\n"; + + function init() { + var nav = document.querySelector("nav.nav-tabs"); + var content = document.querySelector(".app-content"); + if (!nav || !content || document.getElementById("sec-statistik")) return; + + var style = document.createElement("style"); + style.textContent = STYLES; + document.head.appendChild(style); + + var sektion = document.createElement("div"); + sektion.className = "section"; + sektion.id = "sec-statistik"; + sektion.innerHTML = + '
' + + "

Statistik

" + + '
' + + '' + + '' + + '' + + "
" + + "
" + + '
' + + '
'; + content.appendChild(sektion); + + var btn = document.createElement("button"); + btn.className = "nav-tab"; + btn.setAttribute("data-section", "statistik"); + btn.innerHTML = 'Statistik'; + // Zwischen Audit-Log und Verbrauchsrechner, egal in welcher Ladereihenfolge + var rechnerTab = nav.querySelector('.nav-tab[data-section="rechner"]'); + nav.insertBefore(btn, rechnerTab || null); + + btn.addEventListener("click", function () { + document.querySelectorAll("nav.nav-tabs .nav-tab").forEach(function (t) { t.classList.remove("active"); }); + btn.classList.add("active"); + document.querySelectorAll(".app-content > .section").forEach(function (s) { s.classList.remove("active"); }); + var detail = document.getElementById("orgDetail"); + if (detail) detail.classList.remove("active"); + sektion.classList.add("active"); + if (!geladen) { geladen = true; lade(); } + }); + + nav.addEventListener("click", function (e) { + var ziel = e.target.closest(".nav-tab"); + if (!ziel || ziel === btn) return; + btn.classList.remove("active"); + sektion.classList.remove("active"); + }); + + sektion.querySelectorAll("#st-zeitraum button").forEach(function (b) { + b.addEventListener("click", function () { setzeZeitraum(parseInt(b.getAttribute("data-monate"), 10), b); }); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index 68ab6e7..d50b184 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -56,6 +56,7 @@ AUTH_PROTECTED = [ ("POST", "/api/sources/health/run"), ("POST", "/api/sources/health/run-stream"), ("POST", "/api/sources/health/search-fix/1"), + ("GET", "/api/statistik"), ("GET", "/api/token-usage/overview"), ("GET", "/api/token-usage/1"), ("GET", "/api/token-usage/1/current"), diff --git a/tests/test_statistik.py b/tests/test_statistik.py new file mode 100644 index 0000000..27adc8a --- /dev/null +++ b/tests/test_statistik.py @@ -0,0 +1,93 @@ +"""Tests für die puren Aggregationsfunktionen der Betriebsstatistik.""" +from routers.statistik import ( + baue_monatsachse, normalisiere_fehler, aggregiere_refresh, + berechne_credits, aggregiere_orgs, fuelle_monatsserie, +) + +TARIF = {"monitor_adhoc": 45, "monitor_research": 40, "chat": 1, "enhance": 1, "globe": 1} + + +def test_monatsachse_lueckenlos_ueber_jahresgrenze(): + assert baue_monatsachse("2025-11", "2026-02") == ["2025-11", "2025-12", "2026-01", "2026-02"] + + +def test_monatsachse_leer_bei_unsinn(): + assert baue_monatsachse("2026-05", "2026-02") == [] + assert baue_monatsachse("", "2026-02") == [] + + +def test_fehler_normalisierung(): + assert normalisiere_fehler("Endgültig fehlgeschlagen nach 3 Versuchen: Claude CLI Timeout nach 420s") == "Claude CLI Timeout nach 420s" + assert normalisiere_fehler("Verwaist (Neustart, x)") == "Verwaist (Neustart, automatisch bereinigt)" + assert normalisiere_fehler(None) == "Unbekannt" + + +def _lauf(monat="2026-03", typ="adhoc", status="completed", kosten=5.0, **kw): + return {"monat": monat, "typ": typ, "status": status, "trigger_type": "auto", + "total_cost_usd": kosten, "input_tokens": 100, "output_tokens": 50, + "articles_found": 3, "error_message": None, "dauer_s": 600.0, **kw} + + +def test_median_ignoriert_nullkosten(): + """Läufe mit 0 $ (Kostenerfassung fehlte) dürfen den Median nicht verzerren.""" + rows = [_lauf(kosten=0.0), _lauf(kosten=4.0), _lauf(kosten=6.0)] + agg = aggregiere_refresh(rows, ["2026-03"]) + assert agg["kpi"]["median_kosten_lauf"] == 5.0 + assert agg["kpi"]["laeufe_ok"] == 3 + + +def test_betrieb_und_fehler(): + rows = [_lauf(), _lauf(status="error", kosten=0.0, + error_message="Endgültig fehlgeschlagen nach 3 Versuchen: Timeout X"), + _lauf(status="error", kosten=0.0, error_message="Timeout X")] + agg = aggregiere_refresh(rows, ["2026-03"]) + b = agg["monat_betrieb"][0] + assert b["completed"] == 1 and b["error"] == 2 + assert agg["fehler_top"][0] == {"grund": "Timeout X", "n": 2} + assert agg["kpi"]["erfolgsquote"] == 33.3 + + +def test_aggregiere_refresh_leer(): + agg = aggregiere_refresh([], []) + assert agg["kpi"]["laeufe_gesamt"] == 0 + assert agg["kpi"]["erfolgsquote"] is None + assert agg["monat_typ"] == [] + + +def test_credits_formel_erstlauf_dreifach(): + """Anlage einer Recherche = 3 Durchläufe, erscheint aber als 1 Log-Zeile.""" + credits = berechne_credits(adhoc_ok=2, research_ok=3, research_erstlaeufe=1, + neben_aufrufe={"chat": 4}, tarif=TARIF) + # 2*45 + (3 + 2*1)*40 + 4*1 = 90 + 200 + 4 + assert credits == 294 + + +def test_credits_formel_ohne_erstlauf(): + """Grenzfall, der chronologisch erste Lauf war ein error. Dann zählt kein + Erstlauf-Zuschlag (die Heuristik greift erst beim ersten completed Lauf, + was die SQL-Seite über rn=1 auf completed-Zeilen abbildet).""" + assert berechne_credits(0, 2, 0, {}, TARIF) == 80 + + +def test_orgs_marge(): + rows_orgs = [{"tenant_id": 1, "ist_kosten": 10.0, "laeufe_ok": 2, "laeufe_gesamt": 3, + "adhoc_ok": 2, "research_ok": 0, "research_erstlaeufe": 0}] + rows_neben = [{"organization_id": 1, "source": "chat", "api_calls": 10, "kosten": 0.5}] + orgs = aggregiere_orgs(rows_orgs, rows_neben, {1: "Test-Org"}, TARIF, 0.20) + o = orgs[0] + assert o["credits_geschaetzt"] == 100 # 2*45 + 10*1 + assert o["gegenwert_usd"] == 20.0 + assert o["kosten_usd"] == 10.5 + assert o["marge_usd"] == 9.5 + + +def test_orgs_ohne_laeufe_taucht_trotzdem_auf(): + orgs = aggregiere_orgs([], [], {5: "Leer-Org"}, TARIF, 0.20) + assert orgs[0]["org_id"] == 5 + assert orgs[0]["credits_geschaetzt"] == 0 + + +def test_fuelle_monatsserie(): + rows = [{"monat": "2026-03", "n": 2}] + out = fuelle_monatsserie(rows, ["2026-02", "2026-03"], "monat", ["n"]) + assert out == [{"monat": "2026-02", "n": 0}, {"monat": "2026-03", "n": 2}]