diff --git a/CLAUDE.md b/CLAUDE.md index 854ba9c..827cf69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,15 +43,19 @@ src/: database.py: "DB-Connection-Pool, Schema-Helper" models.py: "Pydantic Request/Response-Schemas" audit.py: "log_action, get_client_ip, row_to_dict, /api/audit Router" + health_run.py: "In-Memory-Status des manuellen Health-Runs (pure Funktionen, unit-testbar). Der Knopf Jetzt prüfen ruft die geteilten Dienste source_health + source_suggester auf (POST /api/sources/health/run, Polling über GET /health/run-status). Zusätzlich zum Monitor-Nachtjob um 04:00. Status ist nach Neustart idle, partieller Stand heilt beim nächsten Lauf" routers/: organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)" - licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module)" + licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module, Credits-Stellwerte inkl. Periode; Helper insert_license_row wird auch vom Org-Anlegen genutzt). Kein Credits-Übertrag mehr (Produktentscheidung 07/2026, ungenutzte Credits verfallen)" users.py: "CRUD User pro Org, Magic-Link-Einladung an info@aegis-sight.de" - sources.py: "Grundquellen, Tenant-Quellen-Übersicht, Discovery, Health-Check, KI-Vorschläge" + sources.py: "Vereinte Quellenliste (GET /api/sources?scope=all|global|tenant inkl. org_name + health_status), Stats/Languages, Einzelquellen-Health (GET /{id}/health), Aufgaben-Badge (GET /tasks/summary), Promote, Discovery, PDF-Upload, KI-Vorschläge, Klassifikation" dashboard.py: "Aggregat-Endpoints für Übersichts-Tab" - token_usage.py: "Token-Verbrauch pro Org/Monat, Budget-Steuerung" - audit.py: "Audit-Log-Abfrage, Filter" + token_usage.py: "Token-Verbrauch pro Org/Monat, Credits-Stellwerte je Lizenz (PUT /budget). Laufzeitfelder (credits_used-Reset, 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 (action/resource_type/admin_id akzeptieren kommagetrennte Mehrfachwerte)" + x_scraper.py: "Recherche-Zugänge: twscrape-X-Konten (eigener Store, X_ACCOUNTS_DB_PATH) + GET /telegram-status (liest system_status, gemeldet vom Monitor)" email_utils/: sender.py: "Async SMTP Versand" @@ -59,17 +63,24 @@ src/: static/: index.html: "Login (Passwort)" - dashboard.html: "Hauptdashboard mit Tabs (Dashboard, Orgs, Lizenzen, Quellen, Audit)" + dashboard.html: "Hauptdashboard mit Tabs (Dashboard, Orgs, Quellen, Audit). Quellen hat 3 Unterreiter (Quellenliste, Aufgaben, X-Recherche-Konten)" favicon.svg: "AegisSight Logo" css/: "Stylesheets (Dark Theme)" js/: - app.js: "Hauptlogik, Login, Tab-Switching, Dashboard-Render" - sources.js: "Grundquellen + Kundenquellen Management" - source-health.js: "Quellen-Health & KI-Vorschläge" + 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: "Vereinte Quellenliste (Grund- + Kundenquellen, Herkunfts-Filter, Health-Ausklapp mit Deaktivieren, Bulk-Promote, Modal, Discovery, PDF-Upload)" + aufgaben.js: "Aufgaben-Posteingang (Health-Vorschläge + Klassifikations-Review + Verlauf, Badge via GET /tasks/summary, Sammel-Bearbeitung via PUT /suggestions/bulk)" + x-scraper.js: "Recherche-Zugänge (X-Konten-Pool + Telegram-Session-Status-Karte)" 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_system_status.py: "Tabelle system_status anlegen (Key-Value, u.a. Telegram-Session-Status). Nötig für die Portal-Staging-DB, auf Live legt sie der Monitor an. Reihenfolge: Monitor VOR Portal promoten." + 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." ``` ## Datenbank-Tabellen (relevant fürs Portal) diff --git a/RELEASES.json b/RELEASES.json index 338b62a..ba85c2d 100644 --- a/RELEASES.json +++ b/RELEASES.json @@ -1,4 +1,16 @@ [ + { + "version": "2026-07-25T22:22Z", + "date": "2026-07-25", + "title": "Neue Zugänge, bessere Navigation & UI-Verbesserungen", + "items": [ + "X und Telegram werden jetzt als gleichwertige Dienste mit korrekten Marken-Symbolen angezeigt", + "Telegram-Verbindungsstatus ist direkt im Reiter Recherche-Zugänge sichtbar", + "Neue schwebende Scroll-Leiste und breitere Inhaltsfläche für mehr Übersicht", + "In der Quellenliste lässt sich jetzt einfacher seitwärts scrollen", + "Nicht abgerufene Credits verfallen jetzt – kein Übertrag mehr in den nächsten Monat" + ] + }, { "version": "2026-07-24T20:37Z", "date": "2026-07-24", diff --git a/migrations/2026-07-25_billing_tariff.py b/migrations/2026-07-25_billing_tariff.py new file mode 100644 index 0000000..633000c --- /dev/null +++ b/migrations/2026-07-25_billing_tariff.py @@ -0,0 +1,67 @@ +"""Migration 2026-07-25: Preistabelle billing_tariff anlegen und befüllen. + +Die Tabelle ist die pflegbare Quelle der Credits-Sätze je Aktion. Der Monitor +legt sie beim Start selbst an (init_db, mit seinen Konfigurationswerten als +Erstbefüllung). Die Portal-Staging-Umgebung nutzt aber eine EIGENE Datenbank, +auf der kein Monitor läuft, deshalb braucht sie dieses Skript. Idempotent, +vorhandene Sätze werden NICHT überschrieben (INSERT OR IGNORE). + +Ausführung: + DB_PATH=/home/claude-dev/AegisSight-Monitor-staging/data/osint.db python3 migrations/2026-07-25_billing_tariff.py + DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-07-25_billing_tariff.py +""" +import os +import sqlite3 +import sys + +# Beschlossene Verkaufssätze (23.07.2026), identisch zu den Monitor-Defaults +SEED = { + "monitor_adhoc": 45.0, + "monitor_research": 40.0, + "analysis": 12.0, + "factcheck": 12.0, + "chat": 1.0, + "enhance": 1.0, + "globe": 1.0, +} + + +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 billing_tariff ( + tariff_key TEXT PRIMARY KEY, + credits REAL NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + print(" + billing_tariff angelegt (oder vorhanden)") + + for key, credits in SEED.items(): + cur = conn.execute( + "INSERT OR IGNORE INTO billing_tariff (tariff_key, credits) VALUES (?, ?)", + (key, credits), + ) + if cur.rowcount: + print(f" + Satz {key} = {credits}") + else: + print(f" = Satz {key} war bereits gesetzt") + + conn.commit() + conn.close() + print("Migration abgeschlossen.") + return 0 + + +if __name__ == "__main__": + db_path = os.environ.get("DB_PATH", "/home/claude-dev/osint-data/osint.db") + sys.exit(main(db_path)) diff --git a/migrations/2026-07-25_credits_period.py b/migrations/2026-07-25_credits_period.py new file mode 100644 index 0000000..7d6296a --- /dev/null +++ b/migrations/2026-07-25_credits_period.py @@ -0,0 +1,57 @@ +"""Migration 2026-07-25: Credits-Perioden-Spalten in licenses. + +Legt die fünf Spalten des Credits-Monatskontingents an, exakt dieselben +Statements wie die Monitor-Migration (AegisSight-Monitor, src/database.py, +Block "Credits-Periode"). Idempotent, ein zweiter Lauf ist ein No-op. + +Hintergrund: Der Monitor legt diese Spalten beim Start selbst an. Die +Portal-Staging-Umgebung nutzt aber eine EIGENE Datenbank, auf der kein +Monitor läuft, deshalb braucht sie dieses Skript. Auf der Live-DB ist es +nur ein Fallback für den Fall, dass das Portal vor dem Monitor deployt wird. + +Ausführung: + DB_PATH=/home/claude-dev/AegisSight-Monitor-staging/data/osint.db python3 migrations/2026-07-25_credits_period.py + DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-07-25_credits_period.py +""" +import os +import sqlite3 +import sys + +# Spaltenname -> ALTER-Statement, wortgleich mit der Monitor-Migration. +# credits_rollover/credits_carried gab es hier kurzzeitig, der Uebertrag +# wurde als Produktentscheidung 07/2026 entfernt (verfaellt zum Periodenende). +COLUMNS = { + "credits_period": "ALTER TABLE licenses ADD COLUMN credits_period TEXT DEFAULT 'monthly'", + "credits_period_start": "ALTER TABLE licenses ADD COLUMN credits_period_start TEXT", + "budget_warning_sent": "ALTER TABLE licenses ADD COLUMN budget_warning_sent INTEGER DEFAULT 0", +} + + +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}") + + cols = [c[1] for c in conn.execute("PRAGMA table_info(licenses)")] + for name, stmt in COLUMNS.items(): + if name not in cols: + conn.execute(stmt) + print(f" + licenses.{name} Spalte hinzugefügt") + else: + print(f" = licenses.{name} war bereits da") + + conn.commit() + conn.close() + print("Migration abgeschlossen.") + return 0 + + +if __name__ == "__main__": + db_path = os.environ.get("DB_PATH", "/home/claude-dev/osint-data/osint.db") + sys.exit(main(db_path)) diff --git a/migrations/2026-07-25_system_status.py b/migrations/2026-07-25_system_status.py new file mode 100644 index 0000000..2e96c40 --- /dev/null +++ b/migrations/2026-07-25_system_status.py @@ -0,0 +1,46 @@ +"""Migration 2026-07-25: Tabelle system_status anlegen (Telegram-Session-Status). + +Der Monitor legt die Tabelle beim Start selbst an und meldet dort u.a. den +Status seiner Telegram-Session (Key telegram_session). 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_system_status.py + DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-07-25_system_status.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 system_status ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """) + print(" + system_status angelegt (oder vorhanden)") + + conn.commit() + conn.close() + print("Migration abgeschlossen.") + return 0 + + +if __name__ == "__main__": + db_path = os.environ.get("DB_PATH", "/home/claude-dev/osint-data/osint.db") + sys.exit(main(db_path)) diff --git a/migrations/2026-07-25_user_activity.py b/migrations/2026-07-25_user_activity.py new file mode 100644 index 0000000..e7177d5 --- /dev/null +++ b/migrations/2026-07-25_user_activity.py @@ -0,0 +1,46 @@ +"""Migration 2026-07-25: Tabelle user_activity_days anlegen (MAU/DAU). + +Der Monitor legt die Tabelle beim Start selbst an und schreibt je Nutzer und +Kalendertag einen Eintrag (auth.get_current_user). Die Portal-Staging-Umgebung +nutzt eine EIGENE Datenbank ohne laufenden Monitor, deshalb dieses Skript. +Idempotent (CREATE TABLE IF NOT EXISTS), es werden keine Daten eingefügt. + +Ausführung: + DB_PATH=/home/claude-dev/AegisSight-Monitor-staging/data/osint.db python3 migrations/2026-07-25_user_activity.py + DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-07-25_user_activity.py +""" +import os +import sqlite3 +import sys + + +def main(db_path: str) -> int: + if not os.path.exists(db_path): + print(f"FEHLER: DB nicht gefunden: {db_path}", file=sys.stderr) + return 1 + + conn = sqlite3.connect(db_path, timeout=60) + conn.execute("PRAGMA busy_timeout = 60000") + conn.execute("PRAGMA journal_mode = WAL") + + print(f"Migration auf {db_path}") + + conn.execute(""" + CREATE TABLE IF NOT EXISTS user_activity_days ( + user_id INTEGER NOT NULL, + day TEXT NOT NULL, + tenant_id INTEGER, + PRIMARY KEY (user_id, day) + ) + """) + print(" + user_activity_days angelegt (oder vorhanden)") + + conn.commit() + conn.close() + print("Migration abgeschlossen.") + return 0 + + +if __name__ == "__main__": + db_path = os.environ.get("DB_PATH", "/home/claude-dev/osint-data/osint.db") + sys.exit(main(db_path)) diff --git a/src/health_run.py b/src/health_run.py new file mode 100644 index 0000000..3803f15 --- /dev/null +++ b/src/health_run.py @@ -0,0 +1,47 @@ +"""In-Memory-Status für den manuellen Health-Run des Portals. + +Pure Funktionen ohne DB und IO, damit die Zustandsübergänge unit-testbar sind. +Der Status lebt nur im Prozess. Nach einem Neustart ist er wieder idle, ein +dabei abgebrochener Lauf hinterlässt höchstens einen partiellen Stand in +source_health_checks, den der nächste Lauf archiviert und überschreibt +(identisch zum Verhalten des Monitor-Nachtjobs um 04:00 Uhr). +""" +from datetime import datetime + + +def new_state() -> dict: + return { + "running": False, + "started_at": None, + "finished_at": None, + "result": None, + "error": None, + } + + +def _now_iso(now=None) -> str: + return now if now is not None else datetime.now().isoformat(timespec="seconds") + + +def try_start(state: dict, now=None) -> bool: + """Setzt den Lauf auf running. False, wenn schon einer läuft.""" + if state.get("running"): + return False + state["running"] = True + state["started_at"] = _now_iso(now) + state["finished_at"] = None + state["result"] = None + state["error"] = None + return True + + +def mark_finished(state: dict, result: dict, now=None) -> None: + state["running"] = False + state["finished_at"] = _now_iso(now) + state["result"] = result + + +def mark_failed(state: dict, error: str, now=None) -> None: + state["running"] = False + state["finished_at"] = _now_iso(now) + state["error"] = error diff --git a/src/main.py b/src/main.py index e780557..0eca26d 100644 --- a/src/main.py +++ b/src/main.py @@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from config import STATIC_DIR, PORT -from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit, translation, x_scraper, pricing +from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit, translation, x_scraper, pricing, statistik logging.basicConfig( level=logging.INFO, @@ -22,6 +22,26 @@ logger = logging.getLogger("verwaltung") @asynccontextmanager async def lifespan(app: FastAPI): + # Schutz gegen falsche Deploy-Reihenfolge: Die Credits-Spalten in licenses + # legt die Monitor-Migration an. Fehlen sie, scheitern Lizenz-Anlage und + # Token-Tab mit "no such column". Hier nur ein deutlicher Log-Hinweis, + # kein Hard-Fail. + try: + from database import get_db + db = await get_db() + try: + cursor = await db.execute("PRAGMA table_info(licenses)") + cols = [row[1] for row in await cursor.fetchall()] + if "credits_period" not in cols: + logger.error( + "licenses-Tabelle ohne Credits-Spalten (credits_period fehlt)! " + "Zuerst den Monitor deployen (dessen Migration legt sie an) " + "oder migrations/2026-07-25_credits_period.py gegen diese DB fahren." + ) + finally: + await db.close() + except Exception as exc: + logger.warning("Startup-Schema-Check uebersprungen: %s", exc) logger.info("Verwaltungsportal gestartet auf Port %s", PORT) yield logger.info("Verwaltungsportal beendet") @@ -45,6 +65,7 @@ app.include_router(audit.router) app.include_router(translation.router) app.include_router(x_scraper.router) app.include_router(pricing.router) +app.include_router(statistik.router) # --- Statische Dateien --- app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") diff --git a/src/models.py b/src/models.py index 573afa2..abf9728 100644 --- a/src/models.py +++ b/src/models.py @@ -22,10 +22,35 @@ class TokenResponse(BaseModel): email: str = "" +class LicenseParams(BaseModel): + """Stellwerte einer Lizenz ohne Org-Bezug. + + Wird zweifach genutzt, direkt beim Lizenz-Anlegen (LicenseCreate) und + verschachtelt beim Org-Anlegen (OrgCreate.license). Die Laufzeitfelder + (credits_used, credits_period_start, budget_warning_sent) verwaltet der + Monitor, das Portal setzt nur Stellwerte. + """ + license_type: str = Field(pattern="^(trial|annual|permanent)$") + max_users: int = Field(default=5, ge=1, le=1000) + duration_days: Optional[int] = Field(default=None, ge=1, le=3650) + token_budget_usd: Optional[float] = None + credits_total: Optional[int] = None + cost_per_credit: Optional[float] = None + budget_warning_percent: Optional[int] = Field(default=80, ge=0, le=100) # 0 = Warnung aus + unlimited_budget: bool = False + # 'monthly' = Kontingent wird zum Monatswechsel neu gefuellt (der Monitor + # setzt den Verbrauch traege zurueck), 'total' = gilt fuer die Laufzeit. + # Ungenutzte Credits verfallen zum Periodenende (kein Uebertrag, + # Produktentscheidung 07/2026). + credits_period: str = Field(default="monthly", pattern="^(monthly|total)$") + + class OrgCreate(BaseModel): name: str = Field(min_length=1, max_length=200) slug: str = Field(min_length=1, max_length=100, pattern="^[a-z0-9-]+$") output_language: str = Field(default="de", pattern="^(de|en)$") + # Optional direkt eine Lizenz mit Credits-Kontingent anlegen + license: Optional[LicenseParams] = None class OrgUpdate(BaseModel): @@ -48,16 +73,8 @@ class OrgResponse(BaseModel): output_language: str = "de" -class LicenseCreate(BaseModel): +class LicenseCreate(LicenseParams): organization_id: int - license_type: str = Field(pattern="^(trial|annual|permanent)$") - max_users: int = Field(default=5, ge=1, le=1000) - duration_days: Optional[int] = Field(default=None, ge=1, le=3650) - token_budget_usd: Optional[float] = None - credits_total: Optional[int] = None - cost_per_credit: Optional[float] = None - budget_warning_percent: Optional[int] = Field(default=80, ge=1, le=100) - unlimited_budget: bool = False class LicenseResponse(BaseModel): @@ -75,6 +92,10 @@ class LicenseResponse(BaseModel): cost_per_credit: Optional[float] = None budget_warning_percent: Optional[int] = None unlimited_budget: bool = False + # Neue Kontingent-Felder, alle optional mit Default, damit Responses auch + # von einer noch nicht migrierten Datenbank validieren. + credits_period: Optional[str] = "monthly" + credits_period_start: Optional[str] = None created_at: str globe_access: bool = False network_access: bool = False diff --git a/src/routers/audit.py b/src/routers/audit.py index d7ed9f4..b94cc89 100644 --- a/src/routers/audit.py +++ b/src/routers/audit.py @@ -2,7 +2,7 @@ import json import logging from typing import Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from auth import get_current_admin from database import db_dependency import aiosqlite @@ -25,7 +25,7 @@ async def list_audit( action: Optional[str] = None, resource_type: Optional[str] = None, resource_id: Optional[int] = None, - admin_id: Optional[int] = None, + admin_id: Optional[str] = None, from_ts: Optional[str] = None, to_ts: Optional[str] = None, limit: int = 200, @@ -33,7 +33,11 @@ async def list_audit( admin: dict = Depends(get_current_admin), db: aiosqlite.Connection = Depends(db_dependency), ): - """Audit-Log-Eintraege auflisten mit Filter + Pagination.""" + """Audit-Log-Eintraege auflisten mit Filter + Pagination. + + action, resource_type und admin_id akzeptieren kommagetrennte Mehrfachwerte + (Checkbox-Filter im Frontend), ein Einzelwert funktioniert weiterhin. + """ if limit < 1 or limit > 1000: limit = 200 if offset < 0: @@ -42,21 +46,34 @@ async def list_audit( where = [] params = [] if action: - where.append("action = ?") - params.append(action) + vals = [v.strip() for v in action.split(",") if v.strip()] + if vals: + where.append(f"action IN ({','.join('?' for _ in vals)})") + params.extend(vals) if resource_type: - where.append("resource_type = ?") - params.append(resource_type) + vals = [v.strip() for v in resource_type.split(",") if v.strip()] + if vals: + where.append(f"resource_type IN ({','.join('?' for _ in vals)})") + params.extend(vals) if resource_id is not None: where.append("resource_id = ?") params.append(resource_id) - if admin_id is not None: - where.append("admin_id = ?") - params.append(admin_id) + if admin_id: + try: + ids = [int(v) for v in admin_id.split(",") if v.strip()] + except ValueError: + raise HTTPException(status_code=422, detail="admin_id muss eine Zahl oder kommagetrennte Zahlen sein") + if ids: + where.append(f"admin_id IN ({','.join('?' for _ in ids)})") + params.extend(ids) if from_ts: where.append("ts >= ?") params.append(from_ts) if to_ts: + # Datums-Only-Wert (aus ) soll den gewählten Tag + # komplett EINschließen, sonst fiele er wegen ts <= 'YYYY-MM-DD' raus. + if len(to_ts) == 10: + to_ts = to_ts + " 23:59:59" where.append("ts <= ?") params.append(to_ts) diff --git a/src/routers/licenses.py b/src/routers/licenses.py index c83d427..77667c6 100644 --- a/src/routers/licenses.py +++ b/src/routers/licenses.py @@ -1,7 +1,7 @@ """Lizenz-CRUD.""" from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status, Request -from models import LicenseCreate, LicenseResponse +from models import LicenseCreate, LicenseParams, LicenseResponse from auth import get_current_admin from database import db_dependency from audit import log_action, get_client_ip, row_to_dict @@ -10,6 +10,88 @@ import aiosqlite router = APIRouter(prefix="/api/licenses", tags=["licenses"]) +async def insert_license_row( + db: aiosqlite.Connection, org_id: int, params: LicenseParams +) -> tuple[int, list[dict]]: + """Widerruft aktive Lizenzen der Org und legt die neue Zeile an. + + Committet bewusst NICHT, die Transaktionsgrenze bestimmt der Aufrufer. + So kann das Org-Anlegen Organisation und Lizenz atomar anlegen. + Gibt (neue Lizenz-ID, Snapshots der widerrufenen Lizenzen) zurueck, + die Audit-Eintraege schreibt audit_license_creation() nach dem Commit. + """ + cursor = await db.execute( + "SELECT * FROM licenses WHERE organization_id = ? AND status = 'active'", + (org_id,), + ) + revoked_lics = [dict(r) for r in await cursor.fetchall()] + if revoked_lics: + await db.execute( + "UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'", + (org_id,), + ) + + now = datetime.now(timezone.utc) + valid_from = now.isoformat() + valid_until = None + + if params.license_type == "permanent": + valid_until = None + elif params.duration_days: + valid_until = (now + timedelta(days=params.duration_days)).isoformat() + elif params.license_type == "trial": + valid_until = (now + timedelta(days=14)).isoformat() + elif params.license_type == "annual": + valid_until = (now + timedelta(days=365)).isoformat() + + # Bei unlimited_budget: Kontingent-Felder ignorieren + if params.unlimited_budget: + token_budget_usd = None + credits_total = None + cost_per_credit = None + else: + token_budget_usd = params.token_budget_usd + credits_total = params.credits_total + cost_per_credit = params.cost_per_credit + + # credits_period_start bleibt bewusst NULL, der Monitor setzt die + # Periodenmarke traege bei der naechsten Lizenzpruefung selbst. + cursor = await db.execute( + """INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status, + token_budget_usd, credits_total, cost_per_credit, budget_warning_percent, unlimited_budget, + credits_period) + VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)""", + (org_id, params.license_type, params.max_users, valid_from, valid_until, + token_budget_usd, credits_total, cost_per_credit, params.budget_warning_percent, + 1 if params.unlimited_budget else 0, + params.credits_period), + ) + return cursor.lastrowid, revoked_lics + + +async def audit_license_creation( + db: aiosqlite.Connection, admin: dict, ip: str, lic_id: int, revoked_lics: list[dict] +) -> dict: + """Schreibt die Audit-Eintraege fuers Lizenz-Anlegen (nach dem Commit).""" + for old_lic in revoked_lics: + new_lic = dict(old_lic) + new_lic["status"] = "revoked" + await log_action( + db, admin, ip, + action="update", resource_type="license", resource_id=old_lic["id"], + before=old_lic, after=new_lic, + ) + + cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (lic_id,)) + new_lic = dict(await cursor.fetchone()) + await log_action( + db, admin, ip, + action="create", resource_type="license", resource_id=lic_id, + after=new_lic, + ) + return new_lic + + @router.get("", response_model=list[LicenseResponse]) async def list_licenses( org_id: int = None, @@ -39,68 +121,12 @@ async def create_license( if not await cursor.fetchone(): raise HTTPException(status_code=404, detail="Organisation nicht gefunden") - # Bestehende aktive Lizenz widerrufen + Snapshot fuer Audit - cursor = await db.execute( - "SELECT * FROM licenses WHERE organization_id = ? AND status = 'active'", - (data.organization_id,), - ) - revoked_lics = [dict(r) for r in await cursor.fetchall()] - if revoked_lics: - await db.execute( - "UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'", - (data.organization_id,), - ) - for old_lic in revoked_lics: - new_lic = dict(old_lic) - new_lic["status"] = "revoked" - await log_action( - db, admin, get_client_ip(request), - action="update", resource_type="license", resource_id=old_lic["id"], - before=old_lic, after=new_lic, - ) - - now = datetime.now(timezone.utc) - valid_from = now.isoformat() - valid_until = None - - if data.license_type == "permanent": - valid_until = None - elif data.duration_days: - valid_until = (now + timedelta(days=data.duration_days)).isoformat() - elif data.license_type == "trial": - valid_until = (now + timedelta(days=14)).isoformat() - elif data.license_type == "annual": - valid_until = (now + timedelta(days=365)).isoformat() - - # Bei unlimited_budget: Credits/Cost/Budget ignorieren - if data.unlimited_budget: - token_budget_usd = None - credits_total = None - cost_per_credit = None - else: - token_budget_usd = data.token_budget_usd - credits_total = data.credits_total - cost_per_credit = data.cost_per_credit - - cursor = await db.execute( - """INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status, - token_budget_usd, credits_total, cost_per_credit, budget_warning_percent, unlimited_budget) - VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)""", - (data.organization_id, data.license_type, data.max_users, valid_from, valid_until, - token_budget_usd, credits_total, cost_per_credit, data.budget_warning_percent, - 1 if data.unlimited_budget else 0), - ) - lic_id = cursor.lastrowid + lic_id, revoked_lics = await insert_license_row(db, data.organization_id, data) await db.commit() - cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (lic_id,)) - new_lic = dict(await cursor.fetchone()) - await log_action( - db, admin, get_client_ip(request), - action="create", resource_type="license", resource_id=lic_id, - after=new_lic, + return await audit_license_creation( + db, admin, get_client_ip(request), lic_id, revoked_lics ) - return new_lic @router.put("/{license_id}/revoke") diff --git a/src/routers/organizations.py b/src/routers/organizations.py index a936665..28f38e7 100644 --- a/src/routers/organizations.py +++ b/src/routers/organizations.py @@ -64,6 +64,16 @@ async def create_organization( (data.name, data.slug, now, now), ) org_id = cursor.lastrowid + + # Optional direkt eine Lizenz mit Credits-Kontingent anlegen. Beide Inserts + # liegen VOR dem Commit, schlaegt der Lizenz-Teil fehl (z.B. fehlende + # Spalten), entsteht keine halb angelegte Organisation. + lic_id = None + revoked_lics: list = [] + if data.license is not None: + from routers.licenses import insert_license_row + lic_id, revoked_lics = await insert_license_row(db, org_id, data.license) + await db.commit() # output_language als organization_settings-Eintrag persistieren @@ -77,6 +87,11 @@ async def create_organization( action="create", resource_type="organization", resource_id=org_id, after=dict(new_row_obj), ) + if lic_id is not None: + from routers.licenses import audit_license_creation + await audit_license_creation( + db, admin, get_client_ip(request), lic_id, revoked_lics + ) return await _enrich_org(db, new_row_obj) diff --git a/src/routers/pricing.py b/src/routers/pricing.py index 5b10a96..ae0ba60 100644 --- a/src/routers/pricing.py +++ b/src/routers/pricing.py @@ -12,8 +12,10 @@ from collections import defaultdict from datetime import datetime, timedelta import aiosqlite -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Request from auth import get_current_admin +from database import get_db +from audit import log_action, get_client_ip logger = logging.getLogger("verwaltung.pricing") router = APIRouter(prefix="/api/pricing", tags=["Pricing"]) @@ -21,6 +23,90 @@ router = APIRouter(prefix="/api/pricing", tags=["Pricing"]) COST_BASIS_DB = os.environ.get("COST_BASIS_DB_PATH", "/home/claude-dev/osint-data/osint.db") USD_PER_CREDIT = 0.20 +# Rückfallwerte, falls die Preistabelle (noch) fehlt. Entsprechen den +# Monitor-Defaults (CREDIT_TARIFF in AegisSight-Monitor, src/config.py). +TARIFF_DEFAULTS = { + "monitor_adhoc": 45.0, + "monitor_research": 40.0, + "analysis": 12.0, + "factcheck": 12.0, + "chat": 1.0, + "enhance": 1.0, + "globe": 1.0, +} + + +@router.get("/tariff") +async def get_tariff(admin=Depends(get_current_admin)): + """Wirksame Credits-Sätze aus der geteilten Preistabelle billing_tariff. + + Die Tabelle ist die Quelle der Buchung im Monitor. Fehlt sie noch + (Monitor-Migration nicht gelaufen), gelten die Rückfallwerte. + """ + tariff = dict(TARIFF_DEFAULTS) + source = "defaults" + db = await get_db() + try: + try: + cursor = await db.execute("SELECT tariff_key, credits FROM billing_tariff") + rows = await cursor.fetchall() + if rows: + source = "db" + for row in rows: + tariff[row["tariff_key"]] = row["credits"] + except Exception: + logger.warning("billing_tariff fehlt noch, liefere Rückfallwerte") + finally: + await db.close() + return {"tariff": tariff, "source": source, "usd_per_credit": USD_PER_CREDIT} + + +@router.put("/tariff") +async def update_tariff(data: dict, request: Request, admin=Depends(get_current_admin)): + """Setzt Credits-Sätze in billing_tariff. Ab der nächsten Buchung wirksam.""" + allowed = tuple(TARIFF_DEFAULTS.keys()) + updates = {} + for key, value in (data or {}).items(): + if key not in allowed: + raise HTTPException(status_code=400, detail=f"Unbekannter Tarif-Schlüssel: {key}") + try: + v = float(value) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail=f"{key} muss eine Zahl sein") + if not 0.5 <= v <= 10000: + raise HTTPException(status_code=400, detail=f"{key} muss zwischen 0,5 und 10000 liegen") + updates[key] = v + if not updates: + raise HTTPException(status_code=400, detail="Keine Sätze übergeben") + + db = await get_db() + try: + cursor = await db.execute("SELECT tariff_key, credits FROM billing_tariff") + before = {r["tariff_key"]: r["credits"] for r in await cursor.fetchall()} + for key, v in updates.items(): + await db.execute( + "INSERT INTO billing_tariff (tariff_key, credits, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) " + "ON CONFLICT(tariff_key) DO UPDATE SET credits = excluded.credits, updated_at = CURRENT_TIMESTAMP", + (key, v), + ) + await db.commit() + await log_action( + db, admin, get_client_ip(request), + action="update", resource_type="tariff", resource_id=0, + before=before, after={**before, **updates}, + ) + logger.info(f"Preistabelle aktualisiert: {updates}") + return {"ok": True, "updated": updates} + except HTTPException: + raise + except Exception as exc: + raise HTTPException( + status_code=500, + detail=f"Preistabelle nicht beschreibbar ({exc}). Falls sie fehlt, zuerst den Monitor deployen oder migrations/2026-07-25_billing_tariff.py fahren.", + ) + finally: + await db.close() + @router.get("/cost-basis") async def cost_basis(days: int = 30, admin=Depends(get_current_admin)): diff --git a/src/routers/sources.py b/src/routers/sources.py index 458893b..3f0e319 100644 --- a/src/routers/sources.py +++ b/src/routers/sources.py @@ -7,7 +7,6 @@ import re import uuid from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Request, UploadFile, status -from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from typing import Optional import aiosqlite @@ -16,7 +15,8 @@ from auth import get_current_admin from database import db_dependency, get_db from audit import log_action, get_client_ip from source_meta import get_meta -from config import HEALTH_CHECK_USER_AGENT, HEALTH_CHECK_TIMEOUT_S, DB_PATH +from health_run import new_state, try_start, mark_finished, mark_failed +from config import DB_PATH from shared.source_rules import ( discover_source, discover_all_feeds, @@ -113,12 +113,45 @@ async def get_sources_meta(admin: dict = Depends(get_current_admin)): return get_meta() +@router.get("/tasks/summary") +async def tasks_summary( + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Billiger Zähler für den Aufgaben-Badge im Quellen-Reiter. + + classification_pending nutzt DIESELBE Bedingung wie /classification/queue, + damit Badge und Review-Karten nie auseinanderlaufen. + """ + suggestions_pending = 0 + cur = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='source_suggestions'" + ) + if await cur.fetchone(): + cur = await db.execute( + "SELECT COUNT(*) AS cnt FROM source_suggestions WHERE status = 'pending'" + ) + suggestions_pending = (await cur.fetchone())["cnt"] + + cur = await db.execute( + """SELECT COUNT(*) AS cnt FROM sources + WHERE status = 'active' AND proposed_political_orientation IS NOT NULL""" + ) + classification_pending = (await cur.fetchone())["cnt"] + + return { + "suggestions_pending": suggestions_pending, + "classification_pending": classification_pending, + "total": suggestions_pending + classification_pending, + } + + class GlobalSourceCreate(BaseModel): name: str = Field(min_length=1, max_length=200) url: Optional[str] = None domain: Optional[str] = None - source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$") + source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|telegram_channel|podcast_feed|pdf_document|x_account)$") category: str = Field(default="sonstige") status: str = Field(default="active", pattern="^(active|inactive)$") notes: Optional[str] = None @@ -131,7 +164,7 @@ class GlobalSourceUpdate(BaseModel): name: Optional[str] = Field(default=None, max_length=200) url: Optional[str] = None domain: Optional[str] = None - source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$") + source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|telegram_channel|podcast_feed|pdf_document|x_account)$") category: Optional[str] = None status: Optional[str] = Field(default=None, pattern="^(active|inactive)$") notes: Optional[str] = None @@ -145,18 +178,27 @@ class GlobalSourceUpdate(BaseModel): alignments: Optional[list[str]] = None -@router.get("/global") -async def list_global_sources( +@router.get("") +async def list_sources( + scope: str = "all", admin: dict = Depends(get_current_admin), db: aiosqlite.Connection = Depends(db_dependency), ): - """Alle Grundquellen auflisten (tenant_id IS NULL). + """Vereinte Quellenliste (Grund- und Kundenquellen). - Liefert pro Quelle den worst-case Health-Status aus source_health_checks - (error > warning > ok > unknown). Damit kann das Frontend ein Inline-Badge - pro Zeile zeigen, ohne separate Health-Tab-Abfrage. + scope = all | global | tenant. Liefert pro Quelle den worst-case + Health-Status aus source_health_checks (error > warning > ok > unknown) + und den Organisationsnamen bei Kundenquellen (org_name, NULL bei global). """ - cursor = await db.execute(""" + scope_where = { + "all": "1=1", + "global": "s.tenant_id IS NULL", + "tenant": "s.tenant_id IS NOT NULL", + }.get(scope) + if scope_where is None: + raise HTTPException(status_code=422, detail="scope muss all, global oder tenant sein") + + cursor = await db.execute(f""" WITH article_stats AS ( -- Match per source-Name (case-insensitive). source_url im articles ist die -- Artikel-URL, nicht die Feed-URL - daher matcht das nicht mit sources.url. @@ -168,14 +210,6 @@ async def list_global_sources( AND source IS NOT NULL GROUP BY LOWER(source) ), - excluded_counts AS ( - SELECT LOWER(ued.domain) AS dom, - COUNT(DISTINCT u.organization_id) AS cnt - FROM user_excluded_domains ued - JOIN users u ON u.id = ued.user_id - WHERE ued.domain IS NOT NULL - GROUP BY LOWER(ued.domain) - ), health_agg AS ( SELECT source_id, MAX(CASE WHEN status = 'error' THEN 3 @@ -186,6 +220,7 @@ async def list_global_sources( GROUP BY source_id ) SELECT s.*, + o.name AS org_name, CASE ha.rank WHEN 3 THEN 'error' WHEN 2 THEN 'warning' @@ -193,20 +228,15 @@ async def list_global_sources( ELSE NULL END AS health_status, COALESCE(ast.a7d, 0) AS articles_7d, - COALESCE(ast.a30d, 0) AS articles_30d, - COALESCE(ec.cnt, 0) AS tenant_excluded_count + COALESCE(ast.a30d, 0) AS articles_30d FROM sources s + LEFT JOIN organizations o ON o.id = s.tenant_id LEFT JOIN article_stats ast ON ast.s_lower = LOWER(s.name) - LEFT JOIN excluded_counts ec ON ec.dom = LOWER(s.domain) LEFT JOIN health_agg ha ON ha.source_id = s.id - WHERE s.tenant_id IS NULL + WHERE {scope_where} ORDER BY s.category, s.source_type, s.name """) - rows = [dict(row) for row in await cursor.fetchall()] - alignments_map = await _load_alignments_for(db, [r["id"] for r in rows]) - for r in rows: - r["alignments"] = alignments_map.get(r["id"], []) - return rows + return [dict(row) for row in await cursor.fetchall()] @router.post("/global", status_code=201) @@ -256,13 +286,17 @@ async def update_global_source( admin: dict = Depends(get_current_admin), db: aiosqlite.Connection = Depends(db_dependency), ): - """Grundquelle bearbeiten — inkl. Klassifikation + alignments.""" + """Quelle bearbeiten (Grund- und Kundenquellen) — inkl. Klassifikation + alignments. + + Kundenquellen sind absichtlich mit erlaubt, damit der Deaktivieren-Knopf + der vereinten Liste (status=inactive) fuer beide Herkuenfte funktioniert. + """ cursor = await db.execute( - "SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,) + "SELECT * FROM sources WHERE id = ?", (source_id,) ) row = await cursor.fetchone() if not row: - raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden") + raise HTTPException(status_code=404, detail="Quelle nicht gefunden") before = dict(row) before_alignments = sorted((await _load_alignments_for(db, [source_id])).get(source_id, [])) @@ -347,31 +381,35 @@ async def delete_global_source( -@router.get("/global/languages") -async def get_global_languages( +@router.get("/languages") +async def get_languages( admin: dict = Depends(get_current_admin), db: aiosqlite.Connection = Depends(db_dependency), ): - """Distinct language-Werte aus Grundquellen - für Frontend-Filter-Dropdown.""" + """Distinct language-Werte aller Quellen - für Frontend-Filter-Dropdown.""" cur = await db.execute(""" SELECT DISTINCT language FROM sources - WHERE tenant_id IS NULL AND language IS NOT NULL AND language != '' + WHERE language IS NOT NULL AND language != '' ORDER BY language """) return [r["language"] for r in await cur.fetchall()] -@router.get("/global/stats") -async def get_global_stats( +@router.get("/stats") +async def get_sources_stats( admin: dict = Depends(get_current_admin), db: aiosqlite.Connection = Depends(db_dependency), ): - """Aggregierte Stats für die Grundquellen-Stats-Bar oben im Tab.""" + """Aggregierte Stats für die Stats-Bar der vereinten Quellenliste. + + Zählt Grund- UND Kundenquellen, aktive wie inaktive (die Liste zeigt + ebenfalls alle). by_origin liefert die Aufteilung, last_check den + Zeitpunkt des jüngsten Health-Checks. + """ cur = await db.execute(""" SELECT source_type, COUNT(*) AS count, COALESCE(SUM(article_count), 0) AS articles FROM sources - WHERE tenant_id IS NULL AND status = 'active' GROUP BY source_type """) by_type = {} @@ -383,8 +421,17 @@ async def get_global_stats( total += d["count"] total_articles += d["articles"] - # Health-Counter + cur = await db.execute(""" + SELECT CASE WHEN tenant_id IS NULL THEN 'global' ELSE 'tenant' END AS origin, + COUNT(*) AS cnt + FROM sources + GROUP BY origin + """) + by_origin = {dict(r)["origin"]: dict(r)["cnt"] for r in await cur.fetchall()} + + # Health-Counter + Zeitpunkt des letzten Checks health = {"errors": 0, "warnings": 0, "ok": 0} + last_check = None cur = await db.execute(""" SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks' """) @@ -393,7 +440,6 @@ async def get_global_stats( SELECT h.status AS hs, COUNT(DISTINCT h.source_id) AS cnt FROM source_health_checks h JOIN sources s ON s.id = h.source_id - WHERE s.tenant_id IS NULL AND s.status = 'active' GROUP BY h.status """) for r in await cur.fetchall(): @@ -404,31 +450,21 @@ async def get_global_stats( health["warnings"] = d["cnt"] elif d["hs"] == "ok": health["ok"] = d["cnt"] + cur = await db.execute("SELECT MAX(checked_at) AS lc FROM source_health_checks") + row = await cur.fetchone() + if row: + last_check = dict(row)["lc"] return { "by_type": by_type, + "by_origin": by_origin, "total": total, "total_articles": total_articles, "health": health, + "last_check": last_check, } -@router.get("/tenant") -async def list_tenant_sources( - admin: dict = Depends(get_current_admin), - db: aiosqlite.Connection = Depends(db_dependency), -): - """Alle tenant-spezifischen Quellen mit Org-Name auflisten.""" - cursor = await db.execute(""" - SELECT s.*, o.name as org_name - FROM sources s - LEFT JOIN organizations o ON o.id = s.tenant_id - WHERE s.tenant_id IS NOT NULL - ORDER BY o.name, s.category, s.name - """) - return [dict(row) for row in await cursor.fetchall()] - - @router.post("/tenant/{source_id}/promote") async def promote_to_global( source_id: int, @@ -678,107 +714,6 @@ async def add_discovered_sources( # --- Health-Check & Vorschläge --- -@router.get("/health") -async def get_health( - limit: int = 100, - offset: int = 0, - admin: dict = Depends(get_current_admin), - db: aiosqlite.Connection = Depends(db_dependency), -): - """Health-Check-Ergebnisse abrufen. - - Default-Limit 100, sortiert nach Status (errors first, dann warnings, dann ok). - Counters (errors/warnings/ok/total_checks) beziehen sich auf den GESAMTEN - Datenbestand, nicht nur auf die zurückgegebene Page. Damit kann das Frontend - den vollen Status anzeigen, ohne alle Zeilen rendern zu müssen. - has_more zeigt an, ob es weitere Items zum Nachladen gibt. - all_orgs liefert die Liste aller Tenants mit Health-Checks (für Filter-Dropdown). - """ - limit = max(1, min(int(limit or 100), 5000)) - offset = max(0, int(offset or 0)) - - # Prüfen ob Tabelle existiert - cursor = await db.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'" - ) - if not await cursor.fetchone(): - return { - "last_check": None, "total_checks": 0, - "errors": 0, "warnings": 0, "ok": 0, - "checks": [], "all_orgs": [], - "limit": limit, "offset": offset, "has_more": False, - } - - # Aggregate über GESAMTEN Bestand. Eine GROUP-BY-Query nach (check_type, status) - # liefert sowohl die Top-Counters als auch das feine Breakdown für die UI. - cursor = await db.execute( - "SELECT check_type, status, COUNT(*) AS n FROM source_health_checks GROUP BY check_type, status" - ) - breakdown = {} # {check_type: {status: count}} - error_count = 0 - warning_count = 0 - ok_count = 0 - for row in await cursor.fetchall(): - ct = row["check_type"] - st = row["status"] - breakdown.setdefault(ct, {})[st] = row["n"] - if st == "error": - error_count += row["n"] - elif st == "warning": - warning_count += row["n"] - elif st == "ok": - ok_count += row["n"] - total_checks = error_count + warning_count + ok_count - - # Paginierte Daten - cursor = await db.execute(""" - SELECT - h.source_id, s.name, s.domain, s.tenant_id, s.language, - o.name AS org_name, - h.check_type, h.status, h.message - FROM source_health_checks h - JOIN sources s ON s.id = h.source_id - LEFT JOIN organizations o ON o.id = s.tenant_id - ORDER BY - CASE h.status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, - s.name - LIMIT ? OFFSET ? - """, (limit, offset)) - checks = [dict(row) for row in await cursor.fetchall()] - - # Org-Liste (alle Tenants mit Health-Checks, für Frontend-Filter-Dropdown) - cursor = await db.execute(""" - SELECT DISTINCT s.tenant_id AS id, o.name AS name - FROM source_health_checks h - JOIN sources s ON s.id = h.source_id - LEFT JOIN organizations o ON o.id = s.tenant_id - WHERE s.tenant_id IS NOT NULL - ORDER BY o.name - """) - all_orgs = [dict(row) for row in await cursor.fetchall()] - - cursor = await db.execute("SELECT MAX(checked_at) as last_check FROM source_health_checks") - row = await cursor.fetchone() - last_check = row["last_check"] if row else None - - return { - "last_check": last_check, - "total_checks": total_checks, - "errors": error_count, - "warnings": warning_count, - "ok": ok_count, - "breakdown": breakdown, - "checks": checks, - "all_orgs": all_orgs, - "limit": limit, - "offset": offset, - "has_more": (offset + len(checks)) < total_checks, - } - - - - - @router.get("/health/history") async def get_health_history( limit: int = 20, @@ -810,12 +745,92 @@ async def get_health_history( return [dict(row) for row in await cursor.fetchall()] +# Status des manuellen Health-Runs. In-Memory: nach einem Neustart wieder idle, +# ein dabei abgebrochener Lauf hinterlässt höchstens einen partiellen +# source_health_checks-Stand, den der nächste Lauf archiviert und heilt. +_HEALTH_RUN_STATE = new_state() + + +async def _health_run_background(): + """Manueller Health-Run, exakt nach dem Vorbild des Monitor-Nachtjobs + daily_source_health_check (eigene Connection, Checks + Vorschläge).""" + from shared.services.source_health import run_health_checks + from shared.services.source_suggester import generate_suggestions + + db = await get_db() + try: + result = await run_health_checks(db) + suggestions = await generate_suggestions(db) + mark_finished(_HEALTH_RUN_STATE, {**result, "suggestions": suggestions}) + logger.info( + "Manueller Health-Run: %s geprüft, %s Probleme, %s neue Vorschläge", + result.get("checked"), result.get("issues"), suggestions, + ) + except Exception as e: + logger.error("Manueller Health-Run fehlgeschlagen: %s", e, exc_info=True) + mark_failed(_HEALTH_RUN_STATE, str(e)) + finally: + await db.close() + + +@router.post("/health/run", status_code=202) +async def start_health_run( + background_tasks: BackgroundTasks, + admin: dict = Depends(get_current_admin), +): + """Health-Check + Vorschläge manuell anstoßen (läuft im Hintergrund weiter).""" + if not try_start(_HEALTH_RUN_STATE): + raise HTTPException(status_code=409, detail="Es läuft bereits eine Prüfung") + background_tasks.add_task(_health_run_background) + return {"status": "started", "started_at": _HEALTH_RUN_STATE["started_at"]} + + +@router.get("/health/run-status") +async def health_run_status(admin: dict = Depends(get_current_admin)): + """Aktueller Zustand des manuellen Health-Runs (Polling-Ziel des Frontends).""" + return _HEALTH_RUN_STATE + + +@router.get("/{source_id}/health") +async def get_source_health( + source_id: int, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Alle Health-Checks einer einzelnen Quelle (für die Ausklapp-Zeile der Liste). + + Sortiert error > warning > ok, damit das Wichtigste oben steht. + """ + cursor = await db.execute("SELECT id FROM sources WHERE id = ?", (source_id,)) + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="Quelle nicht gefunden") + + cursor = await db.execute(""" + SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks' + """) + if not await cursor.fetchone(): + return {"source_id": source_id, "checks": []} + + cursor = await db.execute(""" + SELECT check_type, status, message, checked_at + FROM source_health_checks + WHERE source_id = ? + ORDER BY CASE status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, + check_type + """, (source_id,)) + return {"source_id": source_id, "checks": [dict(row) for row in await cursor.fetchall()]} + + @router.get("/suggestions") async def get_suggestions( admin: dict = Depends(get_current_admin), db: aiosqlite.Connection = Depends(db_dependency), ): - """Alle Vorschläge abrufen (pending zuerst, dann letzte 20 bearbeitete).""" + """Alle OFFENEN Vorschläge plus die letzten 50 bearbeiteten. + + Kein Deckel auf pending, sonst liefe der Aufgaben-Badge + (tasks/summary zählt ungedeckelt) der Tabelle davon. + """ cursor = await db.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='source_suggestions'" ) @@ -824,44 +839,36 @@ async def get_suggestions( cursor = await db.execute(""" SELECT * FROM source_suggestions - ORDER BY - CASE status WHEN 'pending' THEN 0 ELSE 1 END, - created_at DESC + WHERE status = 'pending' + ORDER BY created_at DESC + """) + pending = [dict(row) for row in await cursor.fetchall()] + cursor = await db.execute(""" + SELECT * FROM source_suggestions + WHERE status != 'pending' + ORDER BY created_at DESC LIMIT 50 """) - return [dict(row) for row in await cursor.fetchall()] + return pending + [dict(row) for row in await cursor.fetchall()] class SuggestionAction(BaseModel): accept: bool -@router.put("/suggestions/{suggestion_id}") -async def update_suggestion( - suggestion_id: int, - action: SuggestionAction, - request: Request, - admin: dict = Depends(get_current_admin), - db: aiosqlite.Connection = Depends(db_dependency), -): - """Vorschlag annehmen oder ablehnen.""" +async def _apply_suggestion_decision(db: aiosqlite.Connection, suggestion: dict, accept: bool): + """Führt die Annahme/Ablehnung EINES pending-Vorschlags aus (ohne commit/Audit). + + Liefert (new_status, result_action). Wird vom Einzel- und vom + Sammel-Endpoint identisch genutzt. + """ import json as _json - cursor = await db.execute( - "SELECT * FROM source_suggestions WHERE id = ?", (suggestion_id,) - ) - suggestion = await cursor.fetchone() - if not suggestion: - raise HTTPException(status_code=404, detail="Vorschlag nicht gefunden") - - suggestion = dict(suggestion) - if suggestion["status"] != "pending": - raise HTTPException(status_code=400, detail=f"Vorschlag bereits {suggestion['status']}") - - new_status = "accepted" if action.accept else "rejected" + suggestion_id = suggestion["id"] + new_status = "accepted" if accept else "rejected" result_action = None - if action.accept: + if accept: stype = suggestion["suggestion_type"] data = _json.loads(suggestion["suggested_data"]) if suggestion["suggested_data"] else {} @@ -924,6 +931,75 @@ async def update_suggestion( "UPDATE source_suggestions SET status = ?, reviewed_at = CURRENT_TIMESTAMP WHERE id = ?", (new_status, suggestion_id), ) + return new_status, result_action + + +class BulkSuggestionAction(BaseModel): + suggestion_ids: list[int] = Field(min_length=1, max_length=1000) + accept: bool + + +@router.put("/suggestions/bulk") +async def bulk_update_suggestions( + action: BulkSuggestionAction, + request: Request, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Mehrere Vorschläge auf einmal annehmen oder ablehnen. + + Nicht mehr pendende IDs (schon bearbeitet, per Auto-Reject erledigt + oder unbekannt) werden übersprungen und gezählt, nicht als Fehler gewertet. + """ + accepted = 0 + rejected = 0 + skipped = 0 + for sid in action.suggestion_ids: + cursor = await db.execute("SELECT * FROM source_suggestions WHERE id = ?", (sid,)) + row = await cursor.fetchone() + if not row: + skipped += 1 + continue + suggestion = dict(row) + if suggestion["status"] != "pending": + skipped += 1 + continue + new_status, _ = await _apply_suggestion_decision(db, suggestion, action.accept) + if new_status == "accepted": + accepted += 1 + else: + rejected += 1 + await db.commit() + await log_action( + db, admin, get_client_ip(request), + action="update", resource_type="source", + before={"suggestion_bulk": len(action.suggestion_ids), "accept": action.accept}, + after={"accepted": accepted, "rejected": rejected, "skipped": skipped}, + ) + return {"accepted": accepted, "rejected": rejected, "skipped": skipped} + + +@router.put("/suggestions/{suggestion_id}") +async def update_suggestion( + suggestion_id: int, + action: SuggestionAction, + request: Request, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Vorschlag annehmen oder ablehnen.""" + cursor = await db.execute( + "SELECT * FROM source_suggestions WHERE id = ?", (suggestion_id,) + ) + suggestion = await cursor.fetchone() + if not suggestion: + raise HTTPException(status_code=404, detail="Vorschlag nicht gefunden") + + suggestion = dict(suggestion) + if suggestion["status"] != "pending": + raise HTTPException(status_code=400, detail=f"Vorschlag bereits {suggestion['status']}") + + new_status, result_action = await _apply_suggestion_decision(db, suggestion, action.accept) await db.commit() await log_action( db, admin, get_client_ip(request), @@ -936,154 +1012,6 @@ async def update_suggestion( return {"status": new_status, "action": result_action} - -@router.post("/health/run-stream") -async def run_health_check_stream( - admin: dict = Depends(get_current_admin), - db: aiosqlite.Connection = Depends(db_dependency), -): - """Health-Check mit Fortschrittsanzeige (SSE-Stream).""" - import json as _json - import asyncio - - # Quellen laden - cursor = await db.execute( - "SELECT id, name, url, domain, source_type, article_count, last_seen_at " - "FROM sources WHERE status = 'active'" # tenant + global - ) - sources = [dict(row) for row in await cursor.fetchall()] - sources_with_url = [s for s in sources if s["url"]] - total = len(sources_with_url) - - async def generate(): - import httpx - import feedparser - - # Phase 1: Erreichbarkeit - yield f"data: {_json.dumps({'phase': 'check', 'checked': 0, 'total': total, 'current': ''})}\n\n" - - # Bisherigen Stand archivieren, dann frisch - run_id = uuid.uuid4().hex[:12] - await db.execute( - "INSERT INTO source_health_history " - "(run_id, source_id, check_type, status, message, details, checked_at) " - "SELECT ?, source_id, check_type, status, message, details, checked_at " - "FROM source_health_checks", - (run_id,), - ) - await db.execute("DELETE FROM source_health_checks") - await db.commit() - - issues_found = 0 - checked = 0 - - async with httpx.AsyncClient( - timeout=HEALTH_CHECK_TIMEOUT_S, follow_redirects=True, - headers={"User-Agent": HEALTH_CHECK_USER_AGENT}, - ) as client: - for source in sources_with_url: - try: - checks = [] - try: - resp = await client.get(source["url"]) - if resp.status_code >= 400: - checks.append({"type": "reachability", "status": "error", - "message": f"HTTP {resp.status_code} - nicht erreichbar"}) - else: - checks.append({"type": "reachability", "status": "ok", "message": "Erreichbar"}) - if source["source_type"] in ("rss_feed", "podcast_feed"): - text = resp.text[:20000] - if " 30: - await db.execute( - "INSERT INTO source_health_checks (source_id, check_type, status, message) VALUES (?, 'stale', 'warning', ?)", - (source["id"], f"Letzter Artikel vor {age} Tagen")) - issues_found += 1 - except (ValueError, TypeError): - pass - - # Duplikate - url_map = {} - for s in sources: - if not s["url"]: - continue - url_norm = s["url"].lower().rstrip("/") - if url_norm in url_map: - existing = url_map[url_norm] - await db.execute( - "INSERT INTO source_health_checks (source_id, check_type, status, message) VALUES (?, 'duplicate', 'warning', ?)", - (s["id"], f"Doppelte URL wie '{existing['name']}' (ID {existing['id']})")) - issues_found += 1 - else: - url_map[url_norm] = s - - await db.commit() - - # Phase 2: Vorschlaege - yield f"data: {_json.dumps({'phase': 'suggestions', 'checked': checked, 'total': total})}\n\n" - - from shared.services.source_suggester import generate_suggestions - suggestion_count = await generate_suggestions(db) - - # Fertig - yield f"data: {_json.dumps({'phase': 'done', 'checked': checked, 'total': total, 'issues': issues_found, 'suggestions': suggestion_count})}\n\n" - - return StreamingResponse(generate(), media_type="text/event-stream") - - @router.post("/health/search-fix/{source_id}") async def search_fix_for_source( source_id: int, @@ -1206,31 +1134,6 @@ Nur das JSON, kein anderer Text.""" # === Klassifikations-Review (LLM-Vorschlaege approve/reject/reclassify) === -@router.get("/classification/stats") -async def classification_stats( - admin: dict = Depends(get_current_admin), - db: aiosqlite.Connection = Depends(db_dependency), -): - """Counts pro classification_source-Wert + Anzahl Pending-Reviews (alle Quellen).""" - cursor = await db.execute( - """SELECT classification_source, COUNT(*) as cnt - FROM sources - WHERE status = 'active' - GROUP BY classification_source""" - ) - by_source = {row["classification_source"] or "legacy": row["cnt"] for row in await cursor.fetchall()} - cursor = await db.execute( - """SELECT COUNT(*) as cnt FROM sources - WHERE status = 'active' AND proposed_political_orientation IS NOT NULL""" - ) - pending = (await cursor.fetchone())["cnt"] - return { - "by_classification_source": by_source, - "pending_review": pending, - "total": sum(by_source.values()), - } - - @router.get("/classification/queue") async def classification_queue( limit: int = 50, diff --git a/src/routers/statistik.py b/src/routers/statistik.py new file mode 100644 index 0000000..29cc30e --- /dev/null +++ b/src/routers/statistik.py @@ -0,0 +1,413 @@ +"""Betriebsstatistik für den Statistik-Reiter. + +Ein Endpoint liefert alle Serien und Kennzahlen in einem JSON. Die +Aggregation passiert in puren Funktionen (ohne DB-Zugriff), damit sie sich +als Unit-Tests prüfen lässt. Führende Quelle für Monitor-Läufe ist +refresh_log (hat Lagentyp, Kosten, Dauer je Lauf), token_usage_monthly +ergänzt die übrigen Quellen (globe, chat, enhance). + +Ehrlichkeits-Regeln der Margen-Schätzung (keine historisierten Buchungen): +- Bewertet wird zu den HEUTIGEN Sätzen aus billing_tariff. +- Die Anlage einer Recherche zählt als 3 Durchläufe (Preismodell), erscheint + im refresh_log aber nur als eine Zeile. Deshalb zählt der erste + abgeschlossene Lauf je Recherche-Lage dreifach. +- analysis/factcheck haben keine Nutzungsdaten und fehlen (Schätzung eher + zu niedrig). Ist-Kosten enthalten auch Fehlläufe. +""" +import logging +import re +import statistics +from collections import defaultdict +from datetime import datetime, timedelta + +from fastapi import APIRouter, Depends, Query + +from auth import get_current_admin +from database import get_db +from routers.pricing import TARIFF_DEFAULTS, USD_PER_CREDIT + +logger = logging.getLogger("verwaltung.statistik") +router = APIRouter(prefix="/api/statistik", tags=["Statistik"]) + +_RETRY_PREFIX = re.compile(r"^Endgültig fehlgeschlagen nach \d+ Versuchen:\s*") + + +# -------------------------------------------------------------------------- +# Pure Funktionen (ohne DB, unit-testbar) +# -------------------------------------------------------------------------- + +def baue_monatsachse(von_ym: str, bis_ym: str) -> list[str]: + """Lückenlose Liste 'YYYY-MM' von von_ym bis bis_ym (einschließlich).""" + if not von_ym or not bis_ym or von_ym > bis_ym: + return [] + y, m = int(von_ym[:4]), int(von_ym[5:7]) + ey, em = int(bis_ym[:4]), int(bis_ym[5:7]) + achse = [] + while (y, m) <= (ey, em): + achse.append(f"{y:04d}-{m:02d}") + m += 1 + if m == 13: + y, m = y + 1, 1 + return achse + + +def normalisiere_fehler(msg: str | None) -> str: + """Fehlermeldungen für das Ranking zusammenfassen.""" + if not msg: + return "Unbekannt" + msg = _RETRY_PREFIX.sub("", msg).strip() + if msg.startswith("Verwaist"): + return "Verwaist (Neustart, automatisch bereinigt)" + return msg[:120] + + +def aggregiere_refresh(rows: list[dict], achse: list[str]) -> dict: + """Aggregiert refresh_log-Detailzeilen zu KPI, Monat×Typ, Betrieb, Fehlern. + + Erwartete Row-Schlüssel: monat, typ, status, trigger_type, total_cost_usd, + input_tokens, output_tokens, articles_found, error_message, dauer_s. + Mediane und Durchschnittskosten nur über Läufe mit Kosten > 0 (die + Kostenerfassung begann erst später, Nullen würden verzerren). + """ + typen = ("adhoc", "research") + mt = {(m, t): {"laeufe": 0, "kosten": 0.0, "tokens_in": 0, "tokens_out": 0, + "artikel": 0, "_kosten_liste": [], "_dauern": []} + for m in achse for t in typen} + betrieb = {m: {"completed": 0, "error": 0, "cancelled": 0, "auto": 0, "manual": 0} + for m in achse} + fehler: dict[str, int] = defaultdict(int) + dauer_typ: dict[str, list] = {t: [] for t in typen} + kpi = {"kosten_usd": 0.0, "laeufe_ok": 0, "laeufe_gesamt": 0, + "artikel": 0, "tokens_in": 0, "tokens_out": 0, "_kosten_liste": []} + + for r in rows: + monat = r.get("monat") + typ = r.get("typ") if r.get("typ") in typen else "adhoc" + status = r.get("status") + kosten = r.get("total_cost_usd") or 0.0 + + kpi["laeufe_gesamt"] += 1 + kpi["kosten_usd"] += kosten + kpi["tokens_in"] += r.get("input_tokens") or 0 + kpi["tokens_out"] += r.get("output_tokens") or 0 + + if monat in betrieb: + b = betrieb[monat] + if status in ("completed", "error", "cancelled"): + b[status] += 1 + if status == "completed": + if r.get("trigger_type") == "auto": + b["auto"] += 1 + else: + b["manual"] += 1 + + if status == "error": + fehler[normalisiere_fehler(r.get("error_message"))] += 1 + + if status != "completed": + continue + + kpi["laeufe_ok"] += 1 + kpi["artikel"] += r.get("articles_found") or 0 + if kosten > 0: + kpi["_kosten_liste"].append(kosten) + + dauer = r.get("dauer_s") + if dauer is not None and 0 < dauer < 86400: + dauer_typ[typ].append(dauer) + + key = (monat, typ) + if key in mt: + z = mt[key] + z["laeufe"] += 1 + z["kosten"] += kosten + z["tokens_in"] += r.get("input_tokens") or 0 + z["tokens_out"] += r.get("output_tokens") or 0 + z["artikel"] += r.get("articles_found") or 0 + if kosten > 0: + z["_kosten_liste"].append(kosten) + if dauer is not None and 0 < dauer < 86400: + z["_dauern"].append(dauer) + + monat_typ = [] + for m in achse: + for t in typen: + z = mt[(m, t)] + monat_typ.append({ + "monat": m, "typ": t, + "laeufe": z["laeufe"], + "kosten": round(z["kosten"], 2), + "kosten_median": round(statistics.median(z["_kosten_liste"]), 2) if z["_kosten_liste"] else None, + "tokens_in": z["tokens_in"], "tokens_out": z["tokens_out"], + "artikel": z["artikel"], + "dauer_avg_s": round(sum(z["_dauern"]) / len(z["_dauern"])) if z["_dauern"] else None, + }) + + gesamt = kpi["laeufe_gesamt"] + ergebnis_kpi = { + "kosten_usd": round(kpi["kosten_usd"], 2), + "laeufe_ok": kpi["laeufe_ok"], + "laeufe_gesamt": gesamt, + "erfolgsquote": round(kpi["laeufe_ok"] / gesamt * 100, 1) if gesamt else None, + "median_kosten_lauf": round(statistics.median(kpi["_kosten_liste"]), 2) if kpi["_kosten_liste"] else None, + "artikel": kpi["artikel"], + "tokens_in": kpi["tokens_in"], + "tokens_out": kpi["tokens_out"], + } + fehler_top = [{"grund": g, "n": n} + for g, n in sorted(fehler.items(), key=lambda x: -x[1])[:5]] + dauer_je_typ = {t: (round(sum(v) / len(v)) if v else None) for t, v in dauer_typ.items()} + + return { + "kpi": ergebnis_kpi, + "monat_typ": monat_typ, + "monat_betrieb": [{"monat": m, **betrieb[m]} for m in achse], + "fehler_top": fehler_top, + "dauer_typ": dauer_je_typ, + } + + +def berechne_credits(adhoc_ok: int, research_ok: int, research_erstlaeufe: int, + neben_aufrufe: dict, tarif: dict) -> float: + """Geschätzter Credits-Gegenwert zu heutigen Sätzen. + + Die Anlage einer Recherche (= erster abgeschlossener Lauf) zählt dreifach, + deshalb +2 zusätzliche Durchläufe je Erstlauf. neben_aufrufe ist + {quelle: api_calls} für chat/enhance/globe. + """ + credits = adhoc_ok * tarif.get("monitor_adhoc", 0) + credits += (research_ok + 2 * research_erstlaeufe) * tarif.get("monitor_research", 0) + for quelle, aufrufe in (neben_aufrufe or {}).items(): + credits += (aufrufe or 0) * tarif.get(quelle, 0) + return round(credits, 2) + + +def aggregiere_orgs(rows_orgs: list[dict], rows_neben: list[dict], + org_namen: dict, tarif: dict, usd_per_credit: float) -> list[dict]: + """Baut die Organisations-Tabelle mit Ist-Kosten und Margen-Schätzung.""" + neben: dict[int, dict] = defaultdict(lambda: {"aufrufe": {}, "kosten": 0.0}) + for r in rows_neben: + org = neben[r["organization_id"]] + org["aufrufe"][r["source"]] = (org["aufrufe"].get(r["source"], 0) or 0) + (r["api_calls"] or 0) + org["kosten"] += r["kosten"] or 0.0 + + ergebnis = [] + alle_ids = set(org_namen) | {r["tenant_id"] for r in rows_orgs} | set(neben) + orgs_map = {r["tenant_id"]: r for r in rows_orgs} + for org_id in sorted(alle_ids): + o = orgs_map.get(org_id, {}) + n = neben.get(org_id, {"aufrufe": {}, "kosten": 0.0}) + ist_kosten = (o.get("ist_kosten") or 0.0) + n["kosten"] + credits = berechne_credits( + o.get("adhoc_ok") or 0, o.get("research_ok") or 0, + o.get("research_erstlaeufe") or 0, n["aufrufe"], tarif, + ) + gegenwert = round(credits * usd_per_credit, 2) + ergebnis.append({ + "org_id": org_id, + "name": org_namen.get(org_id, f"Organisation {org_id}"), + "kosten_usd": round(ist_kosten, 2), + "laeufe_ok": o.get("laeufe_ok") or 0, + "laeufe_gesamt": o.get("laeufe_gesamt") or 0, + "credits_geschaetzt": credits, + "gegenwert_usd": gegenwert, + "marge_usd": round(gegenwert - ist_kosten, 2), + }) + ergebnis.sort(key=lambda x: -x["kosten_usd"]) + return ergebnis + + +def fuelle_monatsserie(rows: list[dict], achse: list[str], schluessel: str, + felder: list[str]) -> list[dict]: + """Füllt eine Monatsserie lückenlos mit Nullwerten auf.""" + vorhanden = {r["monat"]: r for r in rows} + out = [] + for m in achse: + r = vorhanden.get(m, {}) + out.append({"monat": m, **{f: (r.get(f) or 0) for f in felder}}) + return out + + +# -------------------------------------------------------------------------- +# Endpoint +# -------------------------------------------------------------------------- + +@router.get("") +async def statistik(months: int = Query(12, ge=0, le=120), + admin=Depends(get_current_admin)): + """Komplette Betriebsstatistik in einem Aufruf. months=0 heißt alles.""" + heute = datetime.now() + heute_ym = heute.strftime("%Y-%m") + start = None + start_ym = None + if months > 0: + jahr, monat = heute.year, heute.month - (months - 1) + while monat <= 0: + monat += 12 + jahr -= 1 + start_ym = f"{jahr:04d}-{monat:02d}" + start = start_ym + "-01" + + db = await get_db() + try: + # Q1: Detailzeilen der Monitor-Läufe + q1 = ("SELECT strftime('%Y-%m', r.started_at) AS monat, i.type AS typ, " + "r.status, r.trigger_type, r.total_cost_usd, r.input_tokens, " + "r.output_tokens, r.articles_found, r.error_message, " + "CASE WHEN r.completed_at IS NOT NULL THEN " + "(julianday(r.completed_at) - julianday(r.started_at)) * 86400.0 END AS dauer_s " + "FROM refresh_log r JOIN incidents i ON i.id = r.incident_id") + p1: tuple = () + if start: + q1 += " WHERE r.started_at >= ?" + p1 = (start,) + rows_refresh = [dict(r) for r in await (await db.execute(q1, p1)).fetchall()] + + # Q2: Monatsserie je Quelle + q2 = ("SELECT year_month AS monat, source AS quelle, " + "SUM(total_cost_usd) AS kosten, SUM(api_calls) AS aufrufe " + "FROM token_usage_monthly") + p2: tuple = () + if start_ym: + q2 += " WHERE year_month >= ?" + p2 = (start_ym,) + q2 += " GROUP BY year_month, source" + rows_quelle = [dict(r) for r in await (await db.execute(q2, p2)).fetchall()] + + # Q3: je Organisation (Erstlauf-Erkennung über ALLE Zeiten, Fenster außen) + q3 = ("WITH research_first AS (" + " SELECT r.id, ROW_NUMBER() OVER (PARTITION BY r.incident_id ORDER BY r.started_at) AS rn" + " FROM refresh_log r JOIN incidents i ON i.id = r.incident_id" + " WHERE r.status = 'completed' AND i.type = 'research') " + "SELECT r.tenant_id, SUM(r.total_cost_usd) AS ist_kosten, " + "SUM(CASE WHEN r.status='completed' THEN 1 ELSE 0 END) AS laeufe_ok, " + "COUNT(*) AS laeufe_gesamt, " + "SUM(CASE WHEN r.status='completed' AND i.type='adhoc' THEN 1 ELSE 0 END) AS adhoc_ok, " + "SUM(CASE WHEN r.status='completed' AND i.type='research' THEN 1 ELSE 0 END) AS research_ok, " + "SUM(CASE WHEN rf.rn = 1 THEN 1 ELSE 0 END) AS research_erstlaeufe " + "FROM refresh_log r " + "JOIN incidents i ON i.id = r.incident_id " + "LEFT JOIN research_first rf ON rf.id = r.id") + p3: tuple = () + if start: + q3 += " WHERE r.started_at >= ?" + p3 = (start,) + q3 += " GROUP BY r.tenant_id" + rows_orgs = [dict(r) for r in await (await db.execute(q3, p3)).fetchall()] + + # Q4: Nicht-Monitor-Verbrauch je Organisation + q4 = ("SELECT organization_id, source, SUM(api_calls) AS api_calls, " + "SUM(total_cost_usd) AS kosten FROM token_usage_monthly " + "WHERE source != 'monitor'") + p4: tuple = () + if start_ym: + q4 += " AND year_month >= ?" + p4 = (start_ym,) + q4 += " GROUP BY organization_id, source" + rows_neben = [dict(r) for r in await (await db.execute(q4, p4)).fetchall()] + + # Q5: Wachstum (drei kleine Abfragen) + wachstum_rows: dict[str, list[dict]] = {} + for art, tabelle in (("orgs", "organizations"), ("users", "users"), ("lagen", "incidents")): + q5 = f"SELECT strftime('%Y-%m', created_at) AS monat, COUNT(*) AS n FROM {tabelle}" + p5: tuple = () + if start: + q5 += " WHERE created_at >= ?" + p5 = (start,) + q5 += " GROUP BY 1" + wachstum_rows[art] = [dict(r) for r in await (await db.execute(q5, p5)).fetchall()] + + # Q6: Meta, Tarif, Org-Namen + letzter_lauf = (await (await db.execute("SELECT MAX(started_at) FROM refresh_log")).fetchone())[0] + letzter_tum = (await (await db.execute("SELECT MAX(year_month) FROM token_usage_monthly")).fetchone())[0] + tarif = dict(TARIFF_DEFAULTS) + tarif_quelle = "defaults" + try: + for r in await (await db.execute("SELECT tariff_key, credits FROM billing_tariff")).fetchall(): + tarif[r["tariff_key"]] = r["credits"] + tarif_quelle = "billing_tariff" + except Exception: + pass + org_namen = {r["id"]: r["name"] for r in + await (await db.execute("SELECT id, name FROM organizations")).fetchall()} + + # Q7: Aktivität (Tabelle kann auf alten Ständen fehlen) + mau_rows: list[dict] = [] + dau_rows: list[dict] = [] + erfassung_seit = None + try: + qm = "SELECT strftime('%Y-%m', day) AS monat, COUNT(DISTINCT user_id) AS nutzer FROM user_activity_days" + pm: tuple = () + if start: + qm += " WHERE day >= ?" + pm = (start[:10],) + qm += " GROUP BY 1" + mau_rows = [dict(r) for r in await (await db.execute(qm, pm)).fetchall()] + dau_start = (heute - timedelta(days=30)).strftime("%Y-%m-%d") + dau_rows = [dict(r) for r in await (await db.execute( + "SELECT day AS tag, COUNT(DISTINCT user_id) AS nutzer FROM user_activity_days " + "WHERE day >= ? GROUP BY day ORDER BY day", (dau_start,))).fetchall()] + erfassung_seit = (await (await db.execute("SELECT MIN(day) FROM user_activity_days")).fetchone())[0] + except Exception: + logger.warning("user_activity_days fehlt noch, MAU/DAU bleiben leer") + + # Momentaufnahme aus last_login_at (verdaut beide Zeitstempelformate) + nutzer_30d = (await (await db.execute( + "SELECT COUNT(*) FROM users WHERE is_active = 1 AND last_login_at IS NOT NULL " + "AND datetime(last_login_at) >= datetime('now', '-30 days')")).fetchone())[0] + finally: + await db.close() + + # Monatsachse: vom Fensterstart (oder ältestem Datenmonat) bis heute + daten_monate = ([r["monat"] for r in rows_refresh if r.get("monat")] + + [r["monat"] for r in rows_quelle] + + [r["monat"] for art in wachstum_rows.values() for r in art if r.get("monat")]) + von_ym = start_ym or (min(daten_monate) if daten_monate else heute_ym) + bis_ym = max([heute_ym] + daten_monate) + achse = baue_monatsachse(von_ym, bis_ym) + + agg = aggregiere_refresh(rows_refresh, achse) + + # Monatsserie je Quelle in stabile Form bringen + quellen = sorted({r["quelle"] for r in rows_quelle}) or [] + monat_quelle = [] + q_map = {(r["monat"], r["quelle"]): r for r in rows_quelle} + for m in achse: + for q in quellen: + r = q_map.get((m, q), {}) + monat_quelle.append({"monat": m, "quelle": q, + "kosten": round(r.get("kosten") or 0.0, 2), + "aufrufe": r.get("aufrufe") or 0}) + + wachstum = [] + w_maps = {art: {r["monat"]: r["n"] for r in rows} for art, rows in wachstum_rows.items()} + for m in achse: + wachstum.append({"monat": m, + "orgs": w_maps["orgs"].get(m, 0), + "users": w_maps["users"].get(m, 0), + "lagen": w_maps["lagen"].get(m, 0)}) + + mau_map = {r["monat"]: r["nutzer"] for r in mau_rows} + aktivitaet = { + "mau": [{"monat": m, "nutzer": mau_map.get(m, 0)} for m in achse], + "dau": dau_rows, + "erfassung_seit": erfassung_seit, + "nutzer_30d": nutzer_30d, + } + + return { + "window": {"months": months, "monate": achse}, + "datenstand": {"letzter_lauf": letzter_lauf, "letzter_tum_monat": letzter_tum, + "kosten_ab": "2026-03", + "erzeugt": heute.strftime("%Y-%m-%d %H:%M")}, + "tarif": {**tarif, "usd_per_credit": USD_PER_CREDIT, "quelle": tarif_quelle}, + "kpi": agg["kpi"], + "monat_typ": agg["monat_typ"], + "monat_quelle": monat_quelle, + "monat_betrieb": agg["monat_betrieb"], + "fehler_top": agg["fehler_top"], + "dauer_typ": agg["dauer_typ"], + "orgs": aggregiere_orgs(rows_orgs, rows_neben, org_namen, tarif, USD_PER_CREDIT), + "wachstum": wachstum, + "aktivitaet": aktivitaet, + } diff --git a/src/routers/token_usage.py b/src/routers/token_usage.py index af02c81..ea2aeeb 100644 --- a/src/routers/token_usage.py +++ b/src/routers/token_usage.py @@ -20,6 +20,7 @@ async def get_usage_overview(admin=Depends(get_current_admin)): o.id, o.name, o.slug, l.credits_total, l.credits_used, l.cost_per_credit, l.token_budget_usd, l.budget_warning_percent, l.unlimited_budget, + l.credits_period, l.credits_period_start, COALESCE(SUM(r.total_cost_usd), 0) as total_cost, COALESCE(SUM(r.input_tokens), 0) as total_input_tokens, COALESCE(SUM(r.output_tokens), 0) as total_output_tokens, @@ -37,8 +38,12 @@ async def get_usage_overview(admin=Depends(get_current_admin)): credits_total = row["credits_total"] or 0 credits_used = row["credits_used"] or 0 unlimited = bool(row["unlimited_budget"]) - credits_remaining = None if unlimited else (max(0, int(credits_total - credits_used)) if credits_total else None) - percent_used = None if unlimited else (round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None) + # Verfuegbar = Kontingent, dieselbe Bezugsgroesse wie der + # Hard-Stop im Monitor. Ungenutzte Credits verfallen zum + # Periodenende (kein Uebertrag, Produktentscheidung 07/2026). + credits_available = credits_total + credits_remaining = None if unlimited else (max(0, int(credits_available - credits_used)) if credits_available else None) + percent_used = None if unlimited else (round((credits_used / credits_available) * 100, 1) if credits_available > 0 else None) budget_usd = row["token_budget_usd"] cost = row["total_cost"] budget_percent = None if unlimited else (round((cost / budget_usd) * 100, 1) if budget_usd and budget_usd > 0 else None) @@ -51,6 +56,9 @@ async def get_usage_overview(admin=Depends(get_current_admin)): "credits_used": round(credits_used, 1), "credits_remaining": credits_remaining, "credits_percent_used": percent_used, + "credits_available": credits_available, + "credits_period": row["credits_period"] or "monthly", + "credits_period_start": row["credits_period_start"], "token_budget_usd": budget_usd, "total_cost_usd": round(cost, 2), "budget_percent_used": budget_percent, @@ -122,13 +130,18 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)): } cursor = await db.execute( - "SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent, unlimited_budget FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1", + "SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent, unlimited_budget, " + "credits_period, credits_period_start " + "FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1", (org_id,)) lic = await cursor.fetchone() unlimited = bool(lic["unlimited_budget"]) if lic else False credits_total = lic["credits_total"] if lic else None credits_used = lic["credits_used"] if lic else 0 + # Verfuegbar = Kontingent, dieselbe Bezugsgroesse wie der Hard-Stop + # im Monitor (kein Uebertrag, Produktentscheidung 07/2026). + credits_available = credits_total or 0 return { "year_month": year_month, @@ -144,8 +157,11 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)): "unlimited_budget": unlimited, "credits_total": credits_total, "credits_used": round(credits_used, 1) if credits_used else 0, - "credits_remaining": None if unlimited else (max(0, int(credits_total - credits_used)) if credits_total else None), - "credits_percent_used": None if unlimited else (round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None), + "credits_remaining": None if unlimited else (max(0, int(credits_available - credits_used)) if credits_available else None), + "credits_percent_used": None if unlimited else (round((credits_used / credits_available) * 100, 1) if credits_available > 0 else None), + "credits_available": credits_available, + "credits_period": (lic["credits_period"] or "monthly") if lic else "monthly", + "credits_period_start": lic["credits_period_start"] if lic else None, "token_budget_usd": lic["token_budget_usd"] if lic else None, "cost_per_credit": lic["cost_per_credit"] if lic else None, "budget_warning_percent": lic["budget_warning_percent"] if lic else 80, @@ -164,9 +180,31 @@ async def update_budget(license_id: int, data: dict, request: Request, admin=Dep if not before: raise HTTPException(status_code=404, detail="Lizenz nicht gefunden") + # Vorab-Validierung, der Endpoint nimmt ein rohes dict entgegen. + # Ein Tippfehler wie 'weekly' wuerde sonst still in der DB landen und + # der Monitor behandelte die Lizenz wie 'total' (kein Monatsreset). + if "credits_period" in data and data["credits_period"] not in ("monthly", "total"): + raise HTTPException(status_code=400, detail="credits_period muss 'monthly' oder 'total' sein") + if "budget_warning_percent" in data: + try: + pct = int(data["budget_warning_percent"]) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="budget_warning_percent muss eine Zahl sein") + if not 0 <= pct <= 100: + raise HTTPException(status_code=400, detail="budget_warning_percent muss zwischen 0 und 100 liegen (0 = Warnung aus)") + data["budget_warning_percent"] = pct + if "max_users" in data: + try: + mu = int(data["max_users"]) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="max_users muss eine Zahl sein") + if not 1 <= mu <= 1000: + raise HTTPException(status_code=400, detail="max_users muss zwischen 1 und 1000 liegen") + data["max_users"] = mu + fields = [] values = [] - for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent"): + for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent", "credits_period", "max_users"): if key in data: fields.append(f"{key} = ?") values.append(data[key]) @@ -179,6 +217,9 @@ async def update_budget(license_id: int, data: dict, request: Request, admin=Dep fields.append("unlimited_budget = ?") values.append(1 if data["unlimited_budget"] else 0) + # Bewusst NICHT aenderbar: credits_period_start und budget_warning_sent + # sind Laufzeitfelder, die verwaltet der Monitor. + if not fields: raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") diff --git a/src/routers/x_scraper.py b/src/routers/x_scraper.py index a38887a..c0495ec 100644 --- a/src/routers/x_scraper.py +++ b/src/routers/x_scraper.py @@ -76,6 +76,38 @@ class XScraperActive(BaseModel): active: bool +@router.get("/telegram-status") +async def telegram_status( + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Status der Telegram-Session, gemeldet vom Monitor (system_status). + + Der Monitor prüft beim Start und täglich um 04:30 und schreibt das + Ergebnis in die geteilte DB. Das Portal liest nur an. Fehlt der + Eintrag (z.B. Portal-Staging mit eigener DB-Kopie), meldet der + Endpoint available=false statt eines Fehlers. + """ + import json as _json + + cur = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='system_status'" + ) + if not await cur.fetchone(): + return {"available": False} + cur = await db.execute( + "SELECT value, updated_at FROM system_status WHERE key = 'telegram_session'" + ) + row = await cur.fetchone() + if not row: + return {"available": False} + try: + data = _json.loads(row["value"]) if row["value"] else {} + except Exception: + data = {} + return {"available": True, "updated_at": row["updated_at"], **data} + + @router.get("/accounts") async def list_accounts(admin: dict = Depends(get_current_admin)): """Alle X-Scraper-Konten auflisten (ohne Passwoerter/Cookies).""" diff --git a/src/shared/services/source_suggester.py b/src/shared/services/source_suggester.py index ca45f66..974228f 100644 --- a/src/shared/services/source_suggester.py +++ b/src/shared/services/source_suggester.py @@ -5,7 +5,7 @@ import re import aiosqlite -from agents.claude_client import call_claude +from shared.agents.claude_client import call_claude from config import CLAUDE_MODEL_FAST logger = logging.getLogger("osint.source_suggester") diff --git a/src/source_meta.py b/src/source_meta.py index 9f6faa8..c3fccae 100644 --- a/src/source_meta.py +++ b/src/source_meta.py @@ -27,17 +27,9 @@ SOURCE_CATEGORIES: list[CategoryEntry] = [ {"key": "international", "label": "International"}, {"key": "regional", "label": "Regional"}, {"key": "boulevard", "label": "Boulevard"}, - {"key": "stimmungsbild", "label": "Forum / Stimmungsbild"}, + {"key": "telegram", "label": "Telegram"}, + {"key": "x", "label": "X (Twitter)"}, {"key": "sonstige", "label": "Sonstige"}, - {"key": "cybercrime", "label": "Cybercrime / Hacktivismus"}, - {"key": "cybercrime-leaks", "label": "Cybercrime / Leaks"}, - {"key": "ukraine-russland-krieg", "label": "Ukraine-Russland-Krieg"}, - {"key": "irankonflikt", "label": "Irankonflikt"}, - {"key": "osint-international", "label": "OSINT International"}, - {"key": "extremismus-deutschland", "label": "Extremismus Deutschland"}, - {"key": "russische-staatspropaganda", "label": "Russische Staatspropaganda"}, - {"key": "russische-opposition", "label": "Russische Opposition / Exilmedien"}, - {"key": "syrien-nahost", "label": "Syrien / Nahost"}, ] @@ -46,7 +38,8 @@ SOURCE_TYPES: list[TypeEntry] = [ {"key": "web_source", "label": "Webquelle"}, {"key": "telegram_channel", "label": "Telegram-Kanal"}, {"key": "podcast_feed", "label": "Podcast-Feed"}, - {"key": "excluded", "label": "Ausgeschlossen"}, + {"key": "x_account", "label": "X-Konto"}, + {"key": "pdf_document", "label": "PDF-Dokument"}, ] diff --git a/src/static/css/style.css b/src/static/css/style.css index ecf1ca6..11d097e 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -1,32 +1,63 @@ -/* AegisSight Dark Theme - Verwaltungsportal */ +/* AegisSight Design System - Verwaltungsportal (Navy/Gold, Dark = Standard, Light per Umschalter) */ :root { - --bg-primary: #0f172a; - --bg-secondary: #1e293b; - --bg-tertiary: #334155; - --text-primary: #e2e8f0; - --text-secondary: #94a3b8; - --text-muted: #64748b; - --accent: #f0b429; - --accent-hover: #d4a017; - --success: #22c55e; - --warning: #f59e0b; - --danger: #ef4444; - --info: #3b82f6; - --border: #334155; - --radius: 8px; - --radius-lg: 12px; + --bg-primary: #0B1121; + --bg-secondary: #151D2E; + --bg-tertiary: #1A2440; + --bg-hover: #1A2440; + --text-primary: #E8ECF4; + --text-secondary: #8896AB; + --text-muted: #95A3B8; + --accent: #C8A851; + --accent-hover: #B5923E; + --success: #10B981; + --warning: #F59E0B; + --danger: #EF4444; + --info: #7C8DB5; + --border: #1E2D45; + --radius: 6px; + --radius-lg: 8px; + --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.5); + --font-title: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; + /* Das Navy-Logo liegt im Dark-Theme auf einer hellen Plakette */ + --logo-plaque: rgba(232, 236, 240, 0.9); +} + +/* Light-Theme. Akzent wie im Monitor auf das dunklere Gold abgesenkt, + #C8A851 schafft auf hellem Grund den WCAG-Kontrast nicht. */ +[data-theme="light"] { + --bg-primary: #F4F5F7; + --bg-secondary: #FFFFFF; + --bg-tertiary: #E8EBF0; + --bg-hover: #E8EBF0; + --text-primary: #1A202C; + --text-secondary: #4A5568; + --text-muted: #A0AEC0; + --accent: #96791A; + --accent-hover: #7D6516; + --success: #059669; + --warning: #D97706; + --danger: #DC2626; + --info: #4A5568; + --border: #E2E8F0; + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12); + --logo-plaque: transparent; } * { margin: 0; padding: 0; box-sizing: border-box; } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, sans-serif; + font-family: var(--font-body); background: var(--bg-primary); color: var(--text-primary); line-height: 1.6; min-height: 100vh; } +h1, h2, h3, .logo, .stat-value, .token-stat-value { + font-family: var(--font-title); +} + /* --- Login Page --- */ .login-page { display: flex; @@ -143,7 +174,7 @@ input:focus, select:focus, textarea:focus { color: var(--text-primary); } .btn-secondary:hover:not(:disabled) { - background: #475569; + background: var(--border); } .btn-danger { @@ -189,6 +220,9 @@ input:focus, select:focus, textarea:focus { } .app-header .logo { + display: flex; + align-items: center; + gap: 10px; color: var(--accent); font-size: 18px; font-weight: 700; @@ -198,9 +232,19 @@ input:focus, select:focus, textarea:focus { color: var(--text-secondary); font-weight: 400; font-size: 13px; - margin-left: 8px; + margin-left: 2px; } +/* Navy-Logo bleibt auch im Dark-Theme, liegt dort auf heller Plakette */ +.logo-plaque { + display: flex; + align-items: center; + background: var(--logo-plaque); + border-radius: 8px; + padding: 3px 6px; +} +.logo-plaque img { height: 24px; display: block; } + .header-right { display: flex; align-items: center; @@ -213,7 +257,8 @@ input:focus, select:focus, textarea:focus { } .app-content { - max-width: 1200px; + /* Breite Bildschirme ausnutzen. Ein groesseres Fenster zeigt mehr Tabelle. */ + max-width: 1720px; margin: 0 auto; padding: 24px; } @@ -237,7 +282,11 @@ input:focus, select:focus, textarea:focus { border-bottom: 2px solid transparent; margin-bottom: -1px; transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 7px; } +.nav-tab svg { flex-shrink: 0; } .nav-tab:hover { color: var(--text-primary); @@ -538,36 +587,6 @@ tr:hover td { font-weight: 500; } -/* --- Recent activity --- */ -.activity-item { - display: flex; - align-items: center; - gap: 12px; - padding: 8px 0; - font-size: 13px; -} - -.activity-icon { - width: 28px; - height: 28px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - flex-shrink: 0; -} - -.activity-icon.org { - background: rgba(59, 130, 246, 0.2); - color: #93c5fd; -} - -.activity-icon.user { - background: rgba(34, 197, 94, 0.2); - color: #86efac; -} - /* --- Org detail panel --- */ .detail-panel { display: none; @@ -654,21 +673,6 @@ tr:hover td { /* --- Health & Suggestion Badges --- */ -.badge-health-error { - background: rgba(239, 68, 68, 0.2); - color: #fca5a5; -} - -.badge-health-warning { - background: rgba(245, 158, 11, 0.2); - color: #fcd34d; -} - -.badge-health-ok { - background: rgba(34, 197, 94, 0.2); - color: #86efac; -} - .badge-suggestion-add_source { background: rgba(59, 130, 246, 0.2); color: #93c5fd; @@ -872,6 +876,193 @@ input[type="date"].filter-select { padding: 6px 10px; } .health-badge-ok { background: rgba(16, 185, 129, 0.15); color: #10b981; } .health-badge-unknown { background: rgba(148, 163, 184, 0.15); color: #94a3b8; } +/* Klickbares Health-Badge oeffnet die Detailzeile */ +.health-badge-btn { cursor: pointer; } +.health-badge-btn:hover { filter: brightness(1.25); text-decoration: underline; } + +/* "Jetzt pruefen"-Knopf in der Stats-Leiste */ +.health-run-item { display: inline-flex; align-items: center; gap: 8px; } +.health-run-item #healthRunInfo { font-size: 12px; } + +/* Checkbox-Dropdowns (Standard fuer alle Filter im Portal) */ +.filter-bar { + display: flex; + align-items: flex-start; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 14px; +} +.filter-dd { position: relative; } +.filter-dd-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-size: 13px; + cursor: pointer; +} +.filter-dd-btn:hover { background: var(--bg-tertiary); } +.filter-dd-btn.has-selection { border-color: var(--accent); } +.filter-dd-count { + background: var(--accent); + color: #1A1A1A; /* Text auf Gold immer dunkel (ON_ACCENT) */ + border-radius: 999px; + font-size: 11px; + font-weight: 700; + padding: 0 6px; + line-height: 16px; +} +.filter-dd-caret { font-size: 10px; color: var(--text-secondary); } +.filter-dd-panel { + display: none; + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 60; + min-width: 210px; + max-height: 320px; + overflow-y: auto; + padding: 8px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + flex-direction: column; + gap: 2px; +} +.filter-dd.open .filter-dd-panel { display: flex; } +[data-theme="light"] .filter-dd-panel { box-shadow: 0 8px 24px rgba(26, 26, 26, 0.18); } +.filter-check { + display: flex; + align-items: center; + gap: 7px; + font-size: 13px; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + user-select: none; + white-space: nowrap; +} +.filter-check:hover { background: var(--bg-tertiary); } +.filter-check input { + accent-color: var(--accent); + cursor: pointer; + margin: 0; + flex-shrink: 0; +} + +/* Checkbox-Spalte: nur sichtbar bei Herkunfts-Filter "Kundenquellen" */ +.select-col { display: none; } +table.show-select .select-col { display: table-cell; } + +/* === Quellenliste: leichteres Seitwaerts-Scrollen === */ +/* Name links und Aktionen rechts bleiben beim Scrollen angepinnt. + Zeilen sind transparent, angepinnte Zellen brauchen deshalb solide + Hintergruende, sonst scheint der Inhalt darunter durch. */ +#unifiedTable th.sticky-name, +#unifiedTable td.sticky-name { + position: sticky; + left: 0; + z-index: 2; + background: var(--bg-secondary); + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + box-shadow: 6px 0 8px -6px rgba(0, 0, 0, 0.45); +} +#unifiedTable th.sticky-actions, +#unifiedTable td.sticky-actions { + position: sticky; + right: 0; + z-index: 2; + background: var(--bg-secondary); + white-space: nowrap; + box-shadow: -6px 0 8px -6px rgba(0, 0, 0, 0.45); +} +#unifiedTable.show-select th.select-col, +#unifiedTable.show-select td.select-col { + position: sticky; + left: 0; + z-index: 2; + width: 40px; + background: var(--bg-secondary); +} +#unifiedTable.show-select th.sticky-name, +#unifiedTable.show-select td.sticky-name { left: 40px; } +#unifiedTable tr:hover td.sticky-name, +#unifiedTable tr:hover td.sticky-actions, +#unifiedTable.show-select tr:hover td.select-col { + background: var(--bg-tertiary); +} +/* Kategorie-Zeile und Ausklapp-Inhalte bleiben im sichtbaren Bereich */ +.cat-header-sticky { + position: sticky; + left: 12px; + display: inline-flex; + align-items: center; +} +#unifiedTable .src-notes-cell > div { + position: sticky; + left: 16px; + max-width: 1080px; +} +/* Ziehen-zum-Scrollen */ +.drag-scroll { cursor: grab; } +.drag-scroll.drag-scrolling { + cursor: grabbing; + user-select: none; +} + +/* Schwebende Scroll-Leiste: bleibt am unteren Bildschirmrand sichtbar, + solange die Tabelle im Bild ist (Proxy, synchron zum echten Container) */ +.hscroll-proxy { + position: sticky; + bottom: 0; + overflow-x: auto; + overflow-y: hidden; + height: 14px; + z-index: 5; + background: var(--bg-secondary); +} +.hscroll-proxy > div { height: 1px; } +.hscroll-proxy::-webkit-scrollbar, +.table-wrap::-webkit-scrollbar { height: 11px; } +.hscroll-proxy::-webkit-scrollbar-thumb, +.table-wrap::-webkit-scrollbar-thumb { + background: var(--bg-tertiary); + border-radius: 6px; + border: 2px solid var(--bg-secondary); +} +.hscroll-proxy::-webkit-scrollbar-thumb:hover, +.table-wrap::-webkit-scrollbar-thumb:hover { background: var(--accent); } +.hscroll-proxy::-webkit-scrollbar-track, +.table-wrap::-webkit-scrollbar-track { background: var(--bg-secondary); } + +/* Health-Details in der Ausklapp-Zeile */ +.src-notes-text { margin-bottom: 8px; } +.health-detail-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 4px 0 8px; +} +.health-detail-row { + display: flex; + align-items: baseline; + gap: 12px; + padding: 3px 0; + font-size: 13px; +} +.health-detail-type { min-width: 120px; font-weight: 600; } +.health-detail-msg { flex: 1; } +.health-detail-ts { white-space: nowrap; font-size: 12px; } + /* === Audit-Spur (Phase 5) === */ .modal.modal-large { max-width: 720px; @@ -947,20 +1138,6 @@ input[type="date"].filter-select { padding: 6px 10px; } .activity-cell.activity-zero { color: #475569; } -.exclude-badge { - display: inline-block; - padding: 2px 8px; - border-radius: 10px; - font-size: 11px; - font-weight: 600; - background: rgba(239, 68, 68, 0.15); - color: #ef4444; -} -.exclude-badge.exclude-zero { - background: transparent; - color: #475569; - font-weight: 400; -} /* === Klassifikations-Review === */ .sources-tab-badge { @@ -996,13 +1173,6 @@ input[type="date"].filter-select { padding: 6px 10px; } font-size: 13px; color: var(--text-primary); } -.review-conf-filter { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--text-secondary); -} .review-toolbar-actions { display: flex; gap: 6px; } .review-list { display: flex; flex-direction: column; gap: 8px; } @@ -1100,24 +1270,303 @@ input[type="date"].filter-select { padding: 6px 10px; } letter-spacing: 0.3px; text-transform: uppercase; } -.alignment-chips { display: flex; flex-wrap: wrap; gap: 6px; } -.alignment-chip { - display: inline-flex; + + +/* === Theme-Umschalter (goldener Schiebeschalter, wie im Monitor) === */ +.theme-switch { + display: flex; align-items: center; - padding: 4px 10px; - border-radius: 999px; - font-size: 11px; - font-weight: 500; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border); + gap: 6px; cursor: pointer; - transition: all 0.12s ease; + user-select: none; + -webkit-user-select: none; } -.alignment-chip:hover { background: var(--bg-tertiary); color: var(--text-primary); } -.alignment-chip.active { +.theme-switch-icon { + font-size: 14px; + line-height: 1; + opacity: 0.4; + transition: opacity 0.3s; +} +.theme-switch.dark .theme-switch-moon, +.theme-switch.light .theme-switch-sun { + opacity: 1; +} +.theme-switch-track { + position: relative; + width: 40px; + height: 22px; + border-radius: 11px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + transition: background 0.3s, border-color 0.3s; + flex-shrink: 0; +} +.theme-switch-knob { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; background: var(--accent); - color: var(--bg-primary); - border-color: var(--accent); + box-shadow: 0 0 8px rgba(200, 168, 81, 0.3); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s; +} +.theme-switch.dark .theme-switch-knob { transform: translateX(18px); } +.theme-switch.light .theme-switch-knob { transform: translateX(0); } +.theme-switch:hover .theme-switch-track { border-color: var(--accent); } +.theme-switch:hover .theme-switch-knob { box-shadow: 0 0 12px rgba(200, 168, 81, 0.5); } + +/* === Kleine Schalter-Optik (fuer das Barrierefreiheits-Panel) === */ +.toggle-switch { + position: relative; + display: inline-block; + width: 34px; + height: 18px; + border-radius: 9px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + transition: background 0.2s ease; +} +.toggle-switch::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--text-secondary); + transition: transform 0.2s ease, background 0.2s ease; } +/* === Barrierefreiheits-Panel (a11y.js haengt sich in .header-right ein) === */ +.a11y-center { position: relative; } +.a11y-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 6px 8px; + border-radius: var(--radius); + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s ease, background 0.2s ease; + width: 36px; + height: 36px; +} +.a11y-btn:hover { color: var(--accent); background: var(--bg-hover); } +.a11y-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 240px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + z-index: 200; + padding: 16px; +} +.a11y-panel-title { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: 0.5px; + margin-bottom: 12px; +} +.a11y-option { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + cursor: pointer; + font-size: 13px; + color: var(--text-primary); + user-select: none; +} +.a11y-option input[type="checkbox"] { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.a11y-option .toggle-switch { flex-shrink: 0; } +.a11y-option input:focus-visible + .toggle-switch { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.a11y-option input:checked + .toggle-switch { background: var(--accent); } +.a11y-option input:checked + .toggle-switch::after { + transform: translateX(16px); + background: var(--bg-primary); +} + +/* === Barrierefreiheits-Modi === */ +[data-a11y-contrast="true"] { + --text-muted: #B0BDD0; + --border: #3A4A66; +} +[data-a11y-contrast="true"] .btn-primary { color: #1A1A1A; } +[data-a11y-contrast="true"][data-theme="light"] { + --accent: #6B5714; + --accent-hover: #5A4A11; + --text-muted: #718096; + --border: #94A3B8; +} +[data-a11y-focus="true"] a:focus-visible, +[data-a11y-focus="true"] button:focus-visible, +[data-a11y-focus="true"] input:focus-visible, +[data-a11y-focus="true"] select:focus-visible, +[data-a11y-focus="true"] textarea:focus-visible, +[data-a11y-focus="true"] [tabindex]:focus-visible, +[data-a11y-focus="true"] [role="button"]:focus-visible { + outline: 3px solid var(--accent) !important; + outline-offset: 2px !important; + box-shadow: 0 0 0 4px rgba(200, 168, 81, 0.3) !important; +} +[data-a11y-fontsize="true"] body { zoom: 1.15; } +[data-a11y-motion="true"] *, +[data-a11y-motion="true"] *::before, +[data-a11y-motion="true"] *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; +} + +/* === Icon-Ausrichtung in Knoepfen === */ +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; } +.btn svg { flex-shrink: 0; } + +/* === Aufklappbarer Bereich (z.B. "Erweitert" im Verbrauchs-Formular) === */ +details.adv-section { grid-column: 1 / -1; border-top: 1px solid var(--border); padding-top: 10px; } +details.adv-section summary { + cursor: pointer; + color: var(--text-secondary); + font-size: 13px; + user-select: none; + display: flex; + align-items: center; + gap: 6px; +} +details.adv-section summary:hover { color: var(--text-primary); } +details.adv-section[open] summary { margin-bottom: 10px; } +details.adv-section .adv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +details.adv-section .adv-hint { grid-column: 1 / -1; font-size: 12px; color: var(--text-muted); margin: 0; } + +/* === Light-Theme: dunklere Text-Farben fuer Badges/Status === + (Die Dark-Pastelltoene sind auf hellem Grund unlesbar. Automatisch + abgeleitete Gegenstuecke, je Original-Farbton eine dunkle Variante.) */ +[data-theme="light"] .error-msg { color: #B91C1C; } +[data-theme="light"] .btn-danger { color: #B91C1C; } +[data-theme="light"] .btn-success { color: #047857; } +[data-theme="light"] .badge-active { color: #047857; } +[data-theme="light"] .badge-inactive { color: #B91C1C; } +[data-theme="light"] .badge-trial { color: #1D4ED8; } +[data-theme="light"] .badge-annual { color: #7E22CE; } +[data-theme="light"] .badge-permanent { color: #B45309; } +[data-theme="light"] .badge-expired { color: #B91C1C; } +[data-theme="light"] .badge-revoked { color: #475569; } +[data-theme="light"] .badge-none { color: #475569; } +[data-theme="light"] .badge-suggestion-add_source { color: #1D4ED8; } +[data-theme="light"] .badge-suggestion-deactivate_source { color: #B45309; } +[data-theme="light"] .badge-suggestion-remove_source { color: #B91C1C; } +[data-theme="light"] .badge-suggestion-fix_url { color: #7E22CE; } +[data-theme="light"] .badge-priority-high { color: #B91C1C; } +[data-theme="light"] .badge-priority-medium { color: #B45309; } +[data-theme="light"] .badge-priority-low { color: #475569; } +[data-theme="light"] .sources-stat-item { color: #475569; } +[data-theme="light"] .health-badge-unknown { color: #475569; } +[data-theme="light"] .audit-entry-meta { color: #475569; } +[data-theme="light"] .audit-entry-detail summary { color: #475569; } +[data-theme="light"] /* === Verwendungs-Sicht (Phase 6) === */ +.activity-cell { color: #475569; } + +/* === Verlinkte Dashboard-Kacheln und -Karten === */ +.stat-card-link { cursor: pointer; transition: border-color 0.15s ease, transform 0.15s ease; } +.stat-card-link:hover, .stat-card-link:focus-visible { + border-color: var(--accent); + transform: translateY(-1px); +} +.card-link { cursor: pointer; transition: border-color 0.15s ease; } +.card-link:hover, .card-link:focus-visible { border-color: var(--accent); } +.card-link .card-link-chevron { color: var(--text-muted); transition: color 0.15s ease, transform 0.15s ease; } +.card-link:hover .card-link-chevron { color: var(--accent); transform: translateX(2px); } + +/* === Org-Detail als durchgehende Seite (statt Unterreitern) === */ +.detail-panel.active { display: flex; flex-direction: column; } + +.org-block { margin-top: 26px; scroll-margin-top: 120px; } +#sub-tokens { order: 2; } +.org-block-title { + font-size: 16px; + margin-bottom: 12px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border); + color: var(--text-primary); +} + +/* === Kontingent-Spalte in der Org-Tabelle === */ +.org-row { cursor: pointer; } +.mini-bar { + width: 90px; + height: 5px; + border-radius: 3px; + background: var(--bg-tertiary); + overflow: hidden; + margin-top: 3px; +} +.mini-bar-fill { height: 100%; background: var(--success); border-radius: 3px; } +.mini-bar-fill.warning { background: var(--warning); } +.mini-bar-fill.critical { background: var(--danger); } + +/* === Klickbare Eintraege in der Ablauf-Liste === */ +.expiring-item-link { cursor: pointer; border-radius: var(--radius); transition: background 0.15s ease; } +.expiring-item-link:hover, .expiring-item-link:focus-visible { background: var(--bg-hover); } + +/* === Prominenter Org-Status-Umschalter in der Kopfzeile === */ +.org-status-toggle { + display: inline-flex; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.ost-btn { + padding: 8px 18px; + font-size: 14px; + font-weight: 600; + font-family: var(--font-body); + background: var(--bg-secondary); + color: var(--text-muted); + border: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 7px; + transition: background 0.15s ease, color 0.15s ease; +} +.ost-btn + .ost-btn { border-left: 1px solid var(--border); } +.ost-btn:hover { color: var(--text-primary); background: var(--bg-hover); } +.ost-btn.on.ost-aktiv { + background: rgba(16, 185, 129, 0.16); + color: var(--success); +} +.ost-btn.on.ost-inaktiv { + background: rgba(239, 68, 68, 0.16); + color: var(--danger); +} + + +/* === Verbrauchs-Korrektur direkt an der Kachel === */ +.stat-korrektur { display: flex; gap: 6px; margin-top: 10px; } +.stat-korrektur input { font-size: 12px; padding: 6px 8px; } + +/* === Zahlenfelder ohne Hochzaehl-Pfeile (portalweit) === */ +input[type="number"]::-webkit-outer-spin-button, +input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +input[type="number"] { -moz-appearance: textfield; appearance: textfield; } diff --git a/src/static/dashboard.html b/src/static/dashboard.html index b1fe1e8..e43b314 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -6,83 +6,74 @@ AegisSight Monitor-Verwaltung - + +
- +
+
+ ☀︎ +
+
+
+ +
- +
- +
-
-
-
-

Bald ablaufende Lizenzen

-
-
-
    -
  • Laden...
  • -
-
-
- -
-
-

Letzte Aktivität

-
-
-
Laden...
-
-
-
- - -
+
@@ -93,7 +84,7 @@
- +
@@ -101,9 +92,9 @@ Name - Slug Nutzer Lizenz + Kontingent Status Aktionen @@ -119,126 +110,125 @@
- - - -
-
- - -
-
-
- - - - - - - - - - - - -
E-MailRolleStatusGlobeNetzwerkAktionen
-
-
-
- - -
-
- - -
-
-
- - - - - - - - - - - - -
TypMax NutzerGültig abGültig bisStatusAktionen
-
-
-
- - -
-
-
-
-
- - -
-
- - -
-
- - - Bestimmt die Ausgabesprache der KI (Lagebild, Faktencheck, Recherche) und der sichtbarsten UI-Elemente fuer alle Nutzer dieser Organisation. -
-
- - -
-
-
-
- -
-
+ + +
+

Übersicht

+
-
Verbrauchte Credits
+
Verbrauchte Credits (Periode)
-
+
+ + +
Verbleibend
-
-
Budget (USD)
-
-
-
-
-
Kosten (USD)
+
API-Kosten Monat (Einkauf)
-
-
- Budget-Auslastung + Kontingent-Auslastung (laufende Periode) 0%
+
+

+ + Zur Einordnung. Ein Live-Lauf kostet 45 Credits, das Anlegen einer Recherche 120 (drei Durchläufe), jede Vertiefung 40, Chat und Beschreibungs-Assistent je 1 Credit. + Verbrauchsrechner öffnen +

+ + +

Lizenz, Nutzer & Credits-Kontingent bearbeiten

+
+
+ Aktive und frühere Lizenzen dieser Organisation + +
+
+ + + + + + + + + + + + + +
TypMax NutzerGültig abGültig bisStatusPipeline-SpracheAktionen
+
+
+ + +
+
+ + + + + + + + + + + + +
E-MailRolleStatusGlobeNetzwerkAktionen
+
+
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+

Monatlicher Verlauf

@@ -254,128 +244,53 @@
- -

Budget bearbeiten

-
-
-
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- -
-
-
-

Bald ablaufende Lizenzen

- -
-
- - - - - - - - - - - -
OrganisationTypMax NutzerLäuft abAktionen
-
-
-
-
- +
- - - - - +
+
+
-
- +
+
- + + - - - - - - + + + + + - - - - + + + + + @@ -384,107 +299,40 @@ - -
-
-
- - - - - - -
- -
-
-
-
Name Name URLDomain Typ Artikel Aktivität Sperren Sprache Domain Typ Artikel Aktivität Sprache BiasLetzter Treffer Health Status AktionenLetzter Treffer Health Status Organisation Aktionen
- - - - - - - - - - - - - - - -
Name Domain Typ Kategorie Organisation Sprache BiasHinzugefügt vonAktionen
-
-
-
- - -
- -
- - -
- - -
+ +
- 0 Vorschläge ausstehend - + 0 Vorschläge offen + 0 Klassifikationen ausstehend
- +
-
+
+
+
+

Klassifikations-Review

Lade Review-Queue…
+
- +
-
-
- +
+
+

X-Zugang

+
+ + +
-
- - -
-
-
-

X-Login-Konten, mit denen der Monitor bei X recherchiert. Mehr Konten bedeuten paralleleres, schnelleres Scrapen. Cookies laufen periodisch ab und müssen dann erneuert werden.

@@ -501,6 +349,26 @@
+
+
+

Telegram-Zugang

+
+
+ + + + + + + + + + + + +
BenutzernameTelefonStatusAnfragenLetzte PrüfungAktionen
Lade…
+
+
@@ -509,16 +377,7 @@
- - - @@ -529,6 +388,7 @@
+
@@ -562,10 +422,7 @@ -
- - -
+
Steuert die Ausgabesprache der KI-Pipeline (Lagebild, Faktencheck, Recherche) und die sichtbarsten UI-Strings im Monitor.
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
- +
- - + +
- - + +
@@ -670,6 +577,53 @@ + + + + + + @@ -884,13 +794,7 @@
- +
@@ -965,7 +869,7 @@
@@ -1038,11 +942,12 @@ - - - - - + + + + + +
diff --git a/src/static/index.html b/src/static/index.html index 97272b2..37a334f 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -7,12 +7,14 @@ AegisSight Monitor-Verwaltung - Anmeldung - + + @@ -54,13 +56,13 @@