feat(statistik): Hauptreiter Statistik mit kompletter Betriebsstatistik
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 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -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."
|
||||
```
|
||||
|
||||
46
migrations/2026-07-25_user_activity.py
Normale Datei
46
migrations/2026-07-25_user_activity.py
Normale Datei
@@ -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))
|
||||
@@ -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")
|
||||
|
||||
413
src/routers/statistik.py
Normale Datei
413
src/routers/statistik.py
Normale Datei
@@ -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,
|
||||
}
|
||||
@@ -1217,7 +1217,7 @@
|
||||
</div>
|
||||
|
||||
<script src="/static/js/a11y.js?v=20260725a"></script>
|
||||
<script src="/static/js/app.js?v=20260725e"></script>
|
||||
<script src="/static/js/app.js?v=20260725f"></script>
|
||||
<script src="/static/js/sources.js?v=20260522x2"></script>
|
||||
<script src="/static/js/x-scraper.js?v=20260522a"></script>
|
||||
<script src="/static/js/source-health.js?v=20260509l"></script>
|
||||
|
||||
@@ -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 = '<div class="stat-label">API-Kosten gesamt</div>' +
|
||||
'<div class="stat-value">$' + totals.cost.toFixed(2) + '</div>' +
|
||||
'<div class="stat-sub">' + totals.refreshes + ' Refreshes</div>';
|
||||
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 () {
|
||||
["/static/js/statistik.js?v=20260725a", "/static/js/rechner.js?v=20260725b"].forEach(function (src) {
|
||||
var s = document.createElement("script");
|
||||
s.src = "/static/js/rechner.js?v=20260725b";
|
||||
s.src = src;
|
||||
s.async = false;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
})();
|
||||
|
||||
440
src/static/js/statistik.js
Normale Datei
440
src/static/js/statistik.js
Normale Datei
@@ -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 <title>.
|
||||
|
||||
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")) + "</title></rect>";
|
||||
}
|
||||
svg += xLabels(labels.map(fmtMonat));
|
||||
return '<svg viewBox="0 0 ' + W + " " + H + '" style="width:100%;height:auto;display:block;" role="img">' + svg + "</svg>";
|
||||
}
|
||||
|
||||
// serien: [{name, farbe, werte[]}] mit null-Lücken. xIstTag = Tageslabels
|
||||
function chartLinie(labels, serien, einheit, xIstTag) {
|
||||
var n = labels.length;
|
||||
if (!n) return '<div class="st-leer">Keine Daten im Zeitraum</div>';
|
||||
var alle = [];
|
||||
serien.forEach(function (s) { s.werte.forEach(function (v) { if (v != null) alle.push(v); }); });
|
||||
if (!alle.length) return '<div class="st-leer">Keine Daten im Zeitraum</div>';
|
||||
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 += '<polyline points="' + punkte.join(" ") + '" fill="none" stroke="' + s.farbe +
|
||||
'" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
|
||||
}
|
||||
for (var j = 0; j < n; j++) {
|
||||
if (s.werte[j] == null) continue;
|
||||
svg += '<circle cx="' + px(j) + '" cy="' + py(s.werte[j]) + '" r="2.5" fill="' + s.farbe + '"/>';
|
||||
svg += '<circle cx="' + px(j) + '" cy="' + py(s.werte[j]) + '" r="9" fill="transparent"><title>' +
|
||||
esc((xIstTag ? fmtDatum(labels[j]) : fmtMonat(labels[j])) + "\n" + s.name + ": " +
|
||||
(einheit === "usd" ? fmtUsd(s.werte[j]) : fmt(s.werte[j]))) + "</title></circle>";
|
||||
}
|
||||
});
|
||||
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 viewBox="0 0 ' + W + " " + H + '" style="width:100%;height:auto;display:block;" role="img">' + svg + "</svg>";
|
||||
}
|
||||
|
||||
function legende(eintraege) {
|
||||
return '<div class="st-legende">' + eintraege.map(function (e) {
|
||||
return '<span><span class="st-dot" style="background:' + e.farbe + '"></span>' + esc(e.name) + "</span>";
|
||||
}).join("") + "</div>";
|
||||
}
|
||||
|
||||
function kpiKachel(label, wert, sub) {
|
||||
return '<div class="stat-card"><div class="stat-label">' + esc(label) + '</div>' +
|
||||
'<div class="stat-value">' + wert + "</div>" +
|
||||
(sub ? '<div class="stat-sub">' + sub + "</div>" : "") + "</div>";
|
||||
}
|
||||
|
||||
function chartCard(titel, inhalt, legendeHtml) {
|
||||
return '<div class="card"><div class="card-header"><h2>' + esc(titel) + "</h2>" +
|
||||
(legendeHtml || "") + '</div><div class="card-body">' + inhalt + "</div></div>";
|
||||
}
|
||||
|
||||
// ---------- 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 += '<h3 class="st-gruppe">Kosten & Marge</h3>';
|
||||
html += '<div class="stats-grid">' +
|
||||
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") +
|
||||
"</div>";
|
||||
|
||||
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 "<tr><td>" + esc(o.name) + "</td><td>" + fmt(o.laeufe_ok) + " / " + fmt(o.laeufe_gesamt) + "</td>" +
|
||||
"<td>" + fmtUsd(o.kosten_usd) + "</td><td>" + fmt(o.credits_geschaetzt) + "</td>" +
|
||||
"<td>" + fmtUsd(o.gegenwert_usd) + '</td><td class="' + margeCls + '">' + fmtUsd(o.marge_usd) + "</td></tr>";
|
||||
}).join("");
|
||||
html += '<div class="card"><div class="card-header"><h2>Je Organisation (Zeitraum)</h2></div>' +
|
||||
'<div class="table-wrap"><table><thead><tr><th>Organisation</th><th>Läufe ok/gesamt</th>' +
|
||||
"<th>Ist-Kosten</th><th>Credits (geschätzt)</th><th>Gegenwert</th><th>Marge</th></tr></thead>" +
|
||||
"<tbody>" + (orgZeilen || '<tr><td colspan="6" class="text-muted">Keine Daten</td></tr>') + "</tbody></table></div>" +
|
||||
'<div class="card-body st-fussnote">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) + ".</div></div>";
|
||||
|
||||
// --- Nutzung ---
|
||||
html += '<h3 class="st-gruppe">Nutzung</h3>';
|
||||
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 += '<h3 class="st-gruppe">Betrieb</h3>';
|
||||
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 += '<div class="stats-grid">' +
|
||||
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, "") +
|
||||
"</div>";
|
||||
|
||||
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 "<tr><td>" + esc(f.grund) + "</td><td>" + fmt(f.n) + "</td></tr>";
|
||||
}).join("");
|
||||
html += '<div class="card"><div class="card-header"><h2>Häufigste Fehlerursachen</h2></div>' +
|
||||
'<div class="table-wrap"><table><thead><tr><th>Ursache</th><th>Anzahl</th></tr></thead><tbody>' +
|
||||
(fehlerZeilen || '<tr><td colspan="2" class="text-muted">Keine Fehler im Zeitraum</td></tr>') +
|
||||
"</tbody></table></div></div>";
|
||||
|
||||
// --- Aktivität ---
|
||||
html += '<h3 class="st-gruppe">Aktivität</h3>';
|
||||
var akt = d.aktivitaet || {};
|
||||
html += '<div class="stats-grid">' +
|
||||
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") +
|
||||
"</div>";
|
||||
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)
|
||||
: '<div class="st-leer">Noch keine Tageswerte</div>', "");
|
||||
} else {
|
||||
html += '<div class="card"><div class="card-body st-leer">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.</div></div>";
|
||||
}
|
||||
|
||||
// --- Wachstum ---
|
||||
html += '<h3 class="st-gruppe">Wachstum</h3>';
|
||||
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 = '<div class="st-leer">Lade Statistik …</div>';
|
||||
API.get("/api/statistik?months=" + aktMonate).then(function (d) {
|
||||
render(d);
|
||||
}).catch(function (err) {
|
||||
if (el) el.innerHTML = '<div class="st-leer">Statistik konnte nicht geladen werden: ' + esc(err.message) + "</div>";
|
||||
});
|
||||
}
|
||||
|
||||
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 =
|
||||
'<div class="st-kopf">' +
|
||||
"<h2>Statistik</h2>" +
|
||||
'<div id="st-zeitraum" style="display:flex; gap:6px;">' +
|
||||
'<button type="button" class="btn btn-secondary btn-small" data-monate="6">6 Monate</button>' +
|
||||
'<button type="button" class="btn btn-primary btn-small" data-monate="12">12 Monate</button>' +
|
||||
'<button type="button" class="btn btn-secondary btn-small" data-monate="0">Alles</button>' +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
'<div class="st-datenstand" id="st-datenstand"></div>' +
|
||||
'<div id="st-inhalt" style="margin-top:14px;"></div>';
|
||||
content.appendChild(sektion);
|
||||
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "nav-tab";
|
||||
btn.setAttribute("data-section", "statistik");
|
||||
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>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();
|
||||
}
|
||||
})();
|
||||
@@ -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"),
|
||||
|
||||
93
tests/test_statistik.py
Normale Datei
93
tests/test_statistik.py
Normale Datei
@@ -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}]
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren