Promote develop → main (2026-07-25 22:22 UTC) #16

Zusammengeführt
IntelSight_Admin hat 41 Commits von develop nach main 2026-07-26 00:22:10 +02:00 zusammengeführt
9 geänderte Dateien mit 1020 neuen und 7 gelöschten Zeilen
Nur Änderungen aus Commit 25ec80c8e4 werden angezeigt - Alle Commits anzeigen

Datei anzeigen

@@ -52,6 +52,7 @@ src/:
dashboard.py: "Aggregat-Endpoints für Übersichts-Tab" 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" 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" 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" audit.py: "Audit-Log-Abfrage, Filter"
email_utils/: email_utils/:
@@ -66,12 +67,14 @@ src/:
js/: js/:
app.js: "Hauptlogik, Login, Tab-Switching, Dashboard-Render, ThemeManager (Dark/Light, localStorage portal_theme)" 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" 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" sources.js: "Grundquellen + Kundenquellen Management"
source-health.js: "Quellen-Health & KI-Vorschläge" source-health.js: "Quellen-Health & KI-Vorschläge"
audit.js: "Audit-Log Tab" audit.js: "Audit-Log Tab"
migrations/: migrations/:
einmal_migrationen: "Backfill-Skripte (DE-Übersetzungen, Umlaute, HTML-Strip etc.)" 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_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." 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."
``` ```

Datei anzeigen

@@ -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))

Datei anzeigen

@@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from config import STATIC_DIR, PORT 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( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -65,6 +65,7 @@ app.include_router(audit.router)
app.include_router(translation.router) app.include_router(translation.router)
app.include_router(x_scraper.router) app.include_router(x_scraper.router)
app.include_router(pricing.router) app.include_router(pricing.router)
app.include_router(statistik.router)
# --- Statische Dateien --- # --- Statische Dateien ---
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

413
src/routers/statistik.py Normale Datei
Datei anzeigen

@@ -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,
}

Datei anzeigen

@@ -1217,7 +1217,7 @@
</div> </div>
<script src="/static/js/a11y.js?v=20260725a"></script> <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/sources.js?v=20260522x2"></script>
<script src="/static/js/x-scraper.js?v=20260522a"></script> <script src="/static/js/x-scraper.js?v=20260522a"></script>
<script src="/static/js/source-health.js?v=20260509l"></script> <script src="/static/js/source-health.js?v=20260509l"></script>

Datei anzeigen

@@ -1180,11 +1180,22 @@ async function loadDashboardTokenStats() {
}; };
}, { cost: 0, refreshes: 0 }); }, { 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'); 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>' + card.innerHTML = '<div class="stat-label">API-Kosten gesamt</div>' +
'<div class="stat-value">$' + totals.cost.toFixed(2) + '</div>' + '<div class="stat-value">$' + totals.cost.toFixed(2) + '</div>' +
'<div class="stat-sub">' + totals.refreshes + ' Refreshes</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); grid.appendChild(card);
} }
} catch (err) { } 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 () { (function () {
["/static/js/statistik.js?v=20260725a", "/static/js/rechner.js?v=20260725b"].forEach(function (src) {
var s = document.createElement("script"); var s = document.createElement("script");
s.src = "/static/js/rechner.js?v=20260725b"; s.src = src;
s.async = false;
document.head.appendChild(s); document.head.appendChild(s);
});
})(); })();

440
src/static/js/statistik.js Normale Datei
Datei anzeigen

@@ -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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 &amp; 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();
}
})();

Datei anzeigen

@@ -56,6 +56,7 @@ AUTH_PROTECTED = [
("POST", "/api/sources/health/run"), ("POST", "/api/sources/health/run"),
("POST", "/api/sources/health/run-stream"), ("POST", "/api/sources/health/run-stream"),
("POST", "/api/sources/health/search-fix/1"), ("POST", "/api/sources/health/search-fix/1"),
("GET", "/api/statistik"),
("GET", "/api/token-usage/overview"), ("GET", "/api/token-usage/overview"),
("GET", "/api/token-usage/1"), ("GET", "/api/token-usage/1"),
("GET", "/api/token-usage/1/current"), ("GET", "/api/token-usage/1/current"),

93
tests/test_statistik.py Normale Datei
Datei anzeigen

@@ -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}]