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

This commit was merged in pull request #16.
Dieser Commit ist enthalten in:
2026-07-26 00:22:09 +02:00
Commit 2afb2da489
37 geänderte Dateien mit 4709 neuen und 2358 gelöschten Zeilen

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

@@ -0,0 +1,46 @@
"""Migration 2026-07-25: Tabelle user_activity_days anlegen (MAU/DAU).
Der Monitor legt die Tabelle beim Start selbst an und schreibt je Nutzer und
Kalendertag einen Eintrag (auth.get_current_user). Die Portal-Staging-Umgebung
nutzt eine EIGENE Datenbank ohne laufenden Monitor, deshalb dieses Skript.
Idempotent (CREATE TABLE IF NOT EXISTS), es werden keine Daten eingefügt.
Ausführung:
DB_PATH=/home/claude-dev/AegisSight-Monitor-staging/data/osint.db python3 migrations/2026-07-25_user_activity.py
DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-07-25_user_activity.py
"""
import os
import sqlite3
import sys
def main(db_path: str) -> int:
if not os.path.exists(db_path):
print(f"FEHLER: DB nicht gefunden: {db_path}", file=sys.stderr)
return 1
conn = sqlite3.connect(db_path, timeout=60)
conn.execute("PRAGMA busy_timeout = 60000")
conn.execute("PRAGMA journal_mode = WAL")
print(f"Migration auf {db_path}")
conn.execute("""
CREATE TABLE IF NOT EXISTS user_activity_days (
user_id INTEGER NOT NULL,
day TEXT NOT NULL,
tenant_id INTEGER,
PRIMARY KEY (user_id, day)
)
""")
print(" + user_activity_days angelegt (oder vorhanden)")
conn.commit()
conn.close()
print("Migration abgeschlossen.")
return 0
if __name__ == "__main__":
db_path = os.environ.get("DB_PATH", "/home/claude-dev/osint-data/osint.db")
sys.exit(main(db_path))

47
src/health_run.py Normale Datei
Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

@@ -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 <input type=date>) 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)

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

@@ -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 "<rss" not in text and "<feed" not in text and "<channel" not in text:
checks.append({"type": "feed_validity", "status": "error",
"message": "Kein RSS/Atom-Feed"})
else:
feed = await asyncio.to_thread(feedparser.parse, text)
if feed.get("bozo") and not feed.entries:
checks.append({"type": "feed_validity", "status": "error",
"message": "Feed fehlerhaft"})
elif not feed.entries:
checks.append({"type": "feed_validity", "status": "warning",
"message": "Feed leer"})
else:
checks.append({"type": "feed_validity", "status": "ok",
"message": f"Feed OK ({len(feed.entries)} Eintr.)"})
except httpx.TimeoutException:
checks.append({"type": "reachability", "status": "error", "message": "Timeout (15s)"})
except httpx.ConnectError:
checks.append({"type": "reachability", "status": "error", "message": "Verbindung fehlgeschlagen"})
except Exception as e:
checks.append({"type": "reachability", "status": "error", "message": f"{type(e).__name__}"})
for c in checks:
await db.execute(
"INSERT INTO source_health_checks (source_id, check_type, status, message) VALUES (?, ?, ?, ?)",
(source["id"], c["type"], c["status"], c["message"]))
if c["status"] != "ok":
issues_found += 1
except Exception:
pass
checked += 1
status_icon = "ok"
if any(c["status"] == "error" for c in checks):
status_icon = "error"
elif any(c["status"] == "warning" for c in checks):
status_icon = "warning"
yield f"data: {_json.dumps({'phase': 'check', 'checked': checked, 'total': total, 'current': source['name'], 'status': status_icon})}\n\n"
# Stale + Duplikate (schnell, kein Fortschritt noetig)
for source in sources:
if source["source_type"] in ("excluded", "web_source"):
continue
article_count = source.get("article_count") or 0
if article_count == 0:
await db.execute(
"INSERT INTO source_health_checks (source_id, check_type, status, message) VALUES (?, 'stale', 'warning', 'Noch nie Artikel geliefert')",
(source["id"],))
issues_found += 1
elif source.get("last_seen_at"):
try:
from datetime import datetime
last_dt = datetime.fromisoformat(source["last_seen_at"])
age = (datetime.now() - last_dt).days
if age > 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,

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

@@ -0,0 +1,413 @@
"""Betriebsstatistik für den Statistik-Reiter.
Ein Endpoint liefert alle Serien und Kennzahlen in einem JSON. Die
Aggregation passiert in puren Funktionen (ohne DB-Zugriff), damit sie sich
als Unit-Tests prüfen lässt. Führende Quelle für Monitor-Läufe ist
refresh_log (hat Lagentyp, Kosten, Dauer je Lauf), token_usage_monthly
ergänzt die übrigen Quellen (globe, chat, enhance).
Ehrlichkeits-Regeln der Margen-Schätzung (keine historisierten Buchungen):
- Bewertet wird zu den HEUTIGEN Sätzen aus billing_tariff.
- Die Anlage einer Recherche zählt als 3 Durchläufe (Preismodell), erscheint
im refresh_log aber nur als eine Zeile. Deshalb zählt der erste
abgeschlossene Lauf je Recherche-Lage dreifach.
- analysis/factcheck haben keine Nutzungsdaten und fehlen (Schätzung eher
zu niedrig). Ist-Kosten enthalten auch Fehlläufe.
"""
import logging
import re
import statistics
from collections import defaultdict
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
from auth import get_current_admin
from database import get_db
from routers.pricing import TARIFF_DEFAULTS, USD_PER_CREDIT
logger = logging.getLogger("verwaltung.statistik")
router = APIRouter(prefix="/api/statistik", tags=["Statistik"])
_RETRY_PREFIX = re.compile(r"^Endgültig fehlgeschlagen nach \d+ Versuchen:\s*")
# --------------------------------------------------------------------------
# Pure Funktionen (ohne DB, unit-testbar)
# --------------------------------------------------------------------------
def baue_monatsachse(von_ym: str, bis_ym: str) -> list[str]:
"""Lückenlose Liste 'YYYY-MM' von von_ym bis bis_ym (einschließlich)."""
if not von_ym or not bis_ym or von_ym > bis_ym:
return []
y, m = int(von_ym[:4]), int(von_ym[5:7])
ey, em = int(bis_ym[:4]), int(bis_ym[5:7])
achse = []
while (y, m) <= (ey, em):
achse.append(f"{y:04d}-{m:02d}")
m += 1
if m == 13:
y, m = y + 1, 1
return achse
def normalisiere_fehler(msg: str | None) -> str:
"""Fehlermeldungen für das Ranking zusammenfassen."""
if not msg:
return "Unbekannt"
msg = _RETRY_PREFIX.sub("", msg).strip()
if msg.startswith("Verwaist"):
return "Verwaist (Neustart, automatisch bereinigt)"
return msg[:120]
def aggregiere_refresh(rows: list[dict], achse: list[str]) -> dict:
"""Aggregiert refresh_log-Detailzeilen zu KPI, Monat×Typ, Betrieb, Fehlern.
Erwartete Row-Schlüssel: monat, typ, status, trigger_type, total_cost_usd,
input_tokens, output_tokens, articles_found, error_message, dauer_s.
Mediane und Durchschnittskosten nur über Läufe mit Kosten > 0 (die
Kostenerfassung begann erst später, Nullen würden verzerren).
"""
typen = ("adhoc", "research")
mt = {(m, t): {"laeufe": 0, "kosten": 0.0, "tokens_in": 0, "tokens_out": 0,
"artikel": 0, "_kosten_liste": [], "_dauern": []}
for m in achse for t in typen}
betrieb = {m: {"completed": 0, "error": 0, "cancelled": 0, "auto": 0, "manual": 0}
for m in achse}
fehler: dict[str, int] = defaultdict(int)
dauer_typ: dict[str, list] = {t: [] for t in typen}
kpi = {"kosten_usd": 0.0, "laeufe_ok": 0, "laeufe_gesamt": 0,
"artikel": 0, "tokens_in": 0, "tokens_out": 0, "_kosten_liste": []}
for r in rows:
monat = r.get("monat")
typ = r.get("typ") if r.get("typ") in typen else "adhoc"
status = r.get("status")
kosten = r.get("total_cost_usd") or 0.0
kpi["laeufe_gesamt"] += 1
kpi["kosten_usd"] += kosten
kpi["tokens_in"] += r.get("input_tokens") or 0
kpi["tokens_out"] += r.get("output_tokens") or 0
if monat in betrieb:
b = betrieb[monat]
if status in ("completed", "error", "cancelled"):
b[status] += 1
if status == "completed":
if r.get("trigger_type") == "auto":
b["auto"] += 1
else:
b["manual"] += 1
if status == "error":
fehler[normalisiere_fehler(r.get("error_message"))] += 1
if status != "completed":
continue
kpi["laeufe_ok"] += 1
kpi["artikel"] += r.get("articles_found") or 0
if kosten > 0:
kpi["_kosten_liste"].append(kosten)
dauer = r.get("dauer_s")
if dauer is not None and 0 < dauer < 86400:
dauer_typ[typ].append(dauer)
key = (monat, typ)
if key in mt:
z = mt[key]
z["laeufe"] += 1
z["kosten"] += kosten
z["tokens_in"] += r.get("input_tokens") or 0
z["tokens_out"] += r.get("output_tokens") or 0
z["artikel"] += r.get("articles_found") or 0
if kosten > 0:
z["_kosten_liste"].append(kosten)
if dauer is not None and 0 < dauer < 86400:
z["_dauern"].append(dauer)
monat_typ = []
for m in achse:
for t in typen:
z = mt[(m, t)]
monat_typ.append({
"monat": m, "typ": t,
"laeufe": z["laeufe"],
"kosten": round(z["kosten"], 2),
"kosten_median": round(statistics.median(z["_kosten_liste"]), 2) if z["_kosten_liste"] else None,
"tokens_in": z["tokens_in"], "tokens_out": z["tokens_out"],
"artikel": z["artikel"],
"dauer_avg_s": round(sum(z["_dauern"]) / len(z["_dauern"])) if z["_dauern"] else None,
})
gesamt = kpi["laeufe_gesamt"]
ergebnis_kpi = {
"kosten_usd": round(kpi["kosten_usd"], 2),
"laeufe_ok": kpi["laeufe_ok"],
"laeufe_gesamt": gesamt,
"erfolgsquote": round(kpi["laeufe_ok"] / gesamt * 100, 1) if gesamt else None,
"median_kosten_lauf": round(statistics.median(kpi["_kosten_liste"]), 2) if kpi["_kosten_liste"] else None,
"artikel": kpi["artikel"],
"tokens_in": kpi["tokens_in"],
"tokens_out": kpi["tokens_out"],
}
fehler_top = [{"grund": g, "n": n}
for g, n in sorted(fehler.items(), key=lambda x: -x[1])[:5]]
dauer_je_typ = {t: (round(sum(v) / len(v)) if v else None) for t, v in dauer_typ.items()}
return {
"kpi": ergebnis_kpi,
"monat_typ": monat_typ,
"monat_betrieb": [{"monat": m, **betrieb[m]} for m in achse],
"fehler_top": fehler_top,
"dauer_typ": dauer_je_typ,
}
def berechne_credits(adhoc_ok: int, research_ok: int, research_erstlaeufe: int,
neben_aufrufe: dict, tarif: dict) -> float:
"""Geschätzter Credits-Gegenwert zu heutigen Sätzen.
Die Anlage einer Recherche (= erster abgeschlossener Lauf) zählt dreifach,
deshalb +2 zusätzliche Durchläufe je Erstlauf. neben_aufrufe ist
{quelle: api_calls} für chat/enhance/globe.
"""
credits = adhoc_ok * tarif.get("monitor_adhoc", 0)
credits += (research_ok + 2 * research_erstlaeufe) * tarif.get("monitor_research", 0)
for quelle, aufrufe in (neben_aufrufe or {}).items():
credits += (aufrufe or 0) * tarif.get(quelle, 0)
return round(credits, 2)
def aggregiere_orgs(rows_orgs: list[dict], rows_neben: list[dict],
org_namen: dict, tarif: dict, usd_per_credit: float) -> list[dict]:
"""Baut die Organisations-Tabelle mit Ist-Kosten und Margen-Schätzung."""
neben: dict[int, dict] = defaultdict(lambda: {"aufrufe": {}, "kosten": 0.0})
for r in rows_neben:
org = neben[r["organization_id"]]
org["aufrufe"][r["source"]] = (org["aufrufe"].get(r["source"], 0) or 0) + (r["api_calls"] or 0)
org["kosten"] += r["kosten"] or 0.0
ergebnis = []
alle_ids = set(org_namen) | {r["tenant_id"] for r in rows_orgs} | set(neben)
orgs_map = {r["tenant_id"]: r for r in rows_orgs}
for org_id in sorted(alle_ids):
o = orgs_map.get(org_id, {})
n = neben.get(org_id, {"aufrufe": {}, "kosten": 0.0})
ist_kosten = (o.get("ist_kosten") or 0.0) + n["kosten"]
credits = berechne_credits(
o.get("adhoc_ok") or 0, o.get("research_ok") or 0,
o.get("research_erstlaeufe") or 0, n["aufrufe"], tarif,
)
gegenwert = round(credits * usd_per_credit, 2)
ergebnis.append({
"org_id": org_id,
"name": org_namen.get(org_id, f"Organisation {org_id}"),
"kosten_usd": round(ist_kosten, 2),
"laeufe_ok": o.get("laeufe_ok") or 0,
"laeufe_gesamt": o.get("laeufe_gesamt") or 0,
"credits_geschaetzt": credits,
"gegenwert_usd": gegenwert,
"marge_usd": round(gegenwert - ist_kosten, 2),
})
ergebnis.sort(key=lambda x: -x["kosten_usd"])
return ergebnis
def fuelle_monatsserie(rows: list[dict], achse: list[str], schluessel: str,
felder: list[str]) -> list[dict]:
"""Füllt eine Monatsserie lückenlos mit Nullwerten auf."""
vorhanden = {r["monat"]: r for r in rows}
out = []
for m in achse:
r = vorhanden.get(m, {})
out.append({"monat": m, **{f: (r.get(f) or 0) for f in felder}})
return out
# --------------------------------------------------------------------------
# Endpoint
# --------------------------------------------------------------------------
@router.get("")
async def statistik(months: int = Query(12, ge=0, le=120),
admin=Depends(get_current_admin)):
"""Komplette Betriebsstatistik in einem Aufruf. months=0 heißt alles."""
heute = datetime.now()
heute_ym = heute.strftime("%Y-%m")
start = None
start_ym = None
if months > 0:
jahr, monat = heute.year, heute.month - (months - 1)
while monat <= 0:
monat += 12
jahr -= 1
start_ym = f"{jahr:04d}-{monat:02d}"
start = start_ym + "-01"
db = await get_db()
try:
# Q1: Detailzeilen der Monitor-Läufe
q1 = ("SELECT strftime('%Y-%m', r.started_at) AS monat, i.type AS typ, "
"r.status, r.trigger_type, r.total_cost_usd, r.input_tokens, "
"r.output_tokens, r.articles_found, r.error_message, "
"CASE WHEN r.completed_at IS NOT NULL THEN "
"(julianday(r.completed_at) - julianday(r.started_at)) * 86400.0 END AS dauer_s "
"FROM refresh_log r JOIN incidents i ON i.id = r.incident_id")
p1: tuple = ()
if start:
q1 += " WHERE r.started_at >= ?"
p1 = (start,)
rows_refresh = [dict(r) for r in await (await db.execute(q1, p1)).fetchall()]
# Q2: Monatsserie je Quelle
q2 = ("SELECT year_month AS monat, source AS quelle, "
"SUM(total_cost_usd) AS kosten, SUM(api_calls) AS aufrufe "
"FROM token_usage_monthly")
p2: tuple = ()
if start_ym:
q2 += " WHERE year_month >= ?"
p2 = (start_ym,)
q2 += " GROUP BY year_month, source"
rows_quelle = [dict(r) for r in await (await db.execute(q2, p2)).fetchall()]
# Q3: je Organisation (Erstlauf-Erkennung über ALLE Zeiten, Fenster außen)
q3 = ("WITH research_first AS ("
" SELECT r.id, ROW_NUMBER() OVER (PARTITION BY r.incident_id ORDER BY r.started_at) AS rn"
" FROM refresh_log r JOIN incidents i ON i.id = r.incident_id"
" WHERE r.status = 'completed' AND i.type = 'research') "
"SELECT r.tenant_id, SUM(r.total_cost_usd) AS ist_kosten, "
"SUM(CASE WHEN r.status='completed' THEN 1 ELSE 0 END) AS laeufe_ok, "
"COUNT(*) AS laeufe_gesamt, "
"SUM(CASE WHEN r.status='completed' AND i.type='adhoc' THEN 1 ELSE 0 END) AS adhoc_ok, "
"SUM(CASE WHEN r.status='completed' AND i.type='research' THEN 1 ELSE 0 END) AS research_ok, "
"SUM(CASE WHEN rf.rn = 1 THEN 1 ELSE 0 END) AS research_erstlaeufe "
"FROM refresh_log r "
"JOIN incidents i ON i.id = r.incident_id "
"LEFT JOIN research_first rf ON rf.id = r.id")
p3: tuple = ()
if start:
q3 += " WHERE r.started_at >= ?"
p3 = (start,)
q3 += " GROUP BY r.tenant_id"
rows_orgs = [dict(r) for r in await (await db.execute(q3, p3)).fetchall()]
# Q4: Nicht-Monitor-Verbrauch je Organisation
q4 = ("SELECT organization_id, source, SUM(api_calls) AS api_calls, "
"SUM(total_cost_usd) AS kosten FROM token_usage_monthly "
"WHERE source != 'monitor'")
p4: tuple = ()
if start_ym:
q4 += " AND year_month >= ?"
p4 = (start_ym,)
q4 += " GROUP BY organization_id, source"
rows_neben = [dict(r) for r in await (await db.execute(q4, p4)).fetchall()]
# Q5: Wachstum (drei kleine Abfragen)
wachstum_rows: dict[str, list[dict]] = {}
for art, tabelle in (("orgs", "organizations"), ("users", "users"), ("lagen", "incidents")):
q5 = f"SELECT strftime('%Y-%m', created_at) AS monat, COUNT(*) AS n FROM {tabelle}"
p5: tuple = ()
if start:
q5 += " WHERE created_at >= ?"
p5 = (start,)
q5 += " GROUP BY 1"
wachstum_rows[art] = [dict(r) for r in await (await db.execute(q5, p5)).fetchall()]
# Q6: Meta, Tarif, Org-Namen
letzter_lauf = (await (await db.execute("SELECT MAX(started_at) FROM refresh_log")).fetchone())[0]
letzter_tum = (await (await db.execute("SELECT MAX(year_month) FROM token_usage_monthly")).fetchone())[0]
tarif = dict(TARIFF_DEFAULTS)
tarif_quelle = "defaults"
try:
for r in await (await db.execute("SELECT tariff_key, credits FROM billing_tariff")).fetchall():
tarif[r["tariff_key"]] = r["credits"]
tarif_quelle = "billing_tariff"
except Exception:
pass
org_namen = {r["id"]: r["name"] for r in
await (await db.execute("SELECT id, name FROM organizations")).fetchall()}
# Q7: Aktivität (Tabelle kann auf alten Ständen fehlen)
mau_rows: list[dict] = []
dau_rows: list[dict] = []
erfassung_seit = None
try:
qm = "SELECT strftime('%Y-%m', day) AS monat, COUNT(DISTINCT user_id) AS nutzer FROM user_activity_days"
pm: tuple = ()
if start:
qm += " WHERE day >= ?"
pm = (start[:10],)
qm += " GROUP BY 1"
mau_rows = [dict(r) for r in await (await db.execute(qm, pm)).fetchall()]
dau_start = (heute - timedelta(days=30)).strftime("%Y-%m-%d")
dau_rows = [dict(r) for r in await (await db.execute(
"SELECT day AS tag, COUNT(DISTINCT user_id) AS nutzer FROM user_activity_days "
"WHERE day >= ? GROUP BY day ORDER BY day", (dau_start,))).fetchall()]
erfassung_seit = (await (await db.execute("SELECT MIN(day) FROM user_activity_days")).fetchone())[0]
except Exception:
logger.warning("user_activity_days fehlt noch, MAU/DAU bleiben leer")
# Momentaufnahme aus last_login_at (verdaut beide Zeitstempelformate)
nutzer_30d = (await (await db.execute(
"SELECT COUNT(*) FROM users WHERE is_active = 1 AND last_login_at IS NOT NULL "
"AND datetime(last_login_at) >= datetime('now', '-30 days')")).fetchone())[0]
finally:
await db.close()
# Monatsachse: vom Fensterstart (oder ältestem Datenmonat) bis heute
daten_monate = ([r["monat"] for r in rows_refresh if r.get("monat")] +
[r["monat"] for r in rows_quelle] +
[r["monat"] for art in wachstum_rows.values() for r in art if r.get("monat")])
von_ym = start_ym or (min(daten_monate) if daten_monate else heute_ym)
bis_ym = max([heute_ym] + daten_monate)
achse = baue_monatsachse(von_ym, bis_ym)
agg = aggregiere_refresh(rows_refresh, achse)
# Monatsserie je Quelle in stabile Form bringen
quellen = sorted({r["quelle"] for r in rows_quelle}) or []
monat_quelle = []
q_map = {(r["monat"], r["quelle"]): r for r in rows_quelle}
for m in achse:
for q in quellen:
r = q_map.get((m, q), {})
monat_quelle.append({"monat": m, "quelle": q,
"kosten": round(r.get("kosten") or 0.0, 2),
"aufrufe": r.get("aufrufe") or 0})
wachstum = []
w_maps = {art: {r["monat"]: r["n"] for r in rows} for art, rows in wachstum_rows.items()}
for m in achse:
wachstum.append({"monat": m,
"orgs": w_maps["orgs"].get(m, 0),
"users": w_maps["users"].get(m, 0),
"lagen": w_maps["lagen"].get(m, 0)})
mau_map = {r["monat"]: r["nutzer"] for r in mau_rows}
aktivitaet = {
"mau": [{"monat": m, "nutzer": mau_map.get(m, 0)} for m in achse],
"dau": dau_rows,
"erfassung_seit": erfassung_seit,
"nutzer_30d": nutzer_30d,
}
return {
"window": {"months": months, "monate": achse},
"datenstand": {"letzter_lauf": letzter_lauf, "letzter_tum_monat": letzter_tum,
"kosten_ab": "2026-03",
"erzeugt": heute.strftime("%Y-%m-%d %H:%M")},
"tarif": {**tarif, "usd_per_credit": USD_PER_CREDIT, "quelle": tarif_quelle},
"kpi": agg["kpi"],
"monat_typ": agg["monat_typ"],
"monat_quelle": monat_quelle,
"monat_betrieb": agg["monat_betrieb"],
"fehler_top": agg["fehler_top"],
"dauer_typ": agg["dauer_typ"],
"orgs": aggregiere_orgs(rows_orgs, rows_neben, org_namen, tarif, USD_PER_CREDIT),
"wachstum": wachstum,
"aktivitaet": aktivitaet,
}

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

@@ -6,83 +6,74 @@
<title>AegisSight Monitor-Verwaltung</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="apple-touch-icon" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css?v=20260509d">
<link rel="stylesheet" href="/static/css/style.css?v=20260725o">
<script>(function(){var t=localStorage.getItem('portal_theme');if(t==='light')document.documentElement.setAttribute('data-theme','light');try{var a=JSON.parse(localStorage.getItem('osint_a11y')||'{}');Object.keys(a).forEach(function(k){if(a[k])document.documentElement.setAttribute('data-a11y-'+k,'true');});}catch(e){}})()</script>
<style>
.source-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:12px; font-weight:600; }
.badge-monitor { background:#e3f2fd; color:#1565c0; }
.badge-globe { background:#e8f5e9; color:#2e7d32; }
.badge-monitor { background:rgba(59,130,246,0.15); color:#93c5fd; }
.badge-globe { background:rgba(16,185,129,0.15); color:#86efac; }
[data-theme="light"] .badge-monitor { background:#e3f2fd; color:#1565c0; }
[data-theme="light"] .badge-globe { background:#e8f5e9; color:#2e7d32; }
</style>
</head>
<body>
<!-- Header -->
<header class="app-header">
<div class="logo">AegisSight <span>Monitor-Verwaltung</span></div>
<div class="logo">
<span class="logo-plaque"><img src="/static/favicon.svg" alt="AegisSight Logo"></span>
AegisSight <span>Monitor-Verwaltung</span>
</div>
<div class="header-right">
<div class="theme-switch dark" id="theme-toggle" onclick="ThemeManager.toggle()" role="switch" aria-checked="true" aria-label="Dark Mode" title="Theme wechseln">
<span class="theme-switch-icon theme-switch-sun">&#9728;&#65038;</span>
<div class="theme-switch-track">
<div class="theme-switch-knob"></div>
</div>
<span class="theme-switch-icon theme-switch-moon">&#9789;</span>
</div>
<span class="header-user" id="headerUser"></span>
<button class="btn btn-secondary btn-small" id="logoutBtn">Abmelden</button>
<button class="btn btn-secondary btn-small" id="logoutBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></svg>
Abmelden
</button>
</div>
</header>
<main class="app-content">
<!-- Navigation -->
<!-- Navigation (Lucide-Icons, Strichstaerke 2 bei 15px) -->
<nav class="nav-tabs">
<button class="nav-tab active" data-section="dashboard">Dashboard</button>
<button class="nav-tab" data-section="orgs">Organisationen</button>
<button class="nav-tab" data-section="licenses">Lizenzen</button>
<button class="nav-tab" data-section="sources">Quellen</button>
<button class="nav-tab" data-section="audit">Audit-Log</button>
<button class="nav-tab active" data-section="dashboard">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
Dashboard
</button>
<button class="nav-tab" data-section="orgs">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></svg>
Organisationen
</button>
<button class="nav-tab" data-section="sources">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>
Quellen
</button>
<button class="nav-tab" data-section="audit">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>
Audit-Log
</button>
</nav>
<!-- Dashboard Section -->
<div class="section active" id="sec-dashboard">
<div class="stats-grid" id="statsGrid"></div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div class="card">
<div class="card-header">
<h2>Bald ablaufende Lizenzen</h2>
</div>
<div class="card-body">
<ul class="expiring-list" id="expiringList">
<li class="text-muted" style="padding: 8px 0;">Laden...</li>
</ul>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>Letzte Aktivität</h2>
</div>
<div class="card-body" id="recentActivity">
<div class="text-muted">Laden...</div>
</div>
</div>
</div>
<!-- Artikel-Übersetzung -->
<div class="card" id="translationCard" style="margin-top:16px;">
<div class="card card-link" onclick="gotoSection('orgs')" role="link" tabindex="0" title="Zu den Organisationen (Klick auf einen Eintrag öffnet die Organisation)">
<div class="card-header">
<h2>Artikel-Übersetzung</h2>
<h2>Bald ablaufende Lizenzen</h2>
<svg class="card-link-chevron" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="card-body">
<p class="text-muted" style="margin-top:0;">
Die automatische Übersetzung im Monitor ist deaktiviert. Hier lassen sich
fremdsprachige Artikel ohne deutsche Fassung manuell übersetzen.
</p>
<p id="translationInfo" style="margin:12px 0;">Status wird geladen…</p>
<div id="translationProgressWrap" style="display:none; margin:12px 0;">
<div style="background:rgba(128,128,128,0.25); border-radius:6px; height:14px; overflow:hidden;">
<div id="translationProgressBar" style="background:#1565c0; height:100%; width:0%; transition:width .3s;"></div>
</div>
<p class="text-muted" id="translationProgressText" style="margin:6px 0 0;"></p>
</div>
<div style="margin-top:12px; display:flex; gap:8px;">
<button class="btn btn-primary" id="translationRunBtn">Übersetzung starten</button>
<button class="btn btn-danger" id="translationCancelBtn" style="display:none;">Abbrechen</button>
</div>
<ul class="expiring-list" id="expiringList">
<li class="text-muted" style="padding: 8px 0;">Laden...</li>
</ul>
</div>
</div>
</div>
@@ -93,7 +84,7 @@
<div id="orgListView">
<div class="action-bar">
<input type="text" class="search-input" id="orgSearch" placeholder="Organisation suchen...">
<button class="btn btn-primary" id="newOrgBtn">+ Neue Organisation</button>
<button class="btn btn-primary" id="newOrgBtn"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Neue Organisation</button>
</div>
<div class="card">
<div class="table-wrap">
@@ -101,9 +92,9 @@
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Nutzer</th>
<th>Lizenz</th>
<th>Kontingent</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
@@ -119,126 +110,125 @@
<button class="detail-back" id="orgBackBtn">&larr; Zurück</button>
<div id="orgDetailHeader"></div>
<div class="nav-tabs mt-16" id="orgDetailTabs">
<button class="nav-tab active" data-subtab="users">Nutzer</button>
<button class="nav-tab" data-subtab="org-licenses">Lizenzen</button>
<button class="nav-tab" data-subtab="settings">Einstellungen</button>
<button class="nav-tab" data-subtab="tokens">Token-Nutzung</button>
</div>
<!-- Users Sub-Tab -->
<div class="section active" id="sub-users">
<div class="action-bar">
<span class="text-secondary" id="userLimitInfo"></span>
<button class="btn btn-primary" id="newUserBtn">+ Nutzer anlegen</button>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>E-Mail</th>
<th>Rolle</th>
<th>Status</th>
<th>Globe</th>
<th>Netzwerk</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="userTable"></tbody>
</table>
</div>
</div>
</div>
<!-- Licenses Sub-Tab -->
<div class="section" id="sub-org-licenses">
<div class="action-bar">
<span></span>
<button class="btn btn-primary" id="newLicenseBtn">+ Neue Lizenz</button>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Typ</th>
<th>Max Nutzer</th>
<th>Gültig ab</th>
<th>Gültig bis</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="licenseTable"></tbody>
</table>
</div>
</div>
</div>
<!-- Settings Sub-Tab -->
<div class="section" id="sub-settings">
<div class="card">
<div class="card-body">
<form id="orgEditForm">
<div class="form-group">
<label for="editOrgName">Name</label>
<input type="text" id="editOrgName">
</div>
<div class="form-group">
<label>Status</label>
<select id="editOrgActive">
<option value="true">Aktiv</option>
<option value="false">Deaktiviert</option>
</select>
</div>
<div class="form-group">
<label for="editOrgLanguage">Pipeline-Sprache</label>
<select id="editOrgLanguage">
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
<small class="text-secondary">Bestimmt die Ausgabesprache der KI (Lagebild, Faktencheck, Recherche) und der sichtbarsten UI-Elemente fuer alle Nutzer dieser Organisation.</small>
</div>
<div style="display: flex; gap: 8px; margin-top: 16px;">
<button type="submit" class="btn btn-primary">Speichern</button>
<button type="button" class="btn btn-danger" id="deleteOrgBtn">Organisation löschen</button>
</div>
</form>
</div>
</div>
</div>
<div class="section" id="sub-tokens">
<!-- Alles auf einer Seite. Die Nutzer-Verwaltung steckt mit in der
Karte "Lizenz, Nutzer & Credits-Kontingent bearbeiten". -->
<!-- Der fruehere Einstellungen-Block ist aufgeloest. Status und
Pipeline-Sprache sind direkt in der Lizenz-Tabelle umschaltbar,
Umbenennen und Loeschen sitzen in der Kopfzeile. -->
<div class="org-block" id="sub-tokens">
<h2 class="org-block-title">Übersicht</h2>
<div class="token-overview">
<!-- Eine Reihe: Kundensicht (Credits) und interner Einkauf (USD) -->
<div class="token-stats-row" id="tokenStatsRow">
<div class="token-stat-card">
<div class="token-stat-label">Verbrauchte Credits</div>
<div class="token-stat-label">Verbrauchte Credits (Periode)</div>
<div class="token-stat-value" id="tokenCreditsUsed">-</div>
<div class="stat-korrektur" title="Überschreibt den Verbrauchszähler direkt, z.B. für Kulanz (Zähler runtersetzen = Credits zurückgeben). Der Kunde sieht die Änderung sofort.">
<input type="number" id="korrCreditsUsed" min="0" placeholder="Zähler neu setzen">
<button type="button" class="btn btn-secondary btn-small" onclick="korrigiereVerbrauch()">Korrigieren</button>
</div>
</div>
<div class="token-stat-card">
<div class="token-stat-label">Verbleibend</div>
<div class="token-stat-value" id="tokenCreditsRemaining">-</div>
</div>
<div class="token-stat-card">
<div class="token-stat-label">Budget (USD)</div>
<div class="token-stat-value" id="tokenBudgetUsd">-</div>
</div>
<div class="token-stat-card">
<div class="token-stat-label">Kosten (USD)</div>
<div class="token-stat-label">API-Kosten Monat (Einkauf)</div>
<div class="token-stat-value" id="tokenCostUsd">-</div>
</div>
</div>
<div id="tokenSourceSplit" style="margin-top:12px;font-size:14px;"></div>
<div class="token-budget-bar-section">
<div class="token-budget-label">
<span>Budget-Auslastung</span>
<span>Kontingent-Auslastung (laufende Periode)</span>
<span id="tokenBudgetPercent">0%</span>
</div>
<div class="token-budget-bar-container">
<div class="token-budget-bar" id="tokenBudgetBar"></div>
</div>
</div>
<div id="tokenSourceSplit" style="margin-top:10px;font-size:13px;"></div>
<p style="margin-top:10px; font-size:12px; color:var(--text-muted); display:flex; align-items:center; gap:6px;">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
<span><span id="tariffInfoText">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.</span>
<a href="#" onclick="openRechnerTab(); return false;" style="color:var(--accent);">Verbrauchsrechner öffnen</a></span>
</p>
<!-- An zweiter Stelle: Lizenz, Nutzer und Kontingent in EINER Karte -->
<h3 style="margin-top:24px;">Lizenz, Nutzer &amp; Credits-Kontingent bearbeiten</h3>
<div class="card" style="margin-top:12px;">
<div class="card-header">
<span class="text-secondary" style="font-size:13px;">Aktive und frühere Lizenzen dieser Organisation</span>
<button class="btn btn-primary btn-small" id="newLicenseBtn"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Neue Lizenz</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Typ</th>
<th>Max Nutzer</th>
<th>Gültig ab</th>
<th>Gültig bis</th>
<th>Status</th>
<th>Pipeline-Sprache</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="licenseTable"></tbody>
</table>
</div>
<div class="card-header" style="border-top:1px solid var(--border);">
<span class="text-secondary" style="font-size:13px;" id="userLimitInfo"></span>
<button class="btn btn-primary btn-small" id="newUserBtn"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Nutzer anlegen</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>E-Mail</th>
<th>Rolle</th>
<th>Status</th>
<th>Globe</th>
<th>Netzwerk</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="userTable"></tbody>
</table>
</div>
<div class="card-body" style="border-top:1px solid var(--border);">
<form id="tokenBudgetForm" style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div style="grid-column:1/-1; padding:8px 12px; background:var(--bg-tertiary); border-radius:6px; border:1px solid var(--border);">
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; margin:0;">
<input type="checkbox" id="editUnlimitedBudget" onchange="onUnlimitedToggle()">
<span><strong>Unbegrenztes Budget</strong> &mdash; Verbrauch wird trotzdem getrackt, aber kein Limit/Hard-Stop</span>
</label>
</div>
<div class="form-group">
<label for="editCreditsTotal">Credits-Kontingent</label>
<input type="number" id="editCreditsTotal" placeholder="z.B. 10000">
</div>
<div class="form-group">
<label for="editCreditsPeriod">Abrechnungszeitraum</label>
<select id="editCreditsPeriod">
<option value="monthly">Monatlich (füllt sich zum Monatswechsel neu)</option>
<option value="total">Gesamtlaufzeit</option>
</select>
</div>
<div class="form-group">
<label for="editWarnPercent">Warnschwelle (%, 0 = aus)</label>
<input type="number" id="editWarnPercent" min="0" max="100" placeholder="80">
</div>
<div style="grid-column:1/-1; display:flex; gap:8px; align-items:center;">
<button type="submit" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Speichern
</button>
<span id="tokenBudgetMsg" style="font-size:13px; color:var(--text-secondary);"></span>
</div>
</form>
</div>
</div>
<h3 style="margin-top:24px;">Monatlicher Verlauf</h3>
<table class="data-table">
<thead>
@@ -254,128 +244,53 @@
</thead>
<tbody id="tokenMonthlyTable"></tbody>
</table>
<h3 style="margin-top:24px;">Budget bearbeiten</h3>
<div class="card" style="margin-top:12px;">
<div class="card-body">
<form id="tokenBudgetForm" style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div style="grid-column:1/-1; padding:8px 12px; background:var(--bg-tertiary,#0f172a); border-radius:6px; border:1px solid var(--border);">
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; margin:0;">
<input type="checkbox" id="editUnlimitedBudget" onchange="onUnlimitedToggle()">
<span><strong>Unbegrenztes Budget</strong> &mdash; Verbrauch wird trotzdem getrackt, aber kein Limit/Hard-Stop</span>
</label>
</div>
<div class="form-group">
<label for="editCreditsTotal">Credits-Kontingent</label>
<input type="number" id="editCreditsTotal" placeholder="z.B. 600000">
</div>
<div class="form-group">
<label for="editCostPerCredit">Kosten pro Credit (USD)</label>
<input type="number" id="editCostPerCredit" step="0.0001" placeholder="z.B. 0.0033">
</div>
<div class="form-group">
<label for="editBudgetUsd">Budget-Limit (USD)</label>
<input type="number" id="editBudgetUsd" step="0.01" placeholder="z.B. 2000">
</div>
<div class="form-group">
<label for="editCreditsUsed">Verbrauchte Credits</label>
<input type="number" id="editCreditsUsed" step="0.01" placeholder="Aktueller Verbrauch">
</div>
<div style="grid-column:1/-1; display:flex; gap:8px; align-items:center;">
<button type="submit" class="btn btn-primary">Speichern</button>
<span id="tokenBudgetMsg" style="font-size:13px; color:var(--text-secondary);"></span>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Licenses Section (global overview) -->
<div class="section" id="sec-licenses">
<div class="card">
<div class="card-header">
<h2>Bald ablaufende Lizenzen</h2>
<select id="expiringDays" style="background: var(--bg-primary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-primary); padding: 4px 8px; font-size: 13px;">
<option value="14">14 Tage</option>
<option value="30" selected>30 Tage</option>
<option value="90">90 Tage</option>
</select>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Organisation</th>
<th>Typ</th>
<th>Max Nutzer</th>
<th>Läuft ab</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="expiringTable"></tbody>
</table>
</div>
</div>
</div>
<!-- Sources Section -->
<div class="section" id="sec-sources">
<div class="nav-tabs" id="sourceSubTabs">
<button class="nav-tab active" data-subtab="global-sources">Grundquellen</button>
<button class="nav-tab" data-subtab="tenant-sources">Kundenquellen</button>
<button class="nav-tab" data-subtab="source-health">Quellen-Health</button>
<button class="nav-tab" data-subtab="classification-review">Klassifikation <span class="sources-tab-badge" id="classificationPendingBadge">0</span></button>
<button class="nav-tab" data-subtab="x-scraper">X-Recherche-Konten</button>
<button class="nav-tab active" data-subtab="global-sources">Quellenliste</button>
<button class="nav-tab" data-subtab="aufgaben">Aufgaben <span class="sources-tab-badge" id="tasksPendingBadge">0</span></button>
<button class="nav-tab" data-subtab="x-scraper">Recherche-Zugänge</button>
</div>
<!-- Grundquellen -->
<!-- Vereinte Quellenliste (Grund- + Kundenquellen) -->
<div class="section active" id="sub-global-sources">
<div class="sources-stats-bar" id="globalStatsBar"></div>
<div class="action-bar">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<input type="text" class="search-input" id="globalSourceSearch" placeholder="Grundquelle suchen...">
<select class="filter-select" id="globalFilterType" onchange="filterGlobalSources()">
<option value="">Alle Typen</option>
</select>
<select class="filter-select" id="globalFilterCategory" onchange="filterGlobalSources()">
<option value="">Alle Kategorien</option>
</select>
<select class="filter-select" id="globalFilterStatus" onchange="filterGlobalSources()">
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
<select class="filter-select" id="globalFilterLanguage" onchange="filterGlobalSources()">
<option value="">Alle Sprachen</option>
</select>
<input type="text" class="search-input" id="globalSourceSearch" placeholder="Quelle suchen...">
<span class="text-secondary" id="globalSourceCount"></span>
</div>
<button class="btn btn-primary" id="tenantBulkPromoteBtn" onclick="bulkPromoteSelected()" disabled style="display:none;margin-right:8px;">Ausgewählte übernehmen (0)</button>
<button class="btn btn-secondary" id="discoverSourceBtn">Erkennen</button>
<button class="btn btn-secondary" id="newPdfSourceBtn" style="margin-right:8px;">+ PDF hochladen</button>
<button class="btn btn-primary" id="newGlobalSourceBtn">+ Neue Grundquelle</button>
</div>
<div class="filter-bar" id="unifiedFilterBar"></div>
<div class="card">
<div class="table-wrap">
<table>
<div class="table-wrap drag-scroll" id="unifiedTableWrap">
<table id="unifiedTable">
<thead>
<tr>
<th class="sortable" data-sort="name" onclick="sortGlobalSources('name')">Name <span class="sort-icon"></span></th>
<th class="select-col"><input type="checkbox" id="unifiedSelectAll" onchange="toggleTenantSelectAll(this.checked)"></th>
<th class="sortable sticky-name" data-sort="name" onclick="sortUnifiedSources('name')">Name <span class="sort-icon"></span></th>
<th>URL</th>
<th class="sortable" data-sort="domain" onclick="sortGlobalSources('domain')">Domain <span class="sort-icon"></span></th>
<th class="sortable" data-sort="source_type" onclick="sortGlobalSources('source_type')">Typ <span class="sort-icon"></span></th>
<th class="sortable" data-sort="article_count" onclick="sortGlobalSources('article_count')">Artikel <span class="sort-icon"></span></th>
<th class="sortable" data-sort="articles_30d" onclick="sortGlobalSources('articles_30d')">Aktivität <span class="sort-icon"></span></th>
<th class="sortable" data-sort="tenant_excluded_count" onclick="sortGlobalSources('tenant_excluded_count')">Sperren <span class="sort-icon"></span></th>
<th class="sortable" data-sort="language" onclick="sortGlobalSources('language')">Sprache <span class="sort-icon"></span></th>
<th class="sortable" data-sort="domain" onclick="sortUnifiedSources('domain')">Domain <span class="sort-icon"></span></th>
<th class="sortable" data-sort="source_type" onclick="sortUnifiedSources('source_type')">Typ <span class="sort-icon"></span></th>
<th class="sortable" data-sort="article_count" onclick="sortUnifiedSources('article_count')">Artikel <span class="sort-icon"></span></th>
<th class="sortable" data-sort="articles_30d" onclick="sortUnifiedSources('articles_30d')">Aktivität <span class="sort-icon"></span></th>
<th class="sortable" data-sort="language" onclick="sortUnifiedSources('language')">Sprache <span class="sort-icon"></span></th>
<th>Bias</th>
<th class="sortable" data-sort="last_seen_at" onclick="sortGlobalSources('last_seen_at')">Letzter Treffer <span class="sort-icon"></span></th>
<th class="sortable" data-sort="health_status" onclick="sortGlobalSources('health_status')">Health <span class="sort-icon"></span></th>
<th class="sortable" data-sort="status" onclick="sortGlobalSources('status')">Status <span class="sort-icon"></span></th>
<th>Aktionen</th>
<th class="sortable" data-sort="last_seen_at" onclick="sortUnifiedSources('last_seen_at')">Letzter Treffer <span class="sort-icon"></span></th>
<th class="sortable" data-sort="health_status" onclick="sortUnifiedSources('health_status')">Health <span class="sort-icon"></span></th>
<th class="sortable" data-sort="status" onclick="sortUnifiedSources('status')">Status <span class="sort-icon"></span></th>
<th class="sortable" data-sort="org_name" onclick="sortUnifiedSources('org_name')">Organisation <span class="sort-icon"></span></th>
<th class="sticky-actions">Aktionen</th>
</tr>
</thead>
<tbody id="globalSourceTable"></tbody>
@@ -384,107 +299,40 @@
</div>
</div>
<!-- Kundenquellen -->
<div class="section" id="sub-tenant-sources">
<div class="action-bar">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<input type="text" class="search-input" id="tenantSourceSearch" placeholder="Kundenquelle suchen...">
<select class="filter-select" id="tenantFilterType" onchange="filterTenantSources()">
<option value="">Alle Typen</option>
</select>
<select class="filter-select" id="tenantFilterCategory" onchange="filterTenantSources()">
<option value="">Alle Kategorien</option>
</select>
<select class="filter-select" id="tenantFilterOrg" onchange="filterTenantSources()">
<option value="">Alle Organisationen</option>
</select>
<select class="filter-select" id="tenantFilterLanguage" onchange="filterTenantSources()">
<option value="">Alle Sprachen</option>
</select>
<span class="text-secondary" id="tenantSourceCount"></span>
</div>
<button class="btn btn-primary" id="tenantBulkPromoteBtn" disabled onclick="bulkPromoteSelected()">
Ausgewählte übernehmen (0)
</button>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:32px;"><input type="checkbox" id="tenantSelectAll" onchange="toggleTenantSelectAll(this.checked)"></th>
<th class="sortable" data-sort="name" onclick="sortTenantSources('name')">Name <span class="sort-icon"></span></th>
<th class="sortable" data-sort="domain" onclick="sortTenantSources('domain')">Domain <span class="sort-icon"></span></th>
<th class="sortable" data-sort="source_type" onclick="sortTenantSources('source_type')">Typ <span class="sort-icon"></span></th>
<th class="sortable" data-sort="category" onclick="sortTenantSources('category')">Kategorie <span class="sort-icon"></span></th>
<th class="sortable" data-sort="org_name" onclick="sortTenantSources('org_name')">Organisation <span class="sort-icon"></span></th>
<th class="sortable" data-sort="language" onclick="sortTenantSources('language')">Sprache <span class="sort-icon"></span></th>
<th>Bias</th>
<th>Hinzugefügt von</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="tenantSourceTable"></tbody>
</table>
</div>
</div>
</div>
<!-- Quellen-Health (Sub-Tab) - drei Bereiche als Sub-Sub-Tabs;
source-health.js rendert pro Bereich in den jeweiligen Container. -->
<div class="section" id="sub-source-health">
<div class="nav-tabs" id="healthSubTabs" style="margin-top:0;">
<button class="nav-tab active" data-healthtab="suggestions">Vorschläge</button>
<button class="nav-tab" data-healthtab="checks">Health-Status</button>
<button class="nav-tab" data-healthtab="verlauf">Verlauf</button>
</div>
<div id="ht-suggestions" class="health-pane active"></div>
<div id="ht-checks" class="health-pane" style="display:none;"></div>
<div id="ht-verlauf" class="health-pane" style="display:none;"></div>
</div>
<!-- Klassifikations-Review -->
<div class="section" id="sub-classification-review">
<!-- Aufgaben-Posteingang: Health-Vorschläge + Klassifikations-Review + Verlauf -->
<div class="section" id="sub-aufgaben">
<div class="action-bar review-toolbar">
<div class="review-toolbar-info">
<span><strong id="reviewPendingCount">0</strong> Vorschläge ausstehend</span>
<label class="review-conf-filter">
Mindest-Konfidenz:
<select class="filter-select" id="reviewMinConfidence" onchange="loadClassificationQueue()">
<option value="0">alle</option>
<option value="0.5">0.5+</option>
<option value="0.7">0.7+</option>
<option value="0.85">0.85+</option>
<option value="0.9">0.9+</option>
</select>
</label>
<span><strong id="aufgabenSuggestionCount">0</strong> Vorschläge offen</span>
<span><strong id="reviewPendingCount">0</strong> Klassifikationen ausstehend</span>
</div>
<div class="review-toolbar-actions">
<button class="btn btn-secondary btn-small" onclick="triggerExternalReputationSync()" title="IFCN-Faktenchecker und EUvsDisinfo neu syncen">Externe Daten syncen</button>
<button class="btn btn-secondary btn-small" onclick="triggerBulkClassify()" title="LLM-Klassifikation für noch unklassifizierte Quellen starten">+ Klassifikation starten</button>
<button class="btn btn-primary btn-small" onclick="bulkApproveHighConfidence()" title="Alle Vorschläge ab 0.85 Konfidenz übernehmen">Alle ≥ 0.85 genehmigen</button>
<button class="btn btn-primary btn-small" id="bulkApproveBtn" onclick="bulkApproveHighConfidence()" disabled title="Erst aktiv, wenn Klassifikations-Vorschläge ausstehen">Alle ≥ 0.85 genehmigen</button>
</div>
</div>
<div class="card">
<div class="filter-bar" id="aufgabenFilterBar"></div>
<div id="aufgabenSuggestions"></div>
<div class="card" style="margin-bottom:16px;">
<div class="card-header"><h2>Klassifikations-Review</h2></div>
<div class="review-list" id="classificationReviewList">
<div class="text-muted" style="padding:24px;text-align:center;">Lade Review-Queue…</div>
</div>
</div>
<div id="aufgabenVerlauf"></div>
</div>
<!-- X-Recherche-Konten (Sub-Tab) -->
<!-- Recherche-Zugänge (Sub-Tab): X-Konten + Telegram-Session-Status -->
<div class="section" id="sub-x-scraper">
<div class="action-bar">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<span class="text-secondary" id="xScraperCount"></span>
<div class="card" style="margin-bottom:16px;">
<div class="card-header">
<h2><svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="vertical-align:-2px;margin-right:7px;"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>X-Zugang</h2>
<div style="display:flex;gap:8px;">
<button class="btn btn-secondary btn-small" onclick="resetXScraperLocks()">Sperren zurücksetzen</button>
<button class="btn btn-primary btn-small" onclick="openXScraperAddModal()">+ Konto hinzufügen</button>
</div>
</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-secondary" onclick="resetXScraperLocks()">Sperren zurücksetzen</button>
<button class="btn btn-primary" onclick="openXScraperAddModal()">+ Konto hinzufügen</button>
</div>
</div>
<div class="card">
<p class="text-secondary" style="padding:0 4px 12px;">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.</p>
<div class="table-wrap">
<table>
<thead>
@@ -501,6 +349,26 @@
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<h2><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="vertical-align:-3px;margin-right:7px;"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>Telegram-Zugang</h2>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Benutzername</th>
<th>Telefon</th>
<th>Status</th>
<th>Anfragen</th>
<th>Letzte Prüfung</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="telegramStatusTable"><tr><td colspan="6" class="text-muted">Lade…</td></tr></tbody>
</table>
</div>
</div>
</div>
</div> <!-- /sec-sources -->
@@ -509,16 +377,7 @@
<div class="section" id="sec-audit">
<div class="action-bar" style="flex-wrap:wrap;gap:8px;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<select class="filter-select" id="auditFilterAction">
<option value="">Alle Aktionen</option>
</select>
<select class="filter-select" id="auditFilterResource">
<option value="">Alle Ressourcen</option>
</select>
<input type="number" class="filter-select" id="auditFilterResourceId" placeholder="Ressourcen-ID" min="1" style="width:130px;">
<select class="filter-select" id="auditFilterAdmin">
<option value="">Alle Admins</option>
</select>
<input type="date" class="filter-select" id="auditFilterFrom" title="Von (Datum)">
<input type="date" class="filter-select" id="auditFilterTo" title="Bis (Datum)">
<button class="btn btn-secondary btn-small" id="auditFilterReset">Filter zuruecksetzen</button>
@@ -529,6 +388,7 @@
<button class="btn btn-secondary btn-small" id="auditNextBtn" disabled>Weiter &rarr;</button>
</div>
</div>
<div class="filter-bar" id="auditFilterBar"></div>
<div class="card">
<div class="table-wrap">
<table>
@@ -562,10 +422,7 @@
<label for="newOrgName">Name</label>
<input type="text" id="newOrgName" required>
</div>
<div class="form-group">
<label for="newOrgSlug">Slug (URL-freundlich)</label>
<input type="text" id="newOrgSlug" required pattern="[a-z0-9-]+" placeholder="z.B. bundespolizei">
</div>
<input type="hidden" id="newOrgSlug">
<div class="form-group">
<label for="newOrgLanguage">Pipeline-Sprache</label>
<select id="newOrgLanguage">
@@ -574,6 +431,53 @@
</select>
<small class="text-secondary">Steuert die Ausgabesprache der KI-Pipeline (Lagebild, Faktencheck, Recherche) und die sichtbarsten UI-Strings im Monitor.</small>
</div>
<div class="form-group" style="background:var(--bg-tertiary,#0f172a); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="checkbox" id="newOrgWithLicense" checked onchange="onNewOrgLicenseToggle()">
<span><strong>Direkt eine Lizenz mit Credits-Kontingent anlegen</strong></span>
</label>
</div>
<div id="newOrgLicenseFields">
<div class="form-group">
<label for="newOrgLicType">Lizenztyp</label>
<select id="newOrgLicType" onchange="onNewOrgLicTypeChange()">
<option value="trial" selected>Trial</option>
<option value="annual">Jahreslizenz</option>
<option value="permanent">Permanent</option>
</select>
</div>
<div class="form-group">
<label for="newOrgLicMaxUsers">Maximale Nutzer</label>
<input type="number" id="newOrgLicMaxUsers" value="5" min="1" max="1000">
</div>
<div class="form-group" id="newOrgLicDurationGroup">
<label for="newOrgLicDuration">Laufzeit (Tage)</label>
<input type="number" id="newOrgLicDuration" value="14" min="1" max="3650">
<div class="text-muted mt-8" style="font-size: 12px;">Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage</div>
</div>
<div class="form-group" style="background:var(--bg-tertiary,#0f172a); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="checkbox" id="newOrgLicUnlimited" onchange="onNewOrgLicUnlimitedToggle()">
<span><strong>Unbegrenztes Budget</strong> &mdash; getrackt, aber kein Hard-Stop</span>
</label>
</div>
<div class="form-group">
<label for="newOrgLicCreditsTotal">Credits-Kontingent</label>
<input type="number" id="newOrgLicCreditsTotal" value="10000" placeholder="z.B. 10000">
</div>
<div class="form-group">
<label for="newOrgLicCreditsPeriod">Abrechnungszeitraum</label>
<select id="newOrgLicCreditsPeriod">
<option value="monthly" selected>Monatlich (Kontingent füllt sich zum Monatswechsel neu)</option>
<option value="total">Gesamtlaufzeit</option>
</select>
</div>
<div class="form-group">
<label for="newOrgLicWarnPercent">Warnschwelle (%, 0 = aus)</label>
<input type="number" id="newOrgLicWarnPercent" value="80" min="0" max="100">
</div>
</div>
<div id="newOrgError" class="error-msg" style="display:none"></div>
</div>
<div class="modal-footer">
@@ -649,15 +553,18 @@
</div>
<div class="form-group">
<label for="newLicCreditsTotal">Credits-Kontingent</label>
<input type="number" id="newLicCreditsTotal" placeholder="z.B. 600000">
<input type="number" id="newLicCreditsTotal" value="10000" placeholder="z.B. 10000">
</div>
<div class="form-group">
<label for="newLicCostPerCredit">Kosten pro Credit (USD)</label>
<input type="number" id="newLicCostPerCredit" step="0.0001" placeholder="z.B. 0.0033">
<label for="newLicCreditsPeriod">Abrechnungszeitraum</label>
<select id="newLicCreditsPeriod">
<option value="monthly" selected>Monatlich (Kontingent füllt sich zum Monatswechsel neu)</option>
<option value="total">Gesamtlaufzeit</option>
</select>
</div>
<div class="form-group">
<label for="newLicBudgetUsd">Budget-Limit (USD)</label>
<input type="number" id="newLicBudgetUsd" step="0.01" placeholder="z.B. 2000">
<label for="newLicWarnPercent">Warnschwelle (%, 0 = aus)</label>
<input type="number" id="newLicWarnPercent" value="80" min="0" max="100">
</div>
<div id="newLicError" class="error-msg" style="display:none"></div>
</div>
@@ -670,6 +577,53 @@
</div>
<!-- Modal: Organisation umbenennen -->
<div class="modal-overlay" id="modalRenameOrg">
<div class="modal">
<div class="modal-header">
<h3>Organisation umbenennen</h3>
<button class="modal-close" onclick="closeModal('modalRenameOrg')">&times;</button>
</div>
<form id="renameOrgForm">
<div class="modal-body">
<div class="form-group">
<label for="renameOrgName">Name</label>
<input type="text" id="renameOrgName" required maxlength="200">
</div>
<div id="renameOrgError" class="error-msg" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modalRenameOrg')">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<!-- Modal: Lizenz verlängern (ersetzt das alte Browser-prompt) -->
<div class="modal-overlay" id="modalExtendLicense">
<div class="modal">
<div class="modal-header">
<h3>Lizenz verlängern</h3>
<button class="modal-close" onclick="closeModal('modalExtendLicense')">&times;</button>
</div>
<form id="extendLicenseForm">
<div class="modal-body">
<div class="form-group">
<label for="extendDays">Um wie viele Tage verlängern?</label>
<input type="number" id="extendDays" value="365" min="1" max="3650" required>
<div class="text-muted mt-8" style="font-size:12px;">Die Verlängerung setzt am aktuellen Ablaufdatum an und aktiviert die Lizenz, falls sie abgelaufen war.</div>
</div>
<div id="extendLicenseError" class="error-msg" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modalExtendLicense')">Abbrechen</button>
<button type="submit" class="btn btn-primary">Verlängern</button>
</div>
</form>
</div>
</div>
<!-- Modal: Source (Create/Edit) -->
<div class="modal-overlay" id="modalSource">
<div class="modal">
@@ -694,38 +648,11 @@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-group">
<label for="sourceType">Typ</label>
<select id="sourceType">
<option value="rss_feed">RSS-Feed</option>
<option value="web_source">Webquelle</option>
<option value="telegram_channel">Telegram-Kanal</option>
<option value="podcast_feed">Podcast-Feed</option>
<option value="excluded">Ausgeschlossen</option>
<option value="pdf_document" disabled>PDF-Dokument (nur Upload)</option>
</select>
<select id="sourceType"></select>
</div>
<div class="form-group">
<label for="sourceCategory">Kategorie</label>
<select id="sourceCategory">
<option value="nachrichtenagentur">Nachrichtenagentur</option>
<option value="oeffentlich-rechtlich">Öffentlich-Rechtlich</option>
<option value="qualitaetszeitung">Qualitätszeitung</option>
<option value="behoerde">Behörde</option>
<option value="fachmedien">Fachmedien</option>
<option value="think-tank">Think-Tank</option>
<option value="international">International</option>
<option value="regional">Regional</option>
<option value="boulevard">Boulevard</option>
<option value="sonstige" selected>Sonstige</option>
<option value="cybercrime">Cybercrime / Hacktivismus</option>
<option value="cybercrime-leaks">Cybercrime / Leaks</option>
<option value="ukraine-russland-krieg">Ukraine-Russland-Krieg</option>
<option value="irankonflikt">Irankonflikt</option>
<option value="osint-international">OSINT International</option>
<option value="extremismus-deutschland">Extremismus Deutschland</option>
<option value="russische-staatspropaganda">Russische Staatspropaganda</option>
<option value="russische-opposition">Russische Opposition / Exilmedien</option>
<option value="syrien-nahost">Syrien / Nahost</option>
</select>
<select id="sourceCategory"></select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
@@ -830,23 +757,6 @@
</label>
</div>
</div>
<div class="form-group" style="margin-top:8px;">
<label>Geopolitische Nähe (Mehrfachauswahl)</label>
<div id="sourceAlignmentChips" class="alignment-chips" onclick="handleAlignmentChipClick(event)">
<button type="button" class="alignment-chip" data-alignment="prorussisch">prorussisch</button>
<button type="button" class="alignment-chip" data-alignment="proiranisch">proiranisch</button>
<button type="button" class="alignment-chip" data-alignment="prowestlich">prowestlich</button>
<button type="button" class="alignment-chip" data-alignment="proukrainisch">proukrainisch</button>
<button type="button" class="alignment-chip" data-alignment="prochinesisch">prochinesisch</button>
<button type="button" class="alignment-chip" data-alignment="projapanisch">projapanisch</button>
<button type="button" class="alignment-chip" data-alignment="proisraelisch">proisraelisch</button>
<button type="button" class="alignment-chip" data-alignment="propalaestinensisch">propalästinensisch</button>
<button type="button" class="alignment-chip" data-alignment="protuerkisch">protürkisch</button>
<button type="button" class="alignment-chip" data-alignment="panarabisch">panarabisch</button>
<button type="button" class="alignment-chip" data-alignment="neutral">neutral</button>
<button type="button" class="alignment-chip" data-alignment="sonstige">sonstige</button>
</div>
</div>
</div>
<div id="sourceError" class="error-msg" style="display:none"></div>
@@ -884,13 +794,7 @@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-group">
<label for="pdfCategory">Kategorie</label>
<select id="pdfCategory">
<option value="sonstige" selected>Sonstige</option>
<option value="behoerde">Behörde</option>
<option value="think-tank">Think-Tank</option>
<option value="fachmedien">Fachmedien</option>
<option value="international">International</option>
</select>
<select id="pdfCategory"></select>
</div>
<div class="form-group">
<label for="pdfLanguage">Sprache (optional)</label>
@@ -965,7 +869,7 @@
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="confirmCancelBtn" onclick="closeModal('modalConfirm')">Abbrechen</button>
<button class="btn btn-danger" id="confirmOkBtn">Bestätigen</button>
<button class="btn btn-primary" id="confirmOkBtn">Bestätigen</button>
</div>
</div>
</div>
@@ -1038,11 +942,12 @@
</div>
</div>
<script src="/static/js/app.js?v=20260522a"></script>
<script src="/static/js/sources.js?v=20260522x2"></script>
<script src="/static/js/x-scraper.js?v=20260522a"></script>
<script src="/static/js/source-health.js?v=20260509l"></script>
<script src="/static/js/audit.js?v=20260509d"></script>
<script src="/static/js/a11y.js?v=20260725a"></script>
<script src="/static/js/app.js?v=20260725y"></script>
<script src="/static/js/sources.js?v=20260725h"></script>
<script src="/static/js/aufgaben.js?v=20260725d"></script>
<script src="/static/js/x-scraper.js?v=20260725c"></script>
<script src="/static/js/audit.js?v=20260725a"></script>
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
</body>
</html>

Datei anzeigen

@@ -7,12 +7,14 @@
<title>AegisSight Monitor-Verwaltung - Anmeldung</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="apple-touch-icon" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css?v=20260509d">
<link rel="stylesheet" href="/static/css/style.css?v=20260725a">
<script>(function(){if(localStorage.getItem('portal_theme')==='light')document.documentElement.setAttribute('data-theme','light');})()</script>
</head>
<body class="login-page">
<div class="login-container">
<div class="login-card">
<div class="login-header">
<span class="logo-plaque" style="display:inline-flex; margin-bottom:12px;"><img src="/static/favicon.svg" alt="AegisSight Logo" style="height:44px;"></span>
<h1>AegisSight</h1>
<p class="subtitle">Monitor-Verwaltung</p>
</div>
@@ -26,7 +28,7 @@
</div>
<div id="loginError" class="error-msg" style="display:none"></div>
<button type="submit" class="btn btn-primary btn-full" id="magicBtn">Login-Link anfordern</button>
<p class="form-hint" style="margin-top:14px;text-align:center;font-size:12px;color:#94a3b8;">
<p class="form-hint" style="margin-top:14px;text-align:center;font-size:12px;color:var(--text-secondary);">
Wir senden dir einen einmaligen Login-Link per E-Mail.
</p>
</form>
@@ -35,7 +37,7 @@
<div id="sentInfo" style="display:none;text-align:center;">
<div style="font-size:42px;margin:8px 0 16px 0;">&#9993;</div>
<h2 style="font-size:17px;margin:0 0 8px 0;">E-Mail unterwegs</h2>
<p style="margin:0 0 18px 0;font-size:14px;color:#94a3b8;line-height:1.5;">
<p style="margin:0 0 18px 0;font-size:14px;color:var(--text-secondary);line-height:1.5;">
Wenn die Adresse berechtigt ist, hast du gleich einen Login-Link in deinem Posteingang.
Der Link ist 10 Minuten gültig.
</p>
@@ -46,7 +48,7 @@
<div id="verifying" style="display:none;text-align:center;">
<div class="spinner" style="margin:8px auto 16px;"></div>
<h2 style="font-size:17px;margin:0 0 8px 0;">Anmeldung wird geprüft...</h2>
<p style="margin:0;font-size:14px;color:#94a3b8;">Einen Moment bitte.</p>
<p style="margin:0;font-size:14px;color:var(--text-secondary);">Einen Moment bitte.</p>
</div>
</div>
</div>
@@ -54,13 +56,13 @@
<style>
.spinner {
width: 36px; height: 36px;
border: 3px solid rgba(240,180,41,0.2);
border-top-color: #f0b429;
border: 3px solid rgba(200,168,81,0.2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.form-hint { font-size: 12px; color: #94a3b8; margin-top: 8px; }
.form-hint { font-size: 12px; color: var(--text-secondary); margin-top: 8px; }
</style>
<script>

162
src/static/js/a11y.js Normale Datei
Datei anzeigen

@@ -0,0 +1,162 @@
/**
* Barrierefreiheits-Manager: Panel mit 4 Schaltern (Kontrast, Focus, Schrift, Animationen).
*
* Liegt seit 2026-07-21 in einer eigenen Datei, weil ihn beide Oberflaechen brauchen,
* das klassische Dashboard (.header-right) und die Studio-Ansicht (.studio-top-right).
* Vorher steckte er in app.js und fehlte im Studio deshalb komplett.
*/
const A11yManager = {
_key: 'osint_a11y',
_isOpen: false,
_settings: { contrast: false, focus: false, fontsize: false, motion: false },
init() {
// Einstellungen aus localStorage laden
try {
const saved = JSON.parse(localStorage.getItem(this._key) || '{}');
Object.keys(this._settings).forEach(k => {
if (typeof saved[k] === 'boolean') this._settings[k] = saved[k];
});
} catch (e) { /* Ungültige Daten ignorieren */ }
// Button + Panel dynamisch in die Kopfzeile einfügen (vor Theme-Toggle).
// Beide Oberflaechen benennen ihren rechten Kopfzeilen-Block anders.
const headerRight = document.querySelector('.header-right, .studio-top-right');
const themeToggle = document.getElementById('theme-toggle');
if (!headerRight) return;
if (document.getElementById('a11y-btn')) return; // schon vorhanden
const container = document.createElement('div');
container.className = 'a11y-center';
container.innerHTML = `
<button class="a11y-btn" id="a11y-btn" title="Barrierefreiheit"
aria-label="Barrierefreiheit" aria-expanded="false" aria-haspopup="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="4" r="2"/>
<path d="M12 8c-3.3 0-6 .5-6 .5v2s2.7-.5 5-.5v3l-3 7h2.5l2.5-5.5 2.5 5.5h2.5l-3-7v-3c2.3 0 5 .5 5 .5v-2S15.3 8 12 8z"/>
</svg>
</button>
<div class="a11y-panel" id="a11y-panel" role="group" aria-label="Barrierefreiheits-Einstellungen" style="display:none;">
<div class="a11y-panel-title">Barrierefreiheit</div>
<label class="a11y-option">
<input type="checkbox" id="a11y-contrast">
<span class="toggle-switch"></span>
<span>Hoher Kontrast</span>
</label>
<label class="a11y-option">
<input type="checkbox" id="a11y-focus">
<span class="toggle-switch"></span>
<span>Verstärkte Focus-Anzeige</span>
</label>
<label class="a11y-option">
<input type="checkbox" id="a11y-fontsize">
<span class="toggle-switch"></span>
<span>Größere Schrift</span>
</label>
<label class="a11y-option">
<input type="checkbox" id="a11y-motion">
<span class="toggle-switch"></span>
<span>Animationen aus</span>
</label>
</div>
`;
if (themeToggle && themeToggle.parentNode === headerRight) {
headerRight.insertBefore(container, themeToggle);
} else {
headerRight.prepend(container);
}
// Toggle-Event-Listener
['contrast', 'focus', 'fontsize', 'motion'].forEach(key => {
document.getElementById('a11y-' + key).addEventListener('change', () => this.toggle(key));
});
// Button öffnet/schließt Panel
document.getElementById('a11y-btn').addEventListener('click', (e) => {
e.stopPropagation();
this._isOpen ? this._closePanel() : this._openPanel();
});
// Klick außerhalb schließt Panel
document.addEventListener('click', (e) => {
if (this._isOpen && !container.contains(e.target)) {
this._closePanel();
}
});
// Keyboard: Esc schließt, Pfeiltasten navigieren
container.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this._isOpen) {
e.stopPropagation();
this._closePanel();
return;
}
if (!this._isOpen) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const options = Array.from(document.querySelectorAll('.a11y-option input[type="checkbox"]'));
const idx = options.indexOf(document.activeElement);
let next;
if (e.key === 'ArrowDown') {
next = idx < options.length - 1 ? idx + 1 : 0;
} else {
next = idx > 0 ? idx - 1 : options.length - 1;
}
options[next].focus();
}
});
// Einstellungen anwenden + Checkboxen synchronisieren
this._apply();
this._syncUI();
},
toggle(key) {
this._settings[key] = !this._settings[key];
this._apply();
this._syncUI();
this._save();
},
_apply() {
const root = document.documentElement;
Object.keys(this._settings).forEach(k => {
if (this._settings[k]) {
root.setAttribute('data-a11y-' + k, 'true');
} else {
root.removeAttribute('data-a11y-' + k);
}
});
},
_syncUI() {
Object.keys(this._settings).forEach(k => {
const cb = document.getElementById('a11y-' + k);
if (cb) cb.checked = this._settings[k];
});
},
_save() {
localStorage.setItem(this._key, JSON.stringify(this._settings));
},
_openPanel() {
this._isOpen = true;
document.getElementById('a11y-panel').style.display = '';
document.getElementById('a11y-btn').setAttribute('aria-expanded', 'true');
// Fokus auf erste Option setzen
requestAnimationFrame(() => {
const first = document.querySelector('.a11y-option input[type="checkbox"]');
if (first) first.focus();
});
},
_closePanel() {
this._isOpen = false;
document.getElementById('a11y-panel').style.display = 'none';
const btn = document.getElementById('a11y-btn');
btn.setAttribute('aria-expanded', 'false');
btn.focus();
}
};

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@@ -5,6 +5,9 @@ let auditCache = { items: [], total: 0, offset: 0, limit: 200 };
let auditDistinct = { actions: [], resource_types: [], admins: [] };
let expandedRows = new Set();
// Checkbox-Filter (Mehrfachauswahl, leeres Set = keine Einschränkung)
const auditFilters = { actions: new Set(), resources: new Set(), admins: new Set() };
const ACTION_LABELS = {
create: "Erstellt",
update: "Geändert",
@@ -37,20 +40,22 @@ document.addEventListener("DOMContentLoaded", () => {
tab.addEventListener("click", () => loadAudit());
});
// Filter-Inputs verdrahten
["auditFilterAction", "auditFilterResource", "auditFilterResourceId", "auditFilterAdmin",
"auditFilterFrom", "auditFilterTo"].forEach((id) => {
// Filter-Inputs verdrahten (Checkbox-Gruppen laufen über renderAuditFilterBar)
["auditFilterResourceId", "auditFilterFrom", "auditFilterTo"].forEach((id) => {
const el = document.getElementById(id);
if (el) el.addEventListener("change", () => { auditCache.offset = 0; loadAudit(); });
});
const reset = document.getElementById("auditFilterReset");
if (reset) reset.addEventListener("click", () => {
["auditFilterAction", "auditFilterResource", "auditFilterAdmin",
"auditFilterFrom", "auditFilterTo"].forEach((id) => {
auditFilters.actions.clear();
auditFilters.resources.clear();
auditFilters.admins.clear();
["auditFilterResourceId", "auditFilterFrom", "auditFilterTo"].forEach((id) => {
const el = document.getElementById(id);
if (el) el.value = "";
});
renderAuditFilterBar();
auditCache.offset = 0;
loadAudit();
});
@@ -70,57 +75,49 @@ document.addEventListener("DOMContentLoaded", () => {
});
async function loadAuditDistinct() {
// Distinct-Werte nur einmal laden, die Leiste nur bei Bedarf neu aufbauen
// (sonst wuerde jeder Checkbox-Klick die Leiste flackern lassen).
if (auditDistinct.actions && auditDistinct.actions.length) {
const bar = document.getElementById("auditFilterBar");
if (bar && !bar.children.length) renderAuditFilterBar();
return;
}
try {
auditDistinct = await API.get("/api/audit-log/distinct");
const actSel = document.getElementById("auditFilterAction");
if (actSel && actSel.options.length <= 1) {
auditDistinct.actions.forEach((a) => {
const opt = document.createElement("option");
opt.value = a;
opt.textContent = ACTION_LABELS[a] || a;
actSel.appendChild(opt);
});
}
const resSel = document.getElementById("auditFilterResource");
if (resSel && resSel.options.length <= 1) {
auditDistinct.resource_types.forEach((r) => {
const opt = document.createElement("option");
opt.value = r;
opt.textContent = RESOURCE_LABELS[r] || r;
resSel.appendChild(opt);
});
}
const admSel = document.getElementById("auditFilterAdmin");
if (admSel && admSel.options.length <= 1) {
auditDistinct.admins.forEach((a) => {
const opt = document.createElement("option");
opt.value = a.id;
opt.textContent = a.username;
admSel.appendChild(opt);
});
}
renderAuditFilterBar();
} catch (err) {
console.error("Audit-Filter laden fehlgeschlagen:", err);
}
}
// Checkbox-Filterleiste (Aktion, Ressource, Admin) aus den Distinct-Werten
function renderAuditFilterBar() {
const bar = document.getElementById("auditFilterBar");
if (!bar) return;
bar.innerHTML = "";
const onChange = () => { auditCache.offset = 0; loadAudit(); };
renderFilterGroup(bar, "Aktion",
(auditDistinct.actions || []).map((a) => ({ key: a, label: ACTION_LABELS[a] || a })),
auditFilters.actions, onChange);
renderFilterGroup(bar, "Ressource",
(auditDistinct.resource_types || []).map((r) => ({ key: r, label: RESOURCE_LABELS[r] || r })),
auditFilters.resources, onChange);
renderFilterGroup(bar, "Admin",
(auditDistinct.admins || []).map((a) => ({ key: String(a.id), label: a.username })),
auditFilters.admins, onChange);
}
async function loadAudit() {
await loadAuditDistinct();
const params = new URLSearchParams();
const action = document.getElementById("auditFilterAction")?.value;
const resource = document.getElementById("auditFilterResource")?.value;
const resourceId = document.getElementById("auditFilterResourceId")?.value;
const adminId = document.getElementById("auditFilterAdmin")?.value;
const from = document.getElementById("auditFilterFrom")?.value;
const to = document.getElementById("auditFilterTo")?.value;
if (action) params.append("action", action);
if (resource) params.append("resource_type", resource);
if (auditFilters.actions.size) params.append("action", [...auditFilters.actions].join(","));
if (auditFilters.resources.size) params.append("resource_type", [...auditFilters.resources].join(","));
if (auditFilters.admins.size) params.append("admin_id", [...auditFilters.admins].join(","));
if (resourceId) params.append("resource_id", resourceId);
if (adminId) params.append("admin_id", adminId);
if (from) params.append("from_ts", from);
if (to) params.append("to_ts", to);
params.append("limit", auditCache.limit);

560
src/static/js/aufgaben.js Normale Datei
Datei anzeigen

@@ -0,0 +1,560 @@
/* Aufgaben-Posteingang: Health-Vorschläge + Klassifikations-Review + Verlauf */
"use strict";
let suggestionsCache = [];
let healthHistoryCache = [];
let classificationQueueCache = [];
// Auswahl für die Sammel-Bearbeitung der Vorschläge
let aufgabenSelected = new Set();
// Checkbox-Filter des Aufgaben-Reiters. Leeres Set = keine Einschränkung.
const aufgabenFilters = { types: new Set(), prio: new Set(), conf: new Set() };
// Konfidenz-Stufen für den Klassifikations-Filter (Mehrfachauswahl)
const CONF_BUCKETS = [
{ key: "b85", label: "ab 85 %", test: (c) => c >= 0.85 },
{ key: "b70", label: "70 bis 85 %", test: (c) => c >= 0.7 && c < 0.85 },
{ key: "b50", label: "50 bis 70 %", test: (c) => c >= 0.5 && c < 0.7 },
{ key: "b0", label: "unter 50 %", test: (c) => c < 0.5 },
];
const SUGGESTION_TYPE_LABELS = {
add_source: "Neue Quelle",
deactivate_source: "Deaktivieren",
remove_source: "Entfernen",
fix_url: "URL korrigieren",
};
const PRIORITY_LABELS = {
high: "Hoch",
medium: "Mittel",
low: "Niedrig",
};
// Lucide-Icons als Inline-SVG-Konstanten (statt CDN-Abhängigkeit oder Emojis).
// 14x14, currentColor erbt vom Button-Style.
const LUCIDE_ICONS = {
check: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><polyline points="20 6 9 17 4 12"/></svg>',
x: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
search:'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
};
// --- Badge (Summe offene Vorschläge + ausstehende Klassifikationen) ---
async function refreshTasksBadge() {
try {
const s = await API.get("/api/sources/tasks/summary");
const badge = document.getElementById("tasksPendingBadge");
if (badge) badge.textContent = String(s.total || 0);
} catch (_) { /* Badge ist nicht kritisch */ }
}
// Badge schon beim Seitenstart füllen, nicht erst beim Öffnen des Quellen-Reiters
document.addEventListener("DOMContentLoaded", () => {
if (window.API && localStorage.getItem("token")) refreshTasksBadge();
});
// --- Aufgaben laden ---
async function loadAufgaben() {
renderAufgabenFilters();
try {
const [suggestions, history] = await Promise.all([
API.get("/api/sources/suggestions"),
API.get("/api/sources/health/history?limit=10").catch(() => []),
]);
suggestionsCache = suggestions || [];
healthHistoryCache = history || [];
aufgabenSelected.clear();
renderAufgabenSuggestions();
renderAufgabenVerlauf();
} catch (err) {
console.error("Aufgaben laden fehlgeschlagen:", err);
}
loadClassificationQueue();
refreshTasksBadge();
}
// Checkbox-Filterleiste des Aufgaben-Reiters
function renderAufgabenFilters() {
const bar = document.getElementById("aufgabenFilterBar");
if (!bar) return;
bar.innerHTML = "";
renderFilterGroup(bar, "Vorschlagstyp",
Object.entries(SUGGESTION_TYPE_LABELS).map(([key, label]) => ({ key, label })),
aufgabenFilters.types, renderAufgabenSuggestions);
renderFilterGroup(bar, "Priorität",
Object.entries(PRIORITY_LABELS).map(([key, label]) => ({ key, label })),
aufgabenFilters.prio, renderAufgabenSuggestions);
renderFilterGroup(bar, "Konfidenz", CONF_BUCKETS.map(b => ({ key: b.key, label: b.label })),
aufgabenFilters.conf, renderClassificationList);
}
function renderAufgabenSuggestions() {
const pane = document.getElementById("aufgabenSuggestions");
if (!pane) return;
const allPending = suggestionsCache.filter((s) => s.status === "pending");
const countEl = document.getElementById("aufgabenSuggestionCount");
if (countEl) countEl.textContent = String(allPending.length);
const f = aufgabenFilters;
const pending = allPending.filter((s) => {
if (f.types.size && !f.types.has(s.suggestion_type)) return false;
if (f.prio.size && !f.prio.has(s.priority)) return false;
return true;
});
if (allPending.length === 0) {
pane.innerHTML = `
<div class="card" style="margin-bottom:16px;">
<div class="card-header"><h2>Vorschläge</h2></div>
<div class="card-body text-muted">Keine offenen Vorschläge vorhanden.</div>
</div>`;
return;
}
if (pending.length === 0) {
pane.innerHTML = `
<div class="card" style="margin-bottom:16px;">
<div class="card-header"><h2>Vorschläge (0 von ${allPending.length})</h2></div>
<div class="card-body text-muted">Keine Vorschläge mit diesen Filtern.</div>
</div>`;
return;
}
// Auswahl auf die sichtbaren (gefilterten) Zeilen begrenzen, damit nie
// etwas Unsichtbares mitbearbeitet wird
const visibleIds = new Set(pending.map((s) => s.id));
aufgabenSelected.forEach((id) => { if (!visibleIds.has(id)) aufgabenSelected.delete(id); });
const headCount = pending.length === allPending.length
? `${allPending.length} offen`
: `${pending.length} von ${allPending.length}`;
pane.innerHTML = `
<div class="card" style="margin-bottom:16px;">
<div class="card-header">
<h2>Vorschläge (${headCount})</h2>
<div style="display:flex;gap:8px;">
<button class="btn btn-primary btn-small" id="suggBulkAcceptBtn" onclick="bulkSuggestions(true)" disabled>Ausgewählte annehmen (0)</button>
<button class="btn btn-secondary btn-small" id="suggBulkRejectBtn" onclick="bulkSuggestions(false)" disabled>Ausgewählte ablehnen (0)</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:34px;"><input type="checkbox" id="aufgabenSelectAll" onchange="toggleSuggSelectAll(this.checked)" title="Alle angezeigten auswählen"></th>
<th>Typ</th>
<th>Titel</th>
<th>Beschreibung</th>
<th>Priorität</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
${pending
.map(
(s) => `
<tr>
<td><input type="checkbox" class="sugg-select" data-id="${s.id}" ${aufgabenSelected.has(s.id) ? "checked" : ""} onchange="toggleSuggSelect(${s.id}, this.checked)"></td>
<td><span class="badge badge-suggestion-${s.suggestion_type}">${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type}</span></td>
<td>${esc(s.title)}</td>
<td class="text-secondary" style="max-width:420px; white-space:normal; overflow-wrap:anywhere;">${esc(s.description || "")}</td>
<td><span class="badge badge-priority-${s.priority}">${PRIORITY_LABELS[s.priority] || s.priority}</span></td>
<td class="text-secondary">${formatDateTime(s.created_at)}</td>
<td style="white-space:nowrap;">
${s.suggestion_type === "deactivate_source" && s.source_id ? `<button class="btn btn-secondary btn-small" data-source-id="${s.source_id}" data-source-name="${esc(s.title.split(':')[0] || s.title)}" onclick="searchFix(this)" title="Lösung suchen">${LUCIDE_ICONS.search}</button> ` : ""}
<button class="btn btn-success btn-small" onclick="handleSuggestion(${s.id}, true)" title="Annehmen">${LUCIDE_ICONS.check}</button>
<button class="btn btn-danger btn-small" onclick="handleSuggestion(${s.id}, false)" title="Ablehnen">${LUCIDE_ICONS.x}</button>
</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</div>
</div>`;
updateSuggBulkButtons();
}
// --- Sammel-Bearbeitung der Vorschläge ---
function toggleSuggSelectAll(checked) {
document.querySelectorAll("#aufgabenSuggestions input.sugg-select").forEach((cb) => {
cb.checked = checked;
const id = parseInt(cb.dataset.id);
if (checked) aufgabenSelected.add(id); else aufgabenSelected.delete(id);
});
updateSuggBulkButtons();
}
function toggleSuggSelect(id, checked) {
id = parseInt(id);
if (checked) aufgabenSelected.add(id); else aufgabenSelected.delete(id);
const visible = document.querySelectorAll("#aufgabenSuggestions input.sugg-select").length;
const checkedVisible = document.querySelectorAll("#aufgabenSuggestions input.sugg-select:checked").length;
const all = document.getElementById("aufgabenSelectAll");
if (all) all.checked = visible > 0 && visible === checkedVisible;
updateSuggBulkButtons();
}
function updateSuggBulkButtons() {
const n = aufgabenSelected.size;
const a = document.getElementById("suggBulkAcceptBtn");
const r = document.getElementById("suggBulkRejectBtn");
if (a) { a.disabled = n === 0; a.textContent = `Ausgewählte annehmen (${n})`; }
if (r) { r.disabled = n === 0; r.textContent = `Ausgewählte ablehnen (${n})`; }
}
async function bulkSuggestions(accept) {
if (aufgabenSelected.size === 0) return;
const ids = Array.from(aufgabenSelected);
const text = accept
? `Sollen ${ids.length} Vorschläge angenommen werden? Die Aktionen werden sofort ausgeführt, z.B. Quellen deaktiviert, angelegt oder URLs korrigiert.`
: `Sollen ${ids.length} Vorschläge abgelehnt werden? Sie wandern in den Verlauf. Beim nächsten Prüflauf können sie erneut vorgeschlagen werden.`;
const ok = await showConfirm(accept ? "Ausgewählte annehmen" : "Ausgewählte ablehnen", text);
if (!ok) return;
try {
const r = await API.put("/api/sources/suggestions/bulk", { suggestion_ids: ids, accept: accept });
let msg = accept ? `${r.accepted} angenommen` : `${r.rejected} abgelehnt`;
if (accept && r.rejected) msg += `, ${r.rejected} nicht ausführbar (abgelehnt)`;
if (r.skipped) msg += `, ${r.skipped} bereits erledigt`;
showToast(msg + ".", "success");
aufgabenSelected.clear();
loadAufgaben();
if (typeof loadUnifiedSources === "function") loadUnifiedSources();
} catch (err) {
showToast("Sammel-Aktion fehlgeschlagen. " + err.message, "error");
}
}
function renderAufgabenVerlauf() {
const pane = document.getElementById("aufgabenVerlauf");
if (!pane) return;
const recent = suggestionsCache.filter((s) => s.status !== "pending");
let html = "";
if (recent.length > 0) {
const shown = recent.slice(0, 20);
html += `
<details class="card" style="margin-bottom:16px;">
<summary style="cursor:pointer; padding:14px 18px; list-style:none;">
<span style="color:var(--accent, #C8A851); font-weight:600; font-size:1.02rem;">Erledigte Vorschläge</span>
<span class="text-secondary" style="font-size:13px; margin-left:8px;">(${recent.length} Einträge, klick zum Aufklappen)</span>
</summary>
<div class="table-wrap" style="border-top:1px solid var(--border, rgba(255,255,255,0.08));">
<table>
<thead>
<tr><th>Typ</th><th>Titel</th><th>Status</th><th>Bearbeitet</th></tr>
</thead>
<tbody>
${shown
.map(
(s) => `
<tr>
<td><span class="badge badge-suggestion-${s.suggestion_type}">${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type}</span></td>
<td>${esc(s.title)}</td>
<td><span class="badge badge-${s.status === "accepted" ? "active" : "inactive"}">${s.status === "accepted" ? "Angenommen" : "Abgelehnt"}</span></td>
<td class="text-secondary">${formatDateTime(s.reviewed_at)}</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</div>
</details>`;
}
if (healthHistoryCache.length > 0) {
html += `
<details class="card" style="margin-bottom:16px;">
<summary style="cursor:pointer; padding:14px 18px; list-style:none;">
<span style="color:var(--accent, #C8A851); font-weight:600; font-size:1.02rem;">Letzte Prüfläufe</span>
<span class="text-secondary" style="font-size:13px; margin-left:8px;">(${healthHistoryCache.length} Läufe, klick zum Aufklappen)</span>
</summary>
<div class="table-wrap" style="border-top:1px solid var(--border, rgba(255,255,255,0.08));">
<table style="table-layout:fixed; width:100%;">
<colgroup>
<col style="width:200px;">
<col style="width:160px;">
<col style="width:110px;">
<col style="width:130px;">
<col style="width:110px;">
</colgroup>
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Run-ID</th>
<th style="text-align:center;">Fehler</th>
<th style="text-align:center;">Warnungen</th>
<th style="text-align:center;">OK</th>
</tr>
</thead>
<tbody>
${healthHistoryCache.map(r => `
<tr>
<td>${formatDateTime(r.archived_at)}</td>
<td class="text-secondary" style="font-size:12px;" title="${esc(r.run_id)}"><code>${esc(String(r.run_id || "").slice(0, 12))}</code></td>
<td class="text-danger" style="text-align:center;">${r.errors || 0}</td>
<td class="text-warning" style="text-align:center;">${r.warnings || 0}</td>
<td class="text-success" style="text-align:center;">${r.ok || 0}</td>
</tr>`).join("")}
</tbody>
</table>
</div>
</details>`;
}
pane.innerHTML = html;
}
// --- Vorschlag annehmen/ablehnen ---
async function handleSuggestion(id, accept) {
const action = accept ? "annehmen" : "ablehnen";
const suggestion = suggestionsCache.find((s) => s.id === id);
if (!suggestion) return;
const ok = await showConfirm("Vorschlag " + (action === "annehmen" ? "annehmen" : "ablehnen"), `Soll "${suggestion.title}" ${action}?`);
if (!ok) return;
try {
const result = await API.put("/api/sources/suggestions/" + id, { accept });
if (result.action) {
showToast("Ergebnis. " + result.action, "success");
}
loadAufgaben();
// Quellenliste auch aktualisieren
if (typeof loadUnifiedSources === "function") loadUnifiedSources();
} catch (err) {
showToast("Fehler. " + err.message, "error");
}
}
// --- Sonnet-Recherche für kaputte Quelle ---
async function searchFix(btn) {
const sourceId = btn.dataset.sourceId;
const sourceName = btn.dataset.sourceName;
const ok = await showConfirm("Lösung suchen", `Sonnet mit WebSearch nach einer Lösung für "${sourceName}" suchen lassen? Das kann einige Minuten dauern.`);
if (!ok) return;
btn.disabled = true;
btn.textContent = "Sucht...";
try {
const result = await API.post("/api/sources/health/search-fix/" + sourceId);
let msg = result.summary || "Keine Zusammenfassung";
if (result.solutions && result.solutions.length > 0) {
msg += "\n\nGefundene Lösungen als Vorschläge gespeichert.";
}
if (result.cost_usd) {
msg += `\n\nKosten: $${result.cost_usd.toFixed(2)}`;
}
showToast(msg, "info");
loadAufgaben();
} catch (err) {
showToast("Fehler. " + err.message, "error");
} finally {
btn.disabled = false;
btn.innerHTML = LUCIDE_ICONS.search; // Icon wiederherstellen, kein Text
}
}
// === Klassifikations-Review ===
const POLITICAL_LABELS = {
links_extrem: { short: "L+", full: "Links (extrem)" },
links: { short: "L", full: "Links" },
mitte_links: { short: "ML", full: "Mitte-Links" },
liberal: { short: "LIB", full: "Liberal" },
mitte: { short: "M", full: "Mitte" },
konservativ: { short: "KON", full: "Konservativ" },
mitte_rechts: { short: "MR", full: "Mitte-Rechts" },
rechts: { short: "R", full: "Rechts" },
rechts_extrem: { short: "R+", full: "Rechts (extrem)" },
na: { short: "?", full: "Nicht eingeordnet" },
};
const RELIABILITY_LABELS = {
sehr_hoch: "Sehr hoch", hoch: "Hoch", gemischt: "Gemischt",
niedrig: "Niedrig", sehr_niedrig: "Sehr niedrig", na: "Nicht eingeordnet",
};
const MEDIA_TYPE_LABELS = {
tageszeitung: "Tageszeitung", wochenzeitung: "Wochenzeitung", magazin: "Magazin",
tv_sender: "TV-Sender", radio: "Radio", oeffentlich_rechtlich: "Öffentlich-Rechtlich",
nachrichtenagentur: "Nachrichtenagentur", online_only: "Online-only", blog: "Blog",
telegram_kanal: "Telegram-Kanal", telegram_bot: "Telegram-Bot", podcast: "Podcast",
social_media: "Social Media", imageboard: "Imageboard", think_tank: "Think Tank",
ngo: "NGO", behoerde: "Behörde", staatsmedium: "Staatsmedium",
fachmedium: "Fachmedium", sonstige: "Sonstige",
};
async function loadClassificationQueue() {
const list = document.getElementById("classificationReviewList");
if (!list) return;
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Lade…</div>';
try {
classificationQueueCache = await API.get("/api/sources/classification/queue?limit=200&min_confidence=0");
renderClassificationList();
} catch (err) {
list.innerHTML = `<div class="text-danger" style="padding:24px;text-align:center;">Fehler. ${esc(err.message)}</div>`;
}
}
// Wendet den Konfidenz-Checkbox-Filter clientseitig auf die geladene Queue an
function renderClassificationList() {
const list = document.getElementById("classificationReviewList");
if (!list) return;
const all = classificationQueueCache || [];
const f = aufgabenFilters.conf;
const items = f.size
? all.filter((it) => {
const c = (it.proposed && it.proposed.confidence) || 0;
return CONF_BUCKETS.some((b) => f.has(b.key) && b.test(c));
})
: all;
const countEl = document.getElementById("reviewPendingCount");
if (countEl) countEl.textContent = String(all.length);
// Sammel-Genehmigung nur anbieten, wenn überhaupt etwas aussteht
const bulkBtn = document.getElementById("bulkApproveBtn");
if (bulkBtn) {
bulkBtn.disabled = all.length === 0;
bulkBtn.title = all.length === 0
? "Erst aktiv, wenn Klassifikations-Vorschläge ausstehen"
: "Alle Vorschläge ab 0.85 Konfidenz übernehmen";
}
if (all.length === 0) {
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Keine ausstehenden Klassifikationen.</div>';
return;
}
if (items.length === 0) {
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Keine Klassifikationen mit diesen Filtern.</div>';
return;
}
list.innerHTML = items.map((it) => renderClassificationQueueItem(it)).join("");
}
function renderClassificationQueueItem(item) {
const cur = item.current || {};
const prop = item.proposed || {};
const conf = prop.confidence || 0;
const confPct = Math.round(conf * 100);
const confClass = conf >= 0.85 ? "high" : conf >= 0.7 ? "medium" : "low";
const polFmt = (v) => (v && v !== "na" ? POLITICAL_LABELS[v]?.full || v : "–");
const mtFmt = (v) => (v ? MEDIA_TYPE_LABELS[v] || v : "–");
const relFmt = (v) => (v && v !== "na" ? RELIABILITY_LABELS[v] || v : "–");
const stateFmt = (v) => (v ? "ja" : "nein");
const ccFmt = (v) => v || "–";
const row = (label, c, p, fmt) => {
const cs = fmt(c);
const ps = fmt(p);
const changed = cs !== ps;
return `<div class="review-diff-row${changed ? " changed" : ""}">
<span class="review-diff-label">${esc(label)}</span>
<span class="review-diff-current">${esc(cs)}</span>
<span class="review-diff-arrow">→</span>
<span class="review-diff-proposed">${esc(ps)}</span>
</div>`;
};
const reasoning = prop.reasoning ? esc(prop.reasoning) : "";
return `<div class="review-card" data-source-id="${item.id}">
<div class="review-card-header">
<div class="review-card-title">
<span class="review-card-name">${esc(item.name)}</span>
${item.is_global ? '<span class="review-global-badge">Grundquelle</span>' : ""}
<span class="review-card-domain">${esc(item.domain || "")}</span>
</div>
<div class="review-card-confidence conf-${confClass}" title="LLM-Konfidenz">
<span class="conf-value">${confPct}%</span>
<span class="conf-label">Konfidenz</span>
</div>
</div>
<div class="review-card-diff">
${row("Politik", cur.political_orientation, prop.political_orientation, polFmt)}
${row("Medientyp", cur.media_type, prop.media_type, mtFmt)}
${row("Glaubwürdigkeit", cur.reliability, prop.reliability, relFmt)}
${row("Staatsnah", cur.state_affiliated, prop.state_affiliated, stateFmt)}
${row("Land", cur.country_code, prop.country_code, ccFmt)}
</div>
${reasoning ? `<div class="review-card-reasoning"><strong>Begründung.</strong> ${reasoning}</div>` : ""}
<div class="review-card-actions">
<button class="btn btn-small btn-primary" onclick="approveClassification(${item.id})">Übernehmen</button>
<button class="btn btn-small btn-secondary" onclick="rejectClassification(${item.id})">Verwerfen</button>
<button class="btn btn-small btn-secondary" data-reclassify-id="${item.id}" onclick="reclassifySource(${item.id})">Neu klassifizieren</button>
</div>
</div>`;
}
async function approveClassification(id) {
try {
await API.post(`/api/sources/${id}/classification/approve`, {});
showToast("Klassifikation übernommen.", "success");
loadClassificationQueue();
refreshTasksBadge();
} catch (err) {
showToast("Approve fehlgeschlagen. " + err.message, "error");
}
}
async function rejectClassification(id) {
try {
await API.post(`/api/sources/${id}/classification/reject`, {});
showToast("Vorschlag verworfen.", "success");
loadClassificationQueue();
refreshTasksBadge();
} catch (err) {
showToast("Reject fehlgeschlagen. " + err.message, "error");
}
}
async function reclassifySource(id) {
const btn = document.querySelector(`[data-reclassify-id="${id}"]`);
if (btn) { btn.disabled = true; btn.textContent = "..."; }
try {
await API.post(`/api/sources/${id}/classification/reclassify`, {});
showToast("Neu klassifiziert.", "success");
loadClassificationQueue();
refreshTasksBadge();
} catch (err) {
showToast("Reclassify fehlgeschlagen. " + err.message, "error");
} finally {
if (btn) { btn.disabled = false; btn.textContent = "Neu klassifizieren"; }
}
}
async function triggerBulkClassify() {
const ok = await showConfirm("Klassifikation starten",
"Bulk-Klassifikation aller noch nicht klassifizierten Quellen starten? Läuft im Hintergrund (~3-5 Sek pro Quelle, ~0.02 USD pro Quelle).");
if (!ok) return;
try {
const r = await API.post("/api/sources/classification/bulk-classify?limit=500&only_unclassified=true", {});
showToast(`Bulk-Klassifikation gestartet (limit=${r.limit}). In ~10 min neu laden.`, "info");
} catch (err) {
showToast("Start fehlgeschlagen. " + err.message, "error");
}
}
async function bulkApproveHighConfidence() {
const ok = await showConfirm("Sammel-Genehmigung",
"Alle Vorschläge mit Konfidenz ≥ 0.85 genehmigen?");
if (!ok) return;
try {
const r = await API.post("/api/sources/classification/bulk-approve?min_confidence=0.85", {});
showToast(`${r.approved} Vorschläge übernommen.`, "success");
loadClassificationQueue();
refreshTasksBadge();
} catch (err) {
showToast("Bulk-Approve fehlgeschlagen. " + err.message, "error");
}
}
async function triggerExternalReputationSync() {
const ok = await showConfirm("Externe Daten syncen",
"IFCN- und EUvsDisinfo-Datenbanken jetzt syncen? Läuft im Hintergrund (~30 Sek).");
if (!ok) return;
try {
await API.post("/api/sources/external-reputation/sync", {});
showToast("Externer Sync gestartet. Quellenliste in ~30 Sek neu laden.", "info");
} catch (err) {
showToast("Sync fehlgeschlagen. " + err.message, "error");
}
}

Datei anzeigen

@@ -157,6 +157,79 @@
var empfDaten = null;
// ---------- Aktive Preise (Tabelle billing_tariff) ----------
var aktivTarif = null; // zuletzt geladene bzw. gespeicherte aktive Saetze
function renderAktivHint() {
var el = document.getElementById("vr-aktiv-hint");
if (!el) return;
if (aktivTarif) {
el.innerHTML = "<strong>Simulation.</strong> Abgerechnet wird nach der Preistabelle, aktuell <b>" +
aktivTarif.live + "</b> je Live-Lauf, <b>" + aktivTarif.research + "</b> je Recherche-Durchlauf, <b>" +
aktivTarif.neben + "</b> je Nebenkosten-Aufruf. Die Regler ändern erst dann etwas, wenn du sie unten als aktive Preise speicherst.";
}
}
function setzeSlider(satz, wert) {
var slider = document.getElementById("vr-slider-" + satz);
if (slider) {
if (wert > parseInt(slider.max, 10)) slider.max = wert;
slider.value = wert;
}
}
function ladeTarif() {
if (typeof API === "undefined") return;
API.get("/api/pricing/tariff").then(function (data) {
var t = (data && data.tariff) || {};
aktivTarif = {
live: Math.round(t.monitor_adhoc || saetze.live),
research: Math.round(t.monitor_research || saetze.research),
neben: Math.round(t.chat || saetze.neben),
};
// Regler starten auf den aktiven Preisen
saetze.live = aktivTarif.live;
saetze.research = aktivTarif.research;
saetze.neben = aktivTarif.neben;
setzeSlider("live", saetze.live);
setzeSlider("research", saetze.research);
setzeSlider("neben", saetze.neben);
satzGesetzt();
renderAktivHint();
}).catch(function () { /* Hinweis bleibt generisch */ });
}
function speichereTarif() {
var msg = document.getElementById("vr-tarif-msg");
var tuEs = function () {
if (msg) msg.textContent = "Speichern …";
API.put("/api/pricing/tariff", {
monitor_adhoc: saetze.live,
monitor_research: saetze.research,
chat: saetze.neben,
enhance: saetze.neben,
globe: saetze.neben,
}).then(function () {
aktivTarif = { live: saetze.live, research: saetze.research, neben: saetze.neben };
renderAktivHint();
if (typeof loadTariffInfo === "function") loadTariffInfo();
if (msg) msg.textContent = "Gespeichert, gilt ab sofort für alle neuen Buchungen.";
setTimeout(function () { if (msg) msg.textContent = ""; }, 6000);
}).catch(function (err) {
if (msg) msg.textContent = "Fehler: " + err.message;
});
};
if (typeof showConfirm === "function") {
showConfirm(
"Aktive Preise ändern?",
"Live-Lauf " + saetze.live + ", Recherche-Durchlauf " + saetze.research +
", Nebenkosten " + saetze.neben + " Credits gelten dann ab sofort für alle neuen Buchungen.",
tuEs
);
} else tuEs();
}
function renderEmpfehlung() {
if (!empfDaten) return;
["live", "research"].forEach(function (satz) {
@@ -344,12 +417,17 @@
return '<div class="vr-kopf">' +
"<h2>Verbrauchsrechner</h2>" +
"<p>Sätze frei einstellbar. Die Empfehlung stammt aus den echten API-Kosten der letzten 30 Tage (Median je Aktion geteilt durch 0,20 USD).</p>" +
'<p class="vr-basis" id="vr-aktiv-hint"><strong>Simulation.</strong> Die Regler ändern erst dann etwas, wenn du sie unten als aktive Preise speicherst.</p>' +
"</div>" +
'<div class="vr-regler-grid">' +
reglerHtml("live", "Live-Monitoring", "Anlegen und Aktualisierung", 1, 100) +
reglerHtml("research", "Recherche", "Je Aktualisierung (Durchlauf)", 1, 100) +
reglerHtml("neben", "Nebenkosten", "Je Aufruf", 1, 10) +
"</div>" +
'<div style="margin:14px 0 4px; display:flex; align-items:center; gap:10px;">' +
'<button type="button" class="btn btn-primary" id="vr-tarif-speichern">Diese Sätze als aktive Preise speichern</button>' +
'<span id="vr-tarif-msg" style="font-size:13px; color:var(--text-secondary);"></span>' +
"</div>" +
'<p class="vr-basis">1 Credit = 0,20 USD Selbstkosten.</p>' +
'<div class="vr-calc">' +
"<div>" +
@@ -388,7 +466,7 @@
var btn = document.createElement("button");
btn.className = "nav-tab";
btn.setAttribute("data-section", "rechner");
btn.textContent = "Verbrauchsrechner";
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" x2="16" y1="6" y2="6"/><line x1="16" x2="16" y1="14" y2="18"/><path d="M16 10h.01"/><path d="M12 10h.01"/><path d="M8 10h.01"/><path d="M12 14h.01"/><path d="M8 14h.01"/><path d="M12 18h.01"/><path d="M8 18h.01"/></svg>Verbrauchsrechner';
nav.appendChild(btn);
btn.addEventListener("click", function () {
@@ -472,12 +550,16 @@
}
});
var speichernBtn = document.getElementById("vr-tarif-speichern");
if (speichernBtn) speichernBtn.addEventListener("click", speichereTarif);
updateWerte();
renderBlock("live");
renderBlock("recherche");
renderBlock("neben");
calc();
ladeEmpfehlung();
ladeTarif();
}
if (document.readyState === "loading") {

Datei anzeigen

@@ -1,588 +0,0 @@
/* Quellen-Health & Vorschläge */
"use strict";
let healthData = null;
let suggestionsCache = [];
// Default-Filter zeigt nur Probleme (errors + warnings); OK ist meistens Rauschen.
// "issues" ist ein virtueller Status-Wert, den nur das Frontend versteht (siehe applyHealthFilter).
let healthFilters = { status: "issues", check_type: "", org: "all" };
let healthHistoryCache = [];
// 60-Sekunden-Cache, damit Tab-Wechsel nicht jedes Mal die volle Antwort neu lädt.
// Bei Mutationen (Vorschlag annehmen/ablehnen, run-stream, search-fix) wird mit force=true neu geladen.
// Cache-Key beinhaltet das aktuelle Limit, damit "Mehr laden" nicht aus altem Cache bedient wird.
let healthDataCache = { health: null, suggestions: null, history: null, ts: 0, limit: 0 };
const HEALTH_CACHE_TTL_MS = 60000;
// Default-Pagination: 100 Items reichen meistens (errors+warnings stehen vorne, ok-Status hinten).
// Wird durch loadMoreHealth() / loadAllHealth() hochgesetzt.
let healthLoadLimit = 100;
const CHECK_TYPE_LABELS = {
reachability: "Erreichbarkeit",
feed_validity: "Feed-Validität",
stale: "Aktualität",
duplicate: "Duplikat",
};
const SUGGESTION_TYPE_LABELS = {
add_source: "Neue Quelle",
deactivate_source: "Deaktivieren",
remove_source: "Entfernen",
fix_url: "URL korrigieren",
};
const PRIORITY_LABELS = {
high: "Hoch",
medium: "Mittel",
low: "Niedrig",
};
// Lucide-Icons als Inline-SVG-Konstanten (statt CDN-Abhängigkeit oder Emojis).
// 14x14, currentColor erbt vom Button-Style.
const LUCIDE_ICONS = {
check: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><polyline points="20 6 9 17 4 12"/></svg>',
x: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
search:'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
refresh:'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>',
};
// --- Init ---
function setupHealthTab() {
const tab = document.querySelector('#sourceSubTabs .nav-tab[data-subtab="source-health"]');
if (tab) {
tab.addEventListener("click", () => loadHealthData());
}
}
// Sub-Sub-Tabs innerhalb von Quellen-Health: Vorschläge / Health-Status / Verlauf.
function setupHealthSubTabs() {
document.querySelectorAll("#healthSubTabs .nav-tab").forEach((tab) => {
tab.addEventListener("click", () => {
const which = tab.dataset.healthtab;
document.querySelectorAll("#healthSubTabs .nav-tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
["suggestions", "checks", "verlauf"].forEach(name => {
const pane = document.getElementById("ht-" + name);
if (pane) pane.style.display = name === which ? "block" : "none";
});
});
});
}
document.addEventListener("DOMContentLoaded", () => {
setupHealthTab();
setupHealthSubTabs();
});
// --- Health-Daten laden ---
async function loadHealthData(force = false) {
const now = Date.now();
if (!force
&& healthDataCache.health
&& healthDataCache.limit === healthLoadLimit
&& (now - healthDataCache.ts) < HEALTH_CACHE_TTL_MS) {
healthData = healthDataCache.health;
suggestionsCache = healthDataCache.suggestions;
healthHistoryCache = healthDataCache.history;
renderHealthDashboard();
return;
}
try {
const [health, suggestions, history] = await Promise.all([
API.get("/api/sources/health?limit=" + healthLoadLimit),
API.get("/api/sources/suggestions"),
API.get("/api/sources/health/history?limit=10").catch(() => []),
]);
healthData = health;
suggestionsCache = suggestions;
healthHistoryCache = history || [];
healthDataCache = { health, suggestions, history: history || [], ts: Date.now(), limit: healthLoadLimit };
renderHealthDashboard();
} catch (err) {
console.error("Health-Daten laden fehlgeschlagen:", err);
document.getElementById("healthContent").innerHTML =
'<div class="text-muted" style="padding:20px;">Fehler beim Laden der Health-Daten.</div>';
}
}
// Pagination-Steuerung: hochsetzen + neu laden
function loadMoreHealth() {
healthLoadLimit += 200;
loadHealthData(true);
}
function loadAllHealth() {
healthLoadLimit = 100000;
loadHealthData(true);
}
function applyHealthFilter(checks) {
return checks.filter(c => {
// "issues" = Sammelfilter für errors + warnings (Default)
if (healthFilters.status === "issues" && c.status === "ok") return false;
if (healthFilters.status && healthFilters.status !== "issues" && c.status !== healthFilters.status) return false;
if (healthFilters.check_type && c.check_type !== healthFilters.check_type) return false;
if (healthFilters.org === "global" && c.tenant_id !== null) return false;
if (healthFilters.org !== "all" && healthFilters.org !== "global"
&& healthFilters.org && String(c.tenant_id) !== healthFilters.org) return false;
return true;
});
}
function setHealthFilter(field, value) {
healthFilters[field] = value;
renderHealthDashboard();
}
function renderHealthDashboard() {
// Drei Sub-Panes (statt einer monolithischen Health-Section).
const paneSuggestions = document.getElementById("ht-suggestions");
const paneChecks = document.getElementById("ht-checks");
const paneVerlauf = document.getElementById("ht-verlauf");
if (!paneSuggestions || !paneChecks || !paneVerlauf) return;
// Vorschläge rendern
const pendingSuggestions = suggestionsCache.filter((s) => s.status === "pending");
const recentSuggestions = suggestionsCache.filter((s) => s.status !== "pending");
let suggestionsHtml = "";
if (pendingSuggestions.length > 0) {
suggestionsHtml = `
<div class="card" style="margin-bottom:16px;">
<div class="card-header">
<h2>Vorschläge (${pendingSuggestions.length} offen)</h2>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Typ</th>
<th>Titel</th>
<th>Beschreibung</th>
<th>Priorität</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
${pendingSuggestions
.map(
(s) => `
<tr>
<td><span class="badge badge-suggestion-${s.suggestion_type}">${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type}</span></td>
<td>${esc(s.title)}</td>
<td class="text-secondary" style="max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="${esc(s.description || "")}">${esc(s.description || "")}</td>
<td><span class="badge badge-priority-${s.priority}">${PRIORITY_LABELS[s.priority] || s.priority}</span></td>
<td class="text-secondary">${formatDateTime(s.created_at)}</td>
<td style="white-space:nowrap;">
${s.suggestion_type === "deactivate_source" && s.source_id ? `<button class="btn btn-secondary btn-small" data-source-id="${s.source_id}" data-source-name="${esc(s.title.split(':')[0] || s.title)}" onclick="searchFix(this)" title="Lösung suchen">${LUCIDE_ICONS.search}</button> ` : ""}
<button class="btn btn-success btn-small" onclick="handleSuggestion(${s.id}, true)" title="Annehmen">${LUCIDE_ICONS.check}</button>
<button class="btn btn-danger btn-small" onclick="handleSuggestion(${s.id}, false)" title="Ablehnen">${LUCIDE_ICONS.x}</button>
</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</div>
</div>`;
} else {
suggestionsHtml = `
<div class="card" style="margin-bottom:16px;">
<div class="card-header"><h2>Vorschläge</h2></div>
<div class="card-body text-muted">Keine offenen Vorschläge vorhanden.</div>
</div>`;
}
// Vergangene Vorschläge - eingeklappt by default, weil rein historisch.
let historyHtml = "";
if (recentSuggestions.length > 0) {
const shown = recentSuggestions.slice(0, 20);
historyHtml = `
<details class="card" style="margin-bottom:16px;">
<summary style="cursor:pointer; padding:14px 18px; list-style:none;">
<span style="color:var(--accent, #C8A851); font-weight:600; font-size:1.02rem;">Verlauf</span>
<span class="text-secondary" style="font-size:13px; margin-left:8px;">(${recentSuggestions.length} erledigte Vorschläge - klick zum Aufklappen)</span>
</summary>
<div class="table-wrap" style="border-top:1px solid var(--border, rgba(255,255,255,0.08));">
<table>
<thead>
<tr><th>Typ</th><th>Titel</th><th>Status</th><th>Bearbeitet</th></tr>
</thead>
<tbody>
${shown
.map(
(s) => `
<tr>
<td><span class="badge badge-suggestion-${s.suggestion_type}">${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type}</span></td>
<td>${esc(s.title)}</td>
<td><span class="badge badge-${s.status === "accepted" ? "active" : "inactive"}">${s.status === "accepted" ? "Angenommen" : "Abgelehnt"}</span></td>
<td class="text-secondary">${formatDateTime(s.reviewed_at)}</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</div>
</details>`;
}
// Health-Check Ergebnisse
let healthHtml = "";
if (healthData && healthData.checks && healthData.checks.length > 0) {
// Filter anwenden
const allChecks = healthData.checks;
const filtered = applyHealthFilter(allChecks);
// Counters aus Backend-Aggregat (über Gesamt-Bestand, nicht nur Page)
const okCount = healthData.ok != null ? healthData.ok : healthData.checks.filter((c) => c.status === "ok").length;
const totalAll = healthData.total_checks != null ? healthData.total_checks : allChecks.length;
const hasMore = !!healthData.has_more;
// Org-Liste aus Backend-Liste (volle Liste, auch wenn Page kleiner ist)
const orgs = (healthData.all_orgs || []).map(o => ({ id: String(o.id), name: o.name || ("Org " + o.id) }));
const checkTypes = Array.from(new Set(allChecks.map(c => c.check_type)));
// Counter-Aufgliederung aus Backend-Breakdown (pro check_type x status).
// Beispiel: { reachability: {ok: 281, error: 3, warning: 1}, feed_validity: {...}, stale: {...}, duplicate: {...} }
const breakdown = healthData.breakdown || {};
function breakdownLine(statusKey, cssClass) {
const entries = Object.entries(breakdown)
.map(([ct, byStatus]) => [ct, byStatus[statusKey] || 0])
.filter(([_, n]) => n > 0)
.sort((a, b) => b[1] - a[1]);
if (entries.length === 0) return "";
const total = entries.reduce((s, [, n]) => s + n, 0);
const detail = entries.map(([ct, n]) => `${n} ${CHECK_TYPE_LABELS[ct] || ct}`).join(", ");
const label = statusKey === "error" ? "Fehler" : (statusKey === "warning" ? "Warnungen" : "OK");
return `<span class="${cssClass}" title="${esc(detail)}">${total} ${label}</span> <span class="text-secondary" style="font-size:11px;">(${esc(detail)})</span>`;
}
// Trend-Delta zum vorletzten Run (healthHistoryCache[1]). Index 0 ist
// typischerweise der aktuelle Stand, Index 1 der davor archivierte Run.
// Wenn weniger als 2 Runs in der History: kein Delta anzeigen.
const prevRun = (healthHistoryCache && healthHistoryCache.length > 1) ? healthHistoryCache[1] : null;
function deltaBadge(currentValue, prevValue, badIsUp) {
if (prevValue == null) return "";
const d = currentValue - prevValue;
if (d === 0) return ` <span class="text-secondary" style="font-size:11px;" title="unverändert seit letztem Run">(±0)</span>`;
const sign = d > 0 ? "+" : "";
// badIsUp=true: Anstieg = schlecht (rot), Abnahme = gut (grün). Umgekehrt für OK.
const cls = (badIsUp ? (d > 0) : (d < 0)) ? "text-danger" : "text-success";
return ` <span class="${cls}" style="font-size:11px;" title="seit letztem Run">(${sign}${d})</span>`;
}
const dErr = prevRun ? deltaBadge(healthData.errors, prevRun.errors, true) : "";
const dWarn = prevRun ? deltaBadge(healthData.warnings, prevRun.warnings, true) : "";
const dOk = prevRun ? deltaBadge(okCount, prevRun.ok, false) : "";
healthHtml = `
<div class="card">
<div class="card-header">
<h2>Health-Check Ergebnisse</h2>
<span class="text-secondary" style="font-size:13px;">
Letzter Check: ${healthData.last_check ? formatDateTime(healthData.last_check) : "Noch nie"}
&nbsp;|&nbsp;
${breakdownLine("error", "text-danger") || `<span class="text-danger">0 Fehler</span>`}${dErr} &nbsp;
${breakdownLine("warning", "text-warning") || `<span class="text-warning">0 Warnungen</span>`}${dWarn} &nbsp;
<span class="text-success">${okCount} OK</span>${dOk}
</span>
</div>
<div class="action-bar" style="border-bottom:1px solid var(--border, rgba(255,255,255,0.08));">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<select class="filter-select" onchange="setHealthFilter('status', this.value)">
<option value="issues" ${healthFilters.status === "issues" ? "selected" : ""}>Nur Probleme (Default)</option>
<option value="" ${healthFilters.status === "" ? "selected" : ""}>Alle Status</option>
<option value="error" ${healthFilters.status === "error" ? "selected" : ""}>Nur Fehler</option>
<option value="warning" ${healthFilters.status === "warning" ? "selected" : ""}>Nur Warnungen</option>
<option value="ok" ${healthFilters.status === "ok" ? "selected" : ""}>Nur OK</option>
</select>
<select class="filter-select" onchange="setHealthFilter('check_type', this.value)">
<option value="" ${healthFilters.check_type === "" ? "selected" : ""}>Alle Typen</option>
${checkTypes.map(ct => `<option value="${esc(ct)}" ${healthFilters.check_type === ct ? "selected" : ""}>${esc(CHECK_TYPE_LABELS[ct] || ct)}</option>`).join("")}
</select>
<select class="filter-select" onchange="setHealthFilter('org', this.value)">
<option value="all" ${healthFilters.org === "all" ? "selected" : ""}>Alle Quellen</option>
<option value="global" ${healthFilters.org === "global" ? "selected" : ""}>Nur Grundquellen</option>
${orgs.map(o => `<option value="${esc(o.id)}" ${healthFilters.org === o.id ? "selected" : ""}>Org: ${esc(o.name)}</option>`).join("")}
</select>
<span class="text-secondary" style="font-size:13px;">
${filtered.length} / ${allChecks.length} angezeigt${totalAll > allChecks.length ? ` (von ${totalAll} insgesamt)` : ''}
</span>
</div>
</div>`;
if (filtered.length > 0) {
healthHtml += `
<div class="table-wrap">
<table>
<thead>
<tr><th>Quelle</th><th>Typ</th><th>Org</th><th>Status</th><th>Details</th><th>Aktion</th></tr>
</thead>
<tbody>
${filtered
.map(
(c) => {
// Domain + Sprache in Tooltip vom Quellnamen, statt eigene Spalten.
const tipParts = [];
if (c.domain) tipParts.push(c.domain);
if (c.language) tipParts.push(c.language);
const nameTip = tipParts.length ? ` title="${esc(tipParts.join(" · "))}"` : "";
return `
<tr>
<td${nameTip}>${esc(c.name)}</td>
<td>${CHECK_TYPE_LABELS[c.check_type] || c.check_type}</td>
<td class="text-secondary">${c.tenant_id == null ? '<span style="color:#94a3b8;">global</span>' : esc(c.org_name || ("Org " + c.tenant_id))}</td>
<td><span class="badge badge-health-${c.status}">${c.status === "error" ? "Fehler" : (c.status === "warning" ? "Warnung" : "OK")}</span></td>
<td class="text-secondary" style="max-width:300px;" title="${esc(c.message || "")}">${esc(c.message || "")}</td>
<td>${(
(c.status === "error" && c.check_type === "reachability") ||
(c.status === "warning" && c.check_type === "feed_validity")
) ? `<button class="btn btn-secondary btn-small" data-source-id="${c.source_id}" data-source-name="${esc(c.name)}" onclick="searchFix(this)" title="Lösung suchen">${LUCIDE_ICONS.search}</button>` : ""}</td>
</tr>`;
}
)
.join("")}
</tbody>
</table>
</div>`;
} else if (hasMore) {
// 0 Treffer in der Page, aber es gibt noch ungeladene Items.
// Hinweis, dass der Filter erst über die volle Liste sicher ist.
healthHtml += `
<div class="card-body text-muted">
Keine Treffer in den geladenen ${allChecks.length} von ${totalAll} Items mit dem aktuellen Filter.
<a href="#" onclick="event.preventDefault(); loadAllHealth()" style="text-decoration:underline;">
Alle ${totalAll} Health-Checks laden
</a> und Filter erneut anwenden.
</div>`;
} else {
healthHtml += '<div class="card-body text-muted">Keine Ergebnisse mit diesen Filtern.</div>';
}
// Footer mit Mehr-laden-Buttons, falls Backend has_more meldet
if (hasMore) {
const remaining = Math.max(0, totalAll - allChecks.length);
healthHtml += `
<div class="card-body" style="display:flex;justify-content:center;gap:10px;align-items:center;border-top:1px solid var(--border, rgba(255,255,255,0.08));">
<span class="text-secondary" style="font-size:13px;">${allChecks.length} von ${totalAll} geladen</span>
<button class="btn btn-secondary btn-small" onclick="loadMoreHealth()">+200 laden</button>
<button class="btn btn-secondary btn-small" onclick="loadAllHealth()">Alle ${remaining} weiteren laden</button>
</div>`;
}
healthHtml += "</div>";
} else {
healthHtml = `
<div class="card">
<div class="card-header"><h2>Health-Check Ergebnisse</h2></div>
<div class="card-body text-muted">Noch kein Health-Check durchgeführt.</div>
</div>`;
}
// History-View: letzte Runs. Kompakt: Total raus (= errors+warnings+ok),
// Spalten-Widths explizit, Zahlen zentriert, Run-ID gekürzt + leiser.
let runsHtml = "";
if (healthHistoryCache.length > 0) {
runsHtml = `
<div class="card" style="margin-bottom:16px;">
<div class="card-header"><h2>Verlauf der Health-Check-Runs</h2></div>
<div class="table-wrap">
<table style="table-layout:fixed; width:100%;">
<colgroup>
<col style="width:200px;">
<col style="width:160px;">
<col style="width:110px;">
<col style="width:130px;">
<col style="width:110px;">
</colgroup>
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Run-ID</th>
<th style="text-align:center;">Fehler</th>
<th style="text-align:center;">Warnungen</th>
<th style="text-align:center;">OK</th>
</tr>
</thead>
<tbody>
${healthHistoryCache.map(r => `
<tr>
<td>${formatDateTime(r.archived_at)}</td>
<td class="text-secondary" style="font-size:12px;" title="${esc(r.run_id)}"><code>${esc(String(r.run_id || "").slice(0, 12))}</code></td>
<td class="text-danger" style="text-align:center;">${r.errors || 0}</td>
<td class="text-warning" style="text-align:center;">${r.warnings || 0}</td>
<td class="text-success" style="text-align:center;">${r.ok || 0}</td>
</tr>`).join("")}
</tbody>
</table>
</div>
</div>`;
}
// Statt einer monolithischen Render: drei Sub-Panes, einer pro Sub-Tab.
paneSuggestions.innerHTML = suggestionsHtml;
paneChecks.innerHTML = healthHtml;
paneVerlauf.innerHTML = historyHtml + runsHtml;
// Tab-Label "Vorschläge" mit Counter der offenen Vorschläge anreichern.
const tabBtnSugg = document.querySelector('#healthSubTabs .nav-tab[data-healthtab="suggestions"]');
if (tabBtnSugg) {
const open = pendingSuggestions.length;
tabBtnSugg.textContent = open > 0 ? `Vorschläge (${open} offen)` : "Vorschläge";
}
}
// --- Vorschlag annehmen/ablehnen ---
async function handleSuggestion(id, accept) {
const action = accept ? "annehmen" : "ablehnen";
const suggestion = suggestionsCache.find((s) => s.id === id);
if (!suggestion) return;
const ok = await showConfirm("Vorschlag " + (action === "annehmen" ? "annehmen" : "ablehnen"), `Soll "${suggestion.title}" ${action}?`);
if (!ok) return;
try {
const result = await API.put("/api/sources/suggestions/" + id, { accept });
if (result.action) {
showToast("Ergebnis: " + result.action, "success");
}
loadHealthData(true);
// Grundquellen-Liste auch aktualisieren
if (typeof loadGlobalSources === "function") loadGlobalSources();
} catch (err) {
showToast("Fehler: " + err.message, "error");
}
}
// --- Health-Check manuell starten ---
async function runHealthCheck() {
const btn = document.getElementById("runHealthCheckBtn");
if (!btn) return;
btn.disabled = true;
// Fortschrittsanzeige erstellen
let progressEl = document.getElementById("healthProgress");
if (!progressEl) {
progressEl = document.createElement("div");
progressEl.id = "healthProgress";
progressEl.style.cssText = "display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:16px;font-size:13px;";
btn.parentElement.after(progressEl);
}
progressEl.style.display = "flex";
function updateProgress(data) {
if (data.phase === "check") {
const pct = data.total > 0 ? Math.round((data.checked / data.total) * 100) : 0;
const statusIcon = data.status === "error" ? "\u2717" : data.status === "warning" ? "\u26A0" : "\u2713";
btn.textContent = data.checked + "/" + data.total;
progressEl.innerHTML =
'<div style="flex:1;">' +
'<div style="display:flex;justify-content:space-between;margin-bottom:4px;">' +
'<span>' + (data.current ? statusIcon + " " + esc(data.current) : "Starte...") + '</span>' +
'<span class="text-secondary">' + pct + '%</span>' +
'</div>' +
'<div style="height:4px;background:var(--bg-tertiary);border-radius:2px;overflow:hidden;">' +
'<div style="height:100%;width:' + pct + '%;background:var(--accent);transition:width 0.3s;"></div>' +
'</div>' +
'</div>';
} else if (data.phase === "suggestions") {
progressEl.innerHTML = '<span class="text-secondary">Generiere Vorschl\u00e4ge...</span>';
} else if (data.phase === "done") {
progressEl.innerHTML = '<span class="text-success">' + data.checked + ' gepr\u00fcft, ' + data.issues + ' Probleme, ' + data.suggestions + ' Vorschl\u00e4ge</span>';
setTimeout(function() { progressEl.style.display = "none"; }, 5000);
}
}
try {
const headers = { "Content-Type": "application/json" };
if (API.token) headers["Authorization"] = "Bearer " + API.token;
const response = await fetch("/api/sources/health/run-stream", {
method: "POST",
headers: headers,
});
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const data = JSON.parse(line.slice(6));
updateProgress(data);
} catch (_) {}
}
}
}
loadHealthData(true);
} catch (err) {
progressEl.innerHTML = '<span class="text-danger">Fehler: ' + esc(err.message) + '</span>';
} finally {
btn.disabled = false;
btn.textContent = "Jetzt pr\u00fcfen";
}
}
// --- Hilfsfunktionen ---
function formatDateTime(dateStr) {
if (!dateStr) return "-";
try {
const d = new Date(dateStr);
return d.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch (_) {
return dateStr;
}
}
// --- Sonnet-Recherche für kaputte Quelle ---
async function searchFix(btn) {
const sourceId = btn.dataset.sourceId;
const sourceName = btn.dataset.sourceName;
const ok = await showConfirm("Lösung suchen", `Sonnet mit WebSearch nach einer Lösung für "${sourceName}" suchen lassen? Das kann einige Minuten dauern.`);
if (!ok) return;
btn.disabled = true;
btn.textContent = "Sucht...";
try {
const result = await API.post("/api/sources/health/search-fix/" + sourceId);
let msg = result.summary || "Keine Zusammenfassung";
if (result.solutions && result.solutions.length > 0) {
msg += "\n\nGefundene Lösungen als Vorschläge gespeichert.";
}
if (result.cost_usd) {
msg += `\n\nKosten: $${result.cost_usd.toFixed(2)}`;
}
showToast(msg, "info");
loadHealthData(true);
} catch (err) {
showToast("Fehler: " + err.message, "error");
} finally {
btn.disabled = false;
btn.textContent = "Lösung suchen";
}
}

Datei anzeigen

@@ -1,27 +1,75 @@
/* Grundquellen & Kundenquellen Management */
/* Vereinte Quellenliste (Grund- + Kundenquellen) */
"use strict";
// Hält Grund- UND Kundenquellen (GET /api/sources?scope=all)
let globalSourcesCache = [];
let tenantSourcesCache = [];
// Phase 3c: Tenant-Tab State
let tenantFilters = { search: "", type: "", category: "", org: "", language: "" };
let tenantSort = { field: "org_name", asc: true };
// Auswahl für Bulk-Promote (nur bei Herkunfts-Filter Kundenquellen sichtbar)
let tenantSelected = new Set();
// Checkbox-Filter der Quellenliste. Leeres Set = keine Einschränkung.
const unifiedFilters = { origin: new Set(), types: new Set(), cats: new Set(), status: new Set(), langs: new Set() };
let unifiedLangOptions = [];
let lastShowSelect = false;
// Lazy-Cache für die Health-Ausklapp-Details je Quelle
const sourceHealthDetailCache = new Map();
let editingSourceId = null;
let globalSortField = "category";
let globalSortAsc = true;
const CHECK_TYPE_LABELS = {
reachability: "Erreichbarkeit",
feed_validity: "Feed-Validität",
stale: "Aktualität",
duplicate: "Duplikat",
};
// CATEGORY_LABELS jetzt global (aus app.js loadMeta)
// TYPE_LABELS jetzt global (aus app.js loadMeta)
// META muss geladen sein, bevor Dropdowns befüllt werden (loadMeta läuft async beim Start)
async function ensureMeta() {
if (!window.META || !window.META.categories || !window.META.categories.length) {
if (typeof loadMeta === "function") await loadMeta();
}
}
// Modal-Selects ohne Leer-Option aus META befüllen
function fillSelect(el, items, opts = {}) {
if (!el) return;
el.innerHTML = "";
(items || []).forEach((it) => {
const o = document.createElement("option");
o.value = it.key;
o.textContent = it.label + (opts.disabledKeys && opts.disabledKeys.includes(it.key) && opts.disabledHint ? " " + opts.disabledHint : "");
if (opts.disabledKeys && opts.disabledKeys.includes(it.key)) o.disabled = true;
el.appendChild(o);
});
if (opts.value !== undefined) el.value = opts.value;
}
function fillSourceModalSelects() {
fillSelect(document.getElementById("sourceType"), (window.META && window.META.types) || [],
{ disabledKeys: ["pdf_document"], disabledHint: "(nur Upload)" });
fillSelect(document.getElementById("sourceCategory"), (window.META && window.META.categories) || []);
}
// --- Init ---
document.addEventListener("DOMContentLoaded", () => {
setupSourceSubTabs();
setupSourceForms();
if (typeof enableDragScroll === "function") {
enableDragScroll(document.getElementById("unifiedTableWrap"));
}
if (typeof attachFloatingScrollbar === "function") {
attachFloatingScrollbar(document.getElementById("unifiedTableWrap"));
}
// Beim Tab-Wechsel auf "Quellen" laden
// Beim Tab-Wechsel auf "Quellen" laden (+ Aufgaben-Badge aktualisieren)
document.querySelectorAll('.nav-tab[data-section="sources"]').forEach((tab) => {
tab.addEventListener("click", () => loadGlobalSources());
tab.addEventListener("click", () => {
loadUnifiedSources();
if (typeof refreshTasksBadge === "function") refreshTasksBadge();
});
});
});
@@ -34,40 +82,25 @@ function setupSourceSubTabs() {
document.querySelectorAll("#sec-sources > .section").forEach((s) => s.classList.remove("active"));
document.getElementById("sub-" + subtab).classList.add("active");
if (subtab === "global-sources") loadGlobalSources();
else if (subtab === "tenant-sources") loadTenantSources();
else if (subtab === "source-health") loadHealthData();
else if (subtab === "classification-review") loadClassificationQueue();
if (subtab === "global-sources") loadUnifiedSources();
else if (subtab === "aufgaben") loadAufgaben();
else if (subtab === "x-scraper") loadXScraperAccounts();
});
});
}
// --- Grundquellen ---
async function loadGlobalSources() {
// --- Vereinte Quellenliste ---
async function loadUnifiedSources() {
try {
// Kategorien/Typen-Dropdowns aus META befüllen (idempotent)
if (window.META && window.META.categories && window.META.categories.length) {
populateSelect(document.getElementById("globalFilterCategory"), window.META.categories, "Alle Kategorien");
populateSelect(document.getElementById("globalFilterType"),
(window.META.types || []).filter(t => t.key !== "excluded"), "Alle Typen");
}
await ensureMeta();
const [list, stats, languages] = await Promise.all([
API.get("/api/sources/global"),
API.get("/api/sources/global/stats"),
API.get("/api/sources/global/languages").catch(() => []),
API.get("/api/sources?scope=all"),
API.get("/api/sources/stats"),
API.get("/api/sources/languages").catch(() => []),
]);
globalSourcesCache = list;
populateSelect(
document.getElementById("globalFilterLanguage"),
(languages || []).map(l => ({ key: l, label: l })),
"Alle Sprachen",
);
populateSelect(
document.getElementById("tenantFilterLanguage"),
(languages || []).map(l => ({ key: l, label: l })),
"Alle Sprachen",
);
sourceHealthDetailCache.clear();
unifiedLangOptions = (languages || []).map(l => ({ key: l, label: l }));
// datalist fuer Edit-Modal
const dl = document.getElementById("languageSuggestions");
if (dl) {
@@ -78,13 +111,33 @@ async function loadGlobalSources() {
dl.appendChild(o);
});
}
renderGlobalStats(stats);
renderGlobalSources(globalSourcesCache);
renderUnifiedFilters();
renderUnifiedStats(stats);
filterUnifiedSources();
checkHealthRunOnLoad();
} catch (err) {
console.error("Grundquellen laden fehlgeschlagen:", err);
console.error("Quellenliste laden fehlgeschlagen:", err);
}
}
// Checkbox-Filterleiste der Quellenliste (Herkunft, Typ, Status, Kategorie, Sprache)
function renderUnifiedFilters() {
const bar = document.getElementById("unifiedFilterBar");
if (!bar) return;
bar.innerHTML = "";
renderFilterGroup(bar, "Herkunft", [
{ key: "global", label: "Grundquellen" },
{ key: "tenant", label: "Kundenquellen" },
], unifiedFilters.origin, filterUnifiedSources);
renderFilterGroup(bar, "Typ", (window.META && window.META.types) || [], unifiedFilters.types, filterUnifiedSources);
renderFilterGroup(bar, "Status", [
{ key: "active", label: "Aktiv" },
{ key: "inactive", label: "Inaktiv" },
], unifiedFilters.status, filterUnifiedSources);
renderFilterGroup(bar, "Kategorie", (window.META && window.META.categories) || [], unifiedFilters.cats, filterUnifiedSources);
renderFilterGroup(bar, "Sprache", unifiedLangOptions, unifiedFilters.langs, filterUnifiedSources);
}
async function showSourceAudit(sourceId, sourceName) {
@@ -128,7 +181,11 @@ function renderAuditEntries(items) {
function formatDateTime(iso) {
if (!iso) return "-";
try {
const d = new Date(iso);
let v = String(iso);
// SQLite liefert CURRENT_TIMESTAMP als UTC ohne Zeitzonen-Kennung.
// Ohne das Z würde der Browser den Wert als Lokalzeit fehlinterpretieren.
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(v)) v = v.replace(" ", "T") + "Z";
const d = new Date(v);
return d.toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit",
@@ -136,7 +193,7 @@ function formatDateTime(iso) {
} catch { return iso; }
}
function renderGlobalStats(stats) {
function renderUnifiedStats(stats) {
const bar = document.getElementById("globalStatsBar");
if (!bar) return;
if (!stats || !stats.by_type) { bar.innerHTML = ""; return; }
@@ -144,8 +201,10 @@ function renderGlobalStats(stats) {
const types = window.META && window.META.types ? window.META.types : [];
const parts = [];
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${stats.total || 0}</span> Quellen gesamt</span>`);
const bo = stats.by_origin || {};
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${bo.global || 0}</span> Grundquellen</span>`);
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${bo.tenant || 0}</span> Kundenquellen</span>`);
for (const t of types) {
if (t.key === "excluded") continue;
const v = stats.by_type[t.key] || { count: 0, articles: 0 };
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${v.count}</span> ${esc(t.label)}</span>`);
}
@@ -156,14 +215,83 @@ function renderGlobalStats(stats) {
if (h.warnings) parts.push(`<span class="sources-stat-item health-warning"><span class="sources-stat-value">${h.warnings}</span> Warnungen</span>`);
if (h.ok) parts.push(`<span class="sources-stat-item health-ok"><span class="sources-stat-value">${h.ok}</span> OK</span>`);
const lastCheck = stats.last_check ? formatDateTime(stats.last_check) : "noch nie";
parts.push(`<span class="sources-stat-item health-run-item"><button class="btn btn-secondary btn-small" id="runHealthCheckBtn" onclick="startHealthRun()">Jetzt prüfen</button><span class="text-secondary" id="healthRunInfo">Zuletzt geprüft ${lastCheck}</span></span>`);
bar.innerHTML = parts.join("");
if (healthRunPollTimer) setHealthRunButton(true);
}
function renderGlobalSources(sources) {
// --- Manueller Health-Run (Knopf in der Stats-Leiste) ---
let healthRunPollTimer = null;
async function startHealthRun() {
try {
await API.post("/api/sources/health/run", {});
setHealthRunButton(true);
beginHealthRunPolling();
showToast("Prüfung gestartet. Das kann einige Minuten dauern.", "info");
} catch (err) {
if (String(err.message || "").includes("bereits")) {
showToast("Es läuft bereits eine Prüfung.", "warning");
setHealthRunButton(true);
beginHealthRunPolling();
} else {
showToast("Start fehlgeschlagen. " + err.message, "error");
}
}
}
function beginHealthRunPolling() {
if (healthRunPollTimer) return;
healthRunPollTimer = setInterval(pollHealthRunStatus, 4000);
}
async function pollHealthRunStatus() {
try {
const st = await API.get("/api/sources/health/run-status");
if (st.running) { setHealthRunButton(true); return; }
// Lauf beendet
if (healthRunPollTimer) { clearInterval(healthRunPollTimer); healthRunPollTimer = null; }
setHealthRunButton(false);
if (st.result) {
const r = st.result;
showToast(`Health-Check abgeschlossen. ${r.checked} geprüft, ${r.issues} Probleme, ${r.suggestions} neue Vorschläge.`, "success");
} else if (st.error) {
showToast("Health-Check fehlgeschlagen. " + st.error, "error");
} else {
showToast("Prüfung beendet, Ergebnis unbekannt (Server wurde neu gestartet).", "info");
}
loadUnifiedSources();
if (typeof refreshTasksBadge === "function") refreshTasksBadge();
} catch (_) { /* nächster Tick versucht es erneut */ }
}
function setHealthRunButton(running) {
const btn = document.getElementById("runHealthCheckBtn");
if (!btn) return;
btn.disabled = running;
btn.textContent = running ? "Prüfung läuft…" : "Jetzt prüfen";
}
// Beim Öffnen des Quellen-Reiters einmal nachsehen, ob (z.B. aus einer anderen
// Session) gerade ein Lauf aktiv ist, und dann das Polling aufnehmen.
async function checkHealthRunOnLoad() {
try {
const st = await API.get("/api/sources/health/run-status");
if (st.running) {
setHealthRunButton(true);
beginHealthRunPolling();
}
} catch (_) { /* unkritisch */ }
}
function renderUnifiedSources(sources) {
const tbody = document.getElementById("globalSourceTable");
const cols = 13;
const cols = 14;
if (sources.length === 0) {
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Grundquellen</td></tr>`;
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Quellen</td></tr>`;
updateBulkButton();
return;
}
@@ -180,47 +308,65 @@ function renderGlobalSources(sources) {
order.forEach((cat) => {
const label = CATEGORY_LABELS[cat] || cat;
const count = grouped[cat].length;
html += `<tr class="cat-header-row"><td colspan="${cols}"><span class="cat-header-label">${esc(label)}</span><span class="cat-header-count">${count}</span></td></tr>`;
html += `<tr class="cat-header-row"><td colspan="${cols}"><span class="cat-header-sticky"><span class="cat-header-label">${esc(label)}</span><span class="cat-header-count">${count}</span></span></td></tr>`;
grouped[cat].forEach((s) => {
const isTenant = s.tenant_id != null;
const hasNotes = s.notes && s.notes.trim();
const infoBtn = hasNotes
? `<span class="src-info-toggle" onclick="toggleSourceInfo(${s.id})" title="Info einblenden">&#9432;</span>`
: '';
const notesRow = hasNotes
? `<tr class="src-notes-row" id="notes-${s.id}" style="display:none;"><td colspan="${cols}" class="src-notes-cell">${esc(s.notes)}</td></tr>`
// EINE gemeinsame Detailzeile je Quelle: Notizen + Health-Details.
const hasDetail = hasNotes || s.health_status;
const detailRow = hasDetail
? `<tr class="src-notes-row" id="detail-${s.id}" style="display:none;"><td colspan="${cols}" class="src-notes-cell">${hasNotes ? `<div class="src-notes-text">${esc(s.notes)}</div>` : ""}${s.health_status ? `<div class="health-detail" id="health-detail-${s.id}"></div>` : ""}</td></tr>`
: '';
const lastSeen = s.last_seen_at ? formatDate(s.last_seen_at) : "-";
const hs = s.health_status || "unknown";
const hsLabel = { error: "Fehler", warning: "Warnung", ok: "OK", unknown: "—" }[hs];
const hsClass = "health-badge-" + (hs === "unknown" ? "unknown" : hs);
const healthCell = s.health_status
? `<span class="health-badge ${hsClass} health-badge-btn" onclick="toggleHealthDetail(${s.id})" title="Health-Details anzeigen" role="button" tabindex="0">${hsLabel}</span>`
: `<span class="health-badge ${hsClass}">${hsLabel}</span>`;
const selectCell = isTenant
? `<td class="select-col"><input type="checkbox" class="tenant-select" data-id="${s.id}" ${tenantSelected.has(s.id) ? "checked" : ""} onchange="toggleTenantSelect(${s.id}, this.checked)"></td>`
: `<td class="select-col"></td>`;
// Kundenquellen sind bewusst nicht editierbar: Workflow ist erst
// "Übernehmen" (Promote zur Grundquelle), dann als Grundquelle bearbeiten.
const actions = isTenant
? `<button class="btn btn-primary btn-small" onclick="promoteSource(${s.id}, '${esc(s.name)}')">Übernehmen</button>
<button class="btn btn-secondary btn-small" onclick="showSourceAudit(${s.id}, '${esc(s.name)}')">Audit</button>`
: `<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button>
<button class="btn btn-secondary btn-small" onclick="showSourceAudit(${s.id}, '${esc(s.name)}')">Audit</button>
<button class="btn btn-danger btn-small" onclick="confirmDeleteGlobalSource(${s.id}, '${esc(s.name)}')">Löschen</button>`;
html += `<tr>
<td>${infoBtn} ${esc(s.name)}</td>
${selectCell}
<td class="sticky-name" title="${esc(s.name)}">${infoBtn} ${esc(s.name)}</td>
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.url || '')}">${esc(s.url || "-")}</td>
<td>${esc(s.domain || "-")}</td>
<td>${typeLabel(s.source_type)}</td>
<td class="text-right">${s.article_count || 0}</td>
<td class="${(s.articles_30d || 0) === 0 ? "activity-cell activity-zero" : "activity-cell"}" title="7 Tage / 30 Tage"><strong>${s.articles_7d || 0}</strong> / ${s.articles_30d || 0}</td>
<td class="text-right"><span class="${(s.tenant_excluded_count || 0) === 0 ? "exclude-badge exclude-zero" : "exclude-badge"}">${s.tenant_excluded_count || 0}</span></td>
<td class="text-secondary">${esc(s.language || "-")}</td>
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.bias || "")}">${esc(s.bias || "-")}</td>
<td class="text-secondary">${lastSeen}</td>
<td><span class="health-badge ${hsClass}">${hsLabel}</span></td>
<td>${healthCell}</td>
<td><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td>
<td>
<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button>
<button class="btn btn-secondary btn-small" onclick="showSourceAudit(${s.id}, '${esc(s.name)}')">Audit</button>
<button class="btn btn-danger btn-small" onclick="confirmDeleteGlobalSource(${s.id}, '${esc(s.name)}')">Löschen</button>
</td>
</tr>${notesRow}`;
<td class="text-secondary">${isTenant ? esc(s.org_name || ("Org " + s.tenant_id)) : '<span class="text-muted">—</span>'}</td>
<td class="sticky-actions">${actions}</td>
</tr>${detailRow}`;
});
});
tbody.innerHTML = html;
document.getElementById("globalSourceCount").textContent = `${sources.length} Grundquellen`;
const o = unifiedFilters.origin;
const originLabel = (o.has("global") && !o.has("tenant")) ? "Grundquellen"
: (o.has("tenant") && !o.has("global")) ? "Kundenquellen" : "Quellen";
document.getElementById("globalSourceCount").textContent = `${sources.length} ${originLabel}`;
updateBulkButton();
// Sort-Icons aktualisieren
document.querySelectorAll("th.sortable .sort-icon").forEach(el => el.textContent = "");
const activeHeader = document.querySelector(`th.sortable[data-sort="${globalSortField}"] .sort-icon`);
document.querySelectorAll("#unifiedTable th.sortable .sort-icon").forEach(el => el.textContent = "");
const activeHeader = document.querySelector(`#unifiedTable th.sortable[data-sort="${globalSortField}"] .sort-icon`);
if (activeHeader) activeHeader.textContent = globalSortAsc ? " ▲" : " ▼";
}
@@ -228,23 +374,25 @@ function renderGlobalSources(sources) {
document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById("globalSourceSearch");
if (el) {
el.addEventListener("input", () => filterGlobalSources());
el.addEventListener("input", () => filterUnifiedSources());
}
});
function filterGlobalSources() {
function filterUnifiedSources() {
const q = (document.getElementById("globalSourceSearch")?.value || "").toLowerCase();
const typeFilter = document.getElementById("globalFilterType")?.value || "";
const catFilter = document.getElementById("globalFilterCategory")?.value || "";
const statusFilter = document.getElementById("globalFilterStatus")?.value || "";
const f = unifiedFilters;
updateOriginControls();
const langFilter = document.getElementById("globalFilterLanguage")?.value || "";
let filtered = globalSourcesCache.filter((s) => {
if (q && !(s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.url || "").toLowerCase().includes(q) || (s.bias || "").toLowerCase().includes(q))) return false;
if (typeFilter && s.source_type !== typeFilter) return false;
if (catFilter && s.category !== catFilter) return false;
if (statusFilter && s.status !== statusFilter) return false;
if (langFilter && s.language !== langFilter) return false;
if (f.origin.size) {
const isTenant = s.tenant_id != null;
if (!((isTenant && f.origin.has("tenant")) || (!isTenant && f.origin.has("global")))) return false;
}
if (q && !(s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.url || "").toLowerCase().includes(q) || (s.bias || "").toLowerCase().includes(q) || (s.org_name || "").toLowerCase().includes(q))) return false;
if (f.types.size && !f.types.has(s.source_type)) return false;
if (f.cats.size && !f.cats.has(s.category)) return false;
if (f.status.size && !f.status.has(s.status)) return false;
if (f.langs.size && !f.langs.has(s.language)) return false;
return true;
});
@@ -252,44 +400,72 @@ function filterGlobalSources() {
filtered.sort((a, b) => {
let va = a[globalSortField] ?? "";
let vb = b[globalSortField] ?? "";
const NUMERIC_FIELDS = ["article_count", "articles_7d", "articles_30d", "tenant_excluded_count"];
const NUMERIC_FIELDS = ["article_count", "articles_7d", "articles_30d"];
if (NUMERIC_FIELDS.includes(globalSortField)) {
va = parseInt(va) || 0;
vb = parseInt(vb) || 0;
return globalSortAsc ? va - vb : vb - va;
}
if (globalSortField === "health_status") {
// Nach Schweregrad sortieren (error > warning > ok > unbekannt),
// nicht alphabetisch
const RANK = { error: 3, warning: 2, ok: 1 };
va = RANK[va] || 0;
vb = RANK[vb] || 0;
return globalSortAsc ? va - vb : vb - va;
}
va = String(va).toLowerCase();
vb = String(vb).toLowerCase();
const cmp = va.localeCompare(vb, "de");
return globalSortAsc ? cmp : -cmp;
});
renderGlobalSources(filtered);
renderUnifiedSources(filtered);
}
function sortGlobalSources(field) {
function sortUnifiedSources(field) {
if (globalSortField === field) {
globalSortAsc = !globalSortAsc;
} else {
globalSortField = field;
globalSortAsc = true;
}
filterGlobalSources();
filterUnifiedSources();
}
// Checkbox-Spalte + Bulk-Knopf nur, wenn der Herkunfts-Filter ausschließlich
// auf Kundenquellen steht. Beim Verlassen wird die Auswahl geleert.
function updateOriginControls() {
const o = unifiedFilters.origin;
const showSelect = o.has("tenant") && !o.has("global");
if (!showSelect && lastShowSelect) {
tenantSelected.clear();
}
lastShowSelect = showSelect;
const table = document.getElementById("unifiedTable");
if (table) table.classList.toggle("show-select", showSelect);
const btn = document.getElementById("tenantBulkPromoteBtn");
if (btn) btn.style.display = showSelect ? "" : "none";
}
// --- Grundquelle erstellen/bearbeiten ---
function openNewGlobalSource() {
async function openNewGlobalSource() {
editingSourceId = null;
document.getElementById("sourceModalTitle").textContent = "Neue Grundquelle";
document.getElementById("sourceForm").reset();
setAlignmentChips([]);
await ensureMeta();
fillSourceModalSelects();
document.getElementById("sourceType").value = "rss_feed";
document.getElementById("sourceCategory").value = "sonstige";
openModal("modalSource");
}
function editGlobalSource(id) {
async function editGlobalSource(id) {
const s = globalSourcesCache.find((x) => x.id === id);
if (!s) return;
editingSourceId = id;
await ensureMeta();
fillSourceModalSelects();
document.getElementById("sourceModalTitle").textContent = "Grundquelle bearbeiten";
document.getElementById("sourceName").value = s.name;
document.getElementById("sourceUrl").value = s.url || "";
@@ -306,7 +482,6 @@ function editGlobalSource(id) {
document.getElementById("sourceReliability").value = s.reliability || "";
document.getElementById("sourceCountryCode").value = s.country_code || "";
document.getElementById("sourceStateAffiliated").checked = !!s.state_affiliated;
setAlignmentChips(s.alignments || []);
openModal("modalSource");
}
@@ -330,14 +505,18 @@ function setupSourceForms() {
name: document.getElementById("sourceName").value,
url: document.getElementById("sourceUrl").value || null,
domain: document.getElementById("sourceDomain").value || null,
source_type: document.getElementById("sourceType").value,
category: document.getElementById("sourceCategory").value,
status: document.getElementById("sourceStatus").value,
notes: document.getElementById("sourceNotes").value || null,
language: document.getElementById("sourceLanguage").value || null,
bias: document.getElementById("sourceBias").value || null,
fetch_strategy: document.getElementById("sourceFetchStrategy").value || "default",
};
// Leere Werte NIE mitsenden. Ein leerer Select-Wert (z.B. META noch nicht
// geladen) würde sonst die Kategorie serverseitig still überschreiben.
const st = document.getElementById("sourceType").value;
if (st) body.source_type = st;
const cat = document.getElementById("sourceCategory").value;
if (cat) body.category = cat;
const pol = document.getElementById("sourcePolitical")?.value;
if (pol) body.political_orientation = pol;
@@ -349,7 +528,6 @@ function setupSourceForms() {
if (cc) body.country_code = cc;
if (editingSourceId) {
body.state_affiliated = !!document.getElementById("sourceStateAffiliated")?.checked;
body.alignments = getAlignmentChips();
}
try {
@@ -359,7 +537,7 @@ function setupSourceForms() {
await API.post("/api/sources/global", body);
}
closeModal("modalSource");
loadGlobalSources();
loadUnifiedSources();
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = "block";
@@ -384,93 +562,18 @@ function confirmDeleteGlobalSource(id, name) {
async () => {
try {
await API.del("/api/sources/global/" + id);
loadGlobalSources();
loadUnifiedSources();
} catch (err) {
showToast(err.message, "error");
}
}
},
{ danger: true }
);
}
// --- Kundenquellen ---
async function loadTenantSources() {
try {
tenantSourcesCache = await API.get("/api/sources/tenant");
tenantSelected.clear();
populateTenantFilters();
applyTenantFilterAndSort();
} catch (err) {
console.error("Kundenquellen laden fehlgeschlagen:", err);
showToast("Kundenquellen konnten nicht geladen werden", "error");
}
}
function populateTenantFilters() {
// Typ + Kategorie aus META, Org aus Cache
if (window.META && window.META.types) {
populateSelect(document.getElementById("tenantFilterType"),
window.META.types.filter(t => t.key !== "excluded"), "Alle Typen");
}
if (window.META && window.META.categories) {
populateSelect(document.getElementById("tenantFilterCategory"),
window.META.categories, "Alle Kategorien");
}
// Org-Liste aus den Daten extrahieren (eindeutig)
const orgs = Array.from(new Set(tenantSourcesCache.map(s => s.org_name).filter(Boolean))).sort();
populateSelect(
document.getElementById("tenantFilterOrg"),
orgs.map(o => ({ key: o, label: o })),
"Alle Organisationen",
);
}
function applyTenantFilterAndSort() {
const q = (tenantFilters.search || "").toLowerCase();
let filtered = tenantSourcesCache.filter(s => {
if (q && !(
(s.name || "").toLowerCase().includes(q)
|| (s.domain || "").toLowerCase().includes(q)
|| (s.org_name || "").toLowerCase().includes(q)
|| (s.url || "").toLowerCase().includes(q)
)) return false;
if (tenantFilters.type && s.source_type !== tenantFilters.type) return false;
if (tenantFilters.category && s.category !== tenantFilters.category) return false;
if (tenantFilters.org && s.org_name !== tenantFilters.org) return false;
if (tenantFilters.language && s.language !== tenantFilters.language) return false;
return true;
});
filtered.sort((a, b) => {
const va = String(a[tenantSort.field] ?? "").toLowerCase();
const vb = String(b[tenantSort.field] ?? "").toLowerCase();
const cmp = va.localeCompare(vb, "de");
return tenantSort.asc ? cmp : -cmp;
});
renderTenantSources(filtered);
// Sort-Icons aktualisieren
document.querySelectorAll("#sub-tenant-sources th.sortable .sort-icon").forEach(el => el.textContent = "");
const active = document.querySelector(`#sub-tenant-sources th.sortable[data-sort="${tenantSort.field}"] .sort-icon`);
if (active) active.textContent = tenantSort.asc ? " \u25B2" : " \u25BC";
}
function filterTenantSources() {
tenantFilters.search = (document.getElementById("tenantSourceSearch")?.value || "").trim();
tenantFilters.type = document.getElementById("tenantFilterType")?.value || "";
tenantFilters.category = document.getElementById("tenantFilterCategory")?.value || "";
tenantFilters.org = document.getElementById("tenantFilterOrg")?.value || "";
tenantFilters.language = document.getElementById("tenantFilterLanguage")?.value || "";
applyTenantFilterAndSort();
}
function sortTenantSources(field) {
if (tenantSort.field === field) tenantSort.asc = !tenantSort.asc;
else { tenantSort.field = field; tenantSort.asc = true; }
applyTenantFilterAndSort();
}
// --- Bulk-Promote (Kundenquellen in der vereinten Liste) ---
function toggleTenantSelectAll(checked) {
document.querySelectorAll("#tenantSourceTable input.tenant-select").forEach(cb => {
document.querySelectorAll("#globalSourceTable input.tenant-select").forEach(cb => {
cb.checked = checked;
const id = parseInt(cb.dataset.id);
if (checked) tenantSelected.add(id); else tenantSelected.delete(id);
@@ -483,9 +586,9 @@ function toggleTenantSelect(id, checked) {
if (checked) tenantSelected.add(id); else tenantSelected.delete(id);
updateBulkButton();
// Header-Checkbox anpassen
const visible = document.querySelectorAll("#tenantSourceTable input.tenant-select").length;
const checkedVisible = document.querySelectorAll("#tenantSourceTable input.tenant-select:checked").length;
const all = document.getElementById("tenantSelectAll");
const visible = document.querySelectorAll("#globalSourceTable input.tenant-select").length;
const checkedVisible = document.querySelectorAll("#globalSourceTable input.tenant-select:checked").length;
const all = document.getElementById("unifiedSelectAll");
if (all) all.checked = visible > 0 && visible === checkedVisible;
}
@@ -512,50 +615,12 @@ async function bulkPromoteSelected() {
if (result.failed && result.failed.length) msg += `, ${result.failed.length} Fehler`;
showToast(msg, result.failed && result.failed.length ? "warning" : "success");
tenantSelected.clear();
await loadTenantSources();
await loadUnifiedSources();
} catch (err) {
showToast("Bulk-Promote fehlgeschlagen: " + err.message, "error");
}
}
function renderTenantSources(sources) {
const tbody = document.getElementById("tenantSourceTable");
const cols = 10;
if (sources.length === 0) {
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Kundenquellen</td></tr>`;
document.getElementById("tenantSourceCount").textContent = `0 / ${tenantSourcesCache.length} Kundenquellen`;
updateBulkButton();
return;
}
tbody.innerHTML = sources.map((s) => {
const checked = tenantSelected.has(s.id) ? "checked" : "";
return `
<tr>
<td><input type="checkbox" class="tenant-select" data-id="${s.id}" ${checked} onchange="toggleTenantSelect(${s.id}, this.checked)"></td>
<td>${esc(s.name)}</td>
<td class="text-secondary" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.url || '')}">${esc(s.domain || "-")}</td>
<td>${typeLabel(s.source_type)}</td>
<td>${categoryLabel(s.category)}</td>
<td>${esc(s.org_name || "-")}</td>
<td class="text-secondary">${esc(s.language || "-")}</td>
<td class="text-secondary" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.bias || "")}">${esc(s.bias || "-")}</td>
<td>${esc(s.added_by || "-")}</td>
<td>
<button class="btn btn-primary btn-small" onclick="promoteSource(${s.id}, '${esc(s.name)}')">Übernehmen</button>
</td>
</tr>`;
}).join("");
document.getElementById("tenantSourceCount").textContent = `${sources.length} / ${tenantSourcesCache.length} Kundenquellen`;
updateBulkButton();
}
// Suche Kundenquellen
document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById("tenantSourceSearch");
if (el) el.addEventListener("input", () => filterTenantSources());
});
function promoteSource(id, name) {
showConfirm(
"Zur Grundquelle machen",
@@ -563,7 +628,71 @@ function promoteSource(id, name) {
async () => {
try {
await API.post("/api/sources/tenant/" + id + "/promote");
loadTenantSources();
tenantSelected.delete(id);
loadUnifiedSources();
} catch (err) {
showToast(err.message, "error");
}
}
);
}
// --- Health-Details in der Ausklapp-Zeile ---
async function toggleHealthDetail(id) {
const row = document.getElementById("detail-" + id);
if (!row) return;
const isVisible = row.style.display !== "none";
toggleSourceInfo(id);
if (isVisible) return; // wurde gerade zugeklappt
const box = document.getElementById("health-detail-" + id);
if (!box) return;
if (sourceHealthDetailCache.has(id)) {
renderHealthDetail(id, sourceHealthDetailCache.get(id));
return;
}
box.innerHTML = '<div class="text-muted">Lade Health-Details…</div>';
try {
const res = await API.get("/api/sources/" + id + "/health");
sourceHealthDetailCache.set(id, res.checks || []);
renderHealthDetail(id, res.checks || []);
} catch (err) {
box.innerHTML = `<div class="text-danger">Health-Details konnten nicht geladen werden. ${esc(err.message || "")}</div>`;
}
}
function renderHealthDetail(id, checks) {
const box = document.getElementById("health-detail-" + id);
if (!box) return;
const s = globalSourcesCache.find((x) => x.id === id);
const deactivateBtn = s && s.status === "active"
? `<button class="btn btn-danger btn-small" onclick="deactivateSource(${id}, '${esc(s.name)}')">Quelle deaktivieren</button>`
: "";
if (!checks.length) {
box.innerHTML = `<div class="health-detail-head"><span class="text-muted">Keine Health-Checks für diese Quelle vorhanden.</span>${deactivateBtn}</div>`;
return;
}
const statusLabel = (st) => st === "error" ? "Fehler" : st === "warning" ? "Warnung" : "OK";
const rows = checks.map((c) => `
<div class="health-detail-row">
<span class="health-badge health-badge-${c.status === "error" ? "error" : c.status === "warning" ? "warning" : "ok"}">${statusLabel(c.status)}</span>
<span class="health-detail-type">${CHECK_TYPE_LABELS[c.check_type] || esc(c.check_type)}</span>
<span class="health-detail-msg">${esc(c.message || "")}</span>
<span class="text-secondary health-detail-ts">${formatDateTime(c.checked_at)}</span>
</div>`).join("");
box.innerHTML = `<div class="health-detail-head"><strong>Health-Checks</strong>${deactivateBtn}</div>${rows}`;
}
function deactivateSource(id, name) {
showConfirm(
"Quelle deaktivieren",
`Soll die Quelle "${name}" deaktiviert werden? Sie wird dann nicht mehr abgerufen.`,
async () => {
try {
await API.put("/api/sources/global/" + id, { status: "inactive" });
showToast("Quelle wurde deaktiviert.", "success");
sourceHealthDetailCache.delete(id);
loadUnifiedSources();
} catch (err) {
showToast(err.message, "error");
}
@@ -655,7 +784,7 @@ async function addDiscoveredFeeds() {
try {
const result = await API.post("/api/sources/discover/add", selected);
closeModal("modalDiscover");
loadGlobalSources();
loadUnifiedSources();
showToast(result.added + " Grundquelle(n) hinzugefügt" + (result.skipped ? ", " + result.skipped + " übersprungen" : ""), "success");
} catch (err) {
showToast("Fehler: " + err.message, "error");
@@ -666,214 +795,8 @@ async function addDiscoveredFeeds() {
}
// === Klassifikations-Review ===
const POLITICAL_LABELS = {
links_extrem: { short: "L+", full: "Links (extrem)" },
links: { short: "L", full: "Links" },
mitte_links: { short: "ML", full: "Mitte-Links" },
liberal: { short: "LIB", full: "Liberal" },
mitte: { short: "M", full: "Mitte" },
konservativ: { short: "KON", full: "Konservativ" },
mitte_rechts: { short: "MR", full: "Mitte-Rechts" },
rechts: { short: "R", full: "Rechts" },
rechts_extrem: { short: "R+", full: "Rechts (extrem)" },
na: { short: "?", full: "Nicht eingeordnet" },
};
const RELIABILITY_LABELS = {
sehr_hoch: "Sehr hoch", hoch: "Hoch", gemischt: "Gemischt",
niedrig: "Niedrig", sehr_niedrig: "Sehr niedrig", na: "Nicht eingeordnet",
};
const MEDIA_TYPE_LABELS = {
tageszeitung: "Tageszeitung", wochenzeitung: "Wochenzeitung", magazin: "Magazin",
tv_sender: "TV-Sender", radio: "Radio", oeffentlich_rechtlich: "Öffentlich-Rechtlich",
nachrichtenagentur: "Nachrichtenagentur", online_only: "Online-only", blog: "Blog",
telegram_kanal: "Telegram-Kanal", telegram_bot: "Telegram-Bot", podcast: "Podcast",
social_media: "Social Media", imageboard: "Imageboard", think_tank: "Think Tank",
ngo: "NGO", behoerde: "Behörde", staatsmedium: "Staatsmedium",
fachmedium: "Fachmedium", sonstige: "Sonstige",
};
const ALIGNMENT_LABELS = {
prorussisch: "prorussisch", proiranisch: "proiranisch", prowestlich: "prowestlich",
proukrainisch: "proukrainisch", prochinesisch: "prochinesisch", projapanisch: "projapanisch",
proisraelisch: "proisraelisch", propalaestinensisch: "propalästinensisch",
protuerkisch: "protürkisch", panarabisch: "panarabisch", neutral: "neutral", sonstige: "sonstige",
};
function setAlignmentChips(active) {
const chips = document.querySelectorAll("#sourceAlignmentChips .alignment-chip");
const set = new Set((active || []).map((a) => (a || "").toLowerCase()));
chips.forEach((chip) => {
if (set.has(chip.dataset.alignment)) chip.classList.add("active");
else chip.classList.remove("active");
});
}
function getAlignmentChips() {
return Array.from(document.querySelectorAll("#sourceAlignmentChips .alignment-chip.active"))
.map((chip) => chip.dataset.alignment);
}
function handleAlignmentChipClick(e) {
const chip = e.target.closest(".alignment-chip");
if (!chip) return;
e.preventDefault();
chip.classList.toggle("active");
}
async function refreshClassificationStats() {
try {
const stats = await API.get("/api/sources/classification/stats");
const badge = document.getElementById("classificationPendingBadge");
if (badge) badge.textContent = String(stats.pending_review || 0);
} catch (_) { /* still ok */ }
}
async function loadClassificationQueue() {
const list = document.getElementById("classificationReviewList");
if (!list) return;
const minConf = parseFloat(document.getElementById("reviewMinConfidence")?.value || "0");
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Lade…</div>';
try {
const items = await API.get(`/api/sources/classification/queue?limit=200&min_confidence=${minConf}`);
const countEl = document.getElementById("reviewPendingCount");
if (countEl) countEl.textContent = String(items.length);
refreshClassificationStats();
if (items.length === 0) {
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Keine ausstehenden Vorschläge.</div>';
return;
}
list.innerHTML = items.map((it) => renderClassificationQueueItem(it)).join("");
} catch (err) {
list.innerHTML = `<div class="text-danger" style="padding:24px;text-align:center;">Fehler: ${esc(err.message)}</div>`;
}
}
function renderClassificationQueueItem(item) {
const cur = item.current || {};
const prop = item.proposed || {};
const conf = prop.confidence || 0;
const confPct = Math.round(conf * 100);
const confClass = conf >= 0.85 ? "high" : conf >= 0.7 ? "medium" : "low";
const polFmt = (v) => (v && v !== "na" ? POLITICAL_LABELS[v]?.full || v : "–");
const mtFmt = (v) => (v ? MEDIA_TYPE_LABELS[v] || v : "–");
const relFmt = (v) => (v && v !== "na" ? RELIABILITY_LABELS[v] || v : "–");
const stateFmt = (v) => (v ? "ja" : "nein");
const ccFmt = (v) => v || "–";
const alignFmt = (v) =>
Array.isArray(v) && v.length > 0 ? v.map((a) => ALIGNMENT_LABELS[a] || a).join(", ") : "–";
const row = (label, c, p, fmt) => {
const cs = fmt(c);
const ps = fmt(p);
const changed = cs !== ps;
return `<div class="review-diff-row${changed ? " changed" : ""}">
<span class="review-diff-label">${esc(label)}</span>
<span class="review-diff-current">${esc(cs)}</span>
<span class="review-diff-arrow">→</span>
<span class="review-diff-proposed">${esc(ps)}</span>
</div>`;
};
const reasoning = prop.reasoning ? esc(prop.reasoning) : "";
return `<div class="review-card" data-source-id="${item.id}">
<div class="review-card-header">
<div class="review-card-title">
<span class="review-card-name">${esc(item.name)}</span>
${item.is_global ? '<span class="review-global-badge">Grundquelle</span>' : ""}
<span class="review-card-domain">${esc(item.domain || "")}</span>
</div>
<div class="review-card-confidence conf-${confClass}" title="LLM-Konfidenz">
<span class="conf-value">${confPct}%</span>
<span class="conf-label">Konfidenz</span>
</div>
</div>
<div class="review-card-diff">
${row("Politik", cur.political_orientation, prop.political_orientation, polFmt)}
${row("Medientyp", cur.media_type, prop.media_type, mtFmt)}
${row("Glaubwürdigkeit", cur.reliability, prop.reliability, relFmt)}
${row("Staatsnah", cur.state_affiliated, prop.state_affiliated, stateFmt)}
${row("Land", cur.country_code, prop.country_code, ccFmt)}
${row("Geopol. Nähe", cur.alignments, prop.alignments, alignFmt)}
</div>
${reasoning ? `<div class="review-card-reasoning"><strong>Begründung:</strong> ${reasoning}</div>` : ""}
<div class="review-card-actions">
<button class="btn btn-small btn-primary" onclick="approveClassification(${item.id})">Übernehmen</button>
<button class="btn btn-small btn-secondary" onclick="rejectClassification(${item.id})">Verwerfen</button>
<button class="btn btn-small btn-secondary" data-reclassify-id="${item.id}" onclick="reclassifySource(${item.id})">Neu klassifizieren</button>
</div>
</div>`;
}
async function approveClassification(id) {
try {
await API.post(`/api/sources/${id}/classification/approve`, {});
showToast("Klassifikation übernommen.", "success");
loadClassificationQueue();
} catch (err) {
showToast("Approve fehlgeschlagen: " + err.message, "error");
}
}
async function rejectClassification(id) {
try {
await API.post(`/api/sources/${id}/classification/reject`, {});
showToast("Vorschlag verworfen.", "success");
loadClassificationQueue();
} catch (err) {
showToast("Reject fehlgeschlagen: " + err.message, "error");
}
}
async function reclassifySource(id) {
const btn = document.querySelector(`[data-reclassify-id="${id}"]`);
if (btn) { btn.disabled = true; btn.textContent = "..."; }
try {
await API.post(`/api/sources/${id}/classification/reclassify`, {});
showToast("Neu klassifiziert.", "success");
loadClassificationQueue();
} catch (err) {
showToast("Reclassify fehlgeschlagen: " + err.message, "error");
} finally {
if (btn) { btn.disabled = false; btn.textContent = "Neu klassifizieren"; }
}
}
async function triggerBulkClassify() {
if (!confirm("Bulk-Klassifikation aller noch nicht klassifizierten Quellen starten? Läuft im Hintergrund (~3-5 Sek pro Quelle, ~0.02 USD pro Quelle).")) return;
try {
const r = await API.post("/api/sources/classification/bulk-classify?limit=500&only_unclassified=true", {});
showToast(`Bulk-Klassifikation gestartet (limit=${r.limit}). In ~10 min neu laden.`, "info");
} catch (err) {
showToast("Start fehlgeschlagen: " + err.message, "error");
}
}
async function bulkApproveHighConfidence() {
if (!confirm("Alle Vorschläge mit Konfidenz ≥ 0.85 genehmigen?")) return;
try {
const r = await API.post("/api/sources/classification/bulk-approve?min_confidence=0.85", {});
showToast(`${r.approved} Vorschläge übernommen.`, "success");
loadClassificationQueue();
} catch (err) {
showToast("Bulk-Approve fehlgeschlagen: " + err.message, "error");
}
}
async function triggerExternalReputationSync() {
if (!confirm("IFCN- und EUvsDisinfo-Datenbanken jetzt syncen? Läuft im Hintergrund (~30 Sek).")) return;
try {
await API.post("/api/sources/external-reputation/sync", {});
showToast("Externer Sync gestartet. Quellenliste in ~30 Sek neu laden.", "info");
} catch (err) {
showToast("Sync fehlgeschlagen: " + err.message, "error");
}
}
function toggleSourceInfo(id) {
const row = document.getElementById("notes-" + id);
const row = document.getElementById("detail-" + id);
if (!row) return;
const isVisible = row.style.display !== "none";
row.style.display = isVisible ? "none" : "table-row";
@@ -885,9 +808,11 @@ function toggleSourceInfo(id) {
}
// --- PDF-Quellen-Upload ---
function openPdfUploadModal() {
async function openPdfUploadModal() {
const form = document.getElementById("pdfUploadForm");
if (form) form.reset();
await ensureMeta();
fillSelect(document.getElementById("pdfCategory"), (window.META && window.META.categories) || [], { value: "sonstige" });
const err = document.getElementById("pdfUploadError");
if (err) { err.style.display = "none"; err.textContent = ""; }
const prog = document.getElementById("pdfUploadProgress");
@@ -938,7 +863,7 @@ function setupPdfUploadForm() {
if (typeof showToast === "function") {
showToast("PDF hochgeladen -- Verarbeitung laeuft im Hintergrund", "success");
}
loadGlobalSources();
loadUnifiedSources();
} catch (err) {
errEl.textContent = err.message || "Upload fehlgeschlagen";
errEl.style.display = "block";

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

@@ -0,0 +1,440 @@
"use strict";
// Statistik-Tab für das Verwaltungsportal. Selbst-injizierend nach dem
// Muster von rechner.js, damit dashboard.html unverändert bleibt.
// Charts sind handgerendertes Inline-SVG ohne Bibliothek. Die Serienfarben
// sind mit dem dataviz-Palette-Validator gegen beide Themes geprüft
// (Light #96791A/#2a78d6/#199e70, Dark #a8863a/#3987e5/#199e70, alle Checks PASS).
(function () {
var geladen = false;
var aktMonate = 12;
// ---------- Format-Helfer ----------
function fmt(n) { return Number(n || 0).toLocaleString("de-DE"); }
function fmtUsd(n) {
return "$" + Number(n || 0).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function fmtMonat(ym) {
if (!ym) return "";
var namen = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
return namen[parseInt(ym.slice(5, 7), 10) - 1] + " " + ym.slice(2, 4);
}
function fmtDatum(s) {
if (!s) return "-";
var d = String(s).slice(0, 10).split("-");
return d.length === 3 ? d[2] + "." + d[1] + "." + d[0] : s;
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
// ---------- SVG-Renderer ----------
// Eine Achse pro Chart, rezessives Grid, 2px-Lücken zwischen Stapel-
// Segmenten, Hover über grosszügige unsichtbare Trefferflächen mit <title>.
var W = 720, H = 230, ML = 52, MR = 8, MT = 10, MB = 26;
function niceMax(v) {
if (v <= 0) return 1;
var mag = Math.pow(10, Math.floor(Math.log10(v)));
var norm = v / mag;
var step = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
return step * mag;
}
function achsen(maxY, einheit) {
var plotH = H - MT - MB;
var out = "";
for (var i = 0; i <= 3; i++) {
var y = MT + plotH - (plotH * i) / 3;
var wert = (maxY * i) / 3;
out += '<line x1="' + ML + '" y1="' + y + '" x2="' + (W - MR) + '" y2="' + y +
'" stroke="var(--border)" stroke-width="1"/>';
out += '<text x="' + (ML - 6) + '" y="' + (y + 3) + '" text-anchor="end" font-size="10" fill="var(--text-muted)">' +
esc(kurz(wert, einheit)) + "</text>";
}
return out;
}
function kurz(v, einheit) {
var s;
if (v >= 1000000) s = (v / 1000000).toLocaleString("de-DE", { maximumFractionDigits: 1 }) + " Mio";
else if (v >= 10000) s = Math.round(v / 1000).toLocaleString("de-DE") + "k";
else s = v.toLocaleString("de-DE", { maximumFractionDigits: v < 10 ? 1 : 0 });
return einheit === "usd" ? s + " $" : s;
}
function xLabels(labels) {
var n = labels.length;
if (!n) return "";
var plotW = W - ML - MR;
var schritt = n > 8 ? 2 : 1;
var out = "";
for (var i = 0; i < n; i++) {
if (i % schritt !== 0) continue;
var x = ML + (plotW * (i + 0.5)) / n;
out += '<text x="' + x + '" y="' + (H - 8) + '" text-anchor="middle" font-size="10" fill="var(--text-muted)">' +
esc(labels[i]) + "</text>";
}
return out;
}
// serien: [{name, farbe, werte[]}], gestapelt. einheit: 'usd' | ''
function chartBalken(labels, serien, einheit) {
var n = labels.length;
if (!n) return '<div class="st-leer">Keine Daten im Zeitraum</div>';
var summen = labels.map(function (_, i) {
return serien.reduce(function (a, s) { return a + (s.werte[i] || 0); }, 0);
});
var maxY = niceMax(Math.max.apply(null, summen.concat([0])));
var plotW = W - ML - MR, plotH = H - MT - MB;
var breite = Math.min(40, (plotW / n) * 0.62);
var svg = achsen(maxY, einheit);
for (var i = 0; i < n; i++) {
var x = ML + (plotW * (i + 0.5)) / n - breite / 2;
var yCursor = MT + plotH;
var titel = [fmtMonat(labels[i]) || labels[i]];
for (var s = 0; s < serien.length; s++) {
var wert = serien[s].werte[i] || 0;
titel.push(serien[s].name + ": " + (einheit === "usd" ? fmtUsd(wert) : fmt(wert)));
if (wert <= 0) continue;
var hoehe = (wert / maxY) * plotH;
yCursor -= hoehe;
// 2px-Lücke zwischen Segmenten, oberstes Segment leicht gerundet
var segH = Math.max(0, hoehe - 2);
if (segH > 0) {
svg += '<rect x="' + x + '" y="' + yCursor + '" width="' + breite + '" height="' + segH +
'" rx="2" fill="' + serien[s].farbe + '"/>';
}
}
if (summen[i] <= 0) titel.push("Keine Daten");
svg += '<rect x="' + (ML + (plotW * i) / n) + '" y="' + MT + '" width="' + (plotW / n) +
'" height="' + plotH + '" fill="transparent"><title>' + esc(titel.join("\n")) + "</title></rect>";
}
svg += xLabels(labels.map(fmtMonat));
return '<svg viewBox="0 0 ' + W + " " + H + '" style="width:100%;height:auto;display:block;" role="img">' + svg + "</svg>";
}
// serien: [{name, farbe, werte[]}] mit null-Lücken. xIstTag = Tageslabels
function chartLinie(labels, serien, einheit, xIstTag) {
var n = labels.length;
if (!n) return '<div class="st-leer">Keine Daten im Zeitraum</div>';
var alle = [];
serien.forEach(function (s) { s.werte.forEach(function (v) { if (v != null) alle.push(v); }); });
if (!alle.length) return '<div class="st-leer">Keine Daten im Zeitraum</div>';
var maxY = niceMax(Math.max.apply(null, alle));
var plotW = W - ML - MR, plotH = H - MT - MB;
function px(i) { return ML + (plotW * (i + 0.5)) / n; }
function py(v) { return MT + plotH - (v / maxY) * plotH; }
var svg = achsen(maxY, einheit);
serien.forEach(function (s) {
var punkte = [];
for (var i = 0; i < n; i++) {
if (s.werte[i] == null) continue;
punkte.push(px(i).toFixed(1) + "," + py(s.werte[i]).toFixed(1));
}
if (punkte.length > 1) {
svg += '<polyline points="' + punkte.join(" ") + '" fill="none" stroke="' + s.farbe +
'" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
}
for (var j = 0; j < n; j++) {
if (s.werte[j] == null) continue;
svg += '<circle cx="' + px(j) + '" cy="' + py(s.werte[j]) + '" r="2.5" fill="' + s.farbe + '"/>';
svg += '<circle cx="' + px(j) + '" cy="' + py(s.werte[j]) + '" r="9" fill="transparent"><title>' +
esc((xIstTag ? fmtDatum(labels[j]) : fmtMonat(labels[j])) + "\n" + s.name + ": " +
(einheit === "usd" ? fmtUsd(s.werte[j]) : fmt(s.werte[j]))) + "</title></circle>";
}
});
svg += xLabels(xIstTag ? labels.map(function (t, i) { return i % Math.ceil(n / 8) === 0 ? String(t).slice(8, 10) + "." + String(t).slice(5, 7) + "." : ""; }) : labels.map(fmtMonat));
return '<svg viewBox="0 0 ' + W + " " + H + '" style="width:100%;height:auto;display:block;" role="img">' + svg + "</svg>";
}
function legende(eintraege) {
return '<div class="st-legende">' + eintraege.map(function (e) {
return '<span><span class="st-dot" style="background:' + e.farbe + '"></span>' + esc(e.name) + "</span>";
}).join("") + "</div>";
}
function kpiKachel(label, wert, sub) {
return '<div class="stat-card"><div class="stat-label">' + esc(label) + '</div>' +
'<div class="stat-value">' + wert + "</div>" +
(sub ? '<div class="stat-sub">' + sub + "</div>" : "") + "</div>";
}
function chartCard(titel, inhalt, legendeHtml) {
return '<div class="card"><div class="card-header"><h2>' + esc(titel) + "</h2>" +
(legendeHtml || "") + '</div><div class="card-body">' + inhalt + "</div></div>";
}
// ---------- Rendering ----------
function C1() { return "var(--st-c1)"; }
function C2() { return "var(--st-c2)"; }
function C3() { return "var(--st-c3)"; }
function render(d) {
var el = document.getElementById("st-inhalt");
if (!el) return;
var monate = d.window.monate || [];
var mt = {};
(d.monat_typ || []).forEach(function (r) { mt[r.monat + "|" + r.typ] = r; });
function reihe(typ, feld) {
return monate.map(function (m) { var r = mt[m + "|" + typ]; return r ? r[feld] : 0; });
}
function reiheNull(typ, feld) {
return monate.map(function (m) { var r = mt[m + "|" + typ]; return r && r[feld] != null ? r[feld] : null; });
}
// Übrige Quellen (globe/chat/enhance) je Monat zusammenfassen
var nebenKosten = {};
(d.monat_quelle || []).forEach(function (r) {
if (r.quelle === "monitor") return;
nebenKosten[r.monat] = (nebenKosten[r.monat] || 0) + (r.kosten || 0);
});
var kpi = d.kpi || {};
var html = "";
// --- Kosten & Marge ---
html += '<h3 class="st-gruppe">Kosten &amp; Marge</h3>';
html += '<div class="stats-grid">' +
kpiKachel("API-Kosten im Zeitraum", fmtUsd(kpi.kosten_usd), fmt(kpi.laeufe_gesamt) + " Läufe gesamt") +
kpiKachel("Erfolgreiche Läufe", fmt(kpi.laeufe_ok), kpi.erfolgsquote != null ? kpi.erfolgsquote.toLocaleString("de-DE") + "% Erfolgsquote" : "") +
kpiKachel("Median je Lauf", kpi.median_kosten_lauf != null ? fmtUsd(kpi.median_kosten_lauf) : "-", "nur Läufe mit Kosten") +
kpiKachel("Artikel gefunden", fmt(kpi.artikel), fmt(kpi.tokens_in) + " Tokens ein / " + fmt(kpi.tokens_out) + " aus") +
"</div>";
html += chartCard("API-Kosten je Monat (USD)",
chartBalken(monate, [
{ name: "Live-Monitoring", farbe: C1(), werte: reihe("adhoc", "kosten") },
{ name: "Recherche", farbe: C2(), werte: reihe("research", "kosten") },
{ name: "Übrige Quellen", farbe: C3(), werte: monate.map(function (m) { return Math.round((nebenKosten[m] || 0) * 100) / 100; }) },
], "usd"),
legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }, { name: "Übrige Quellen", farbe: C3() }]));
html += chartCard("Median-Kosten je Lauf (USD), der Frühwarner für Kosten-Anstieg",
chartLinie(monate, [
{ name: "Live-Monitoring", farbe: C1(), werte: reiheNull("adhoc", "kosten_median") },
{ name: "Recherche", farbe: C2(), werte: reiheNull("research", "kosten_median") },
], "usd"),
legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }]));
// Orgs-Tabelle
var orgZeilen = (d.orgs || []).map(function (o) {
var margeCls = o.marge_usd >= 0 ? "st-pos" : "st-neg";
return "<tr><td>" + esc(o.name) + "</td><td>" + fmt(o.laeufe_ok) + " / " + fmt(o.laeufe_gesamt) + "</td>" +
"<td>" + fmtUsd(o.kosten_usd) + "</td><td>" + fmt(o.credits_geschaetzt) + "</td>" +
"<td>" + fmtUsd(o.gegenwert_usd) + '</td><td class="' + margeCls + '">' + fmtUsd(o.marge_usd) + "</td></tr>";
}).join("");
html += '<div class="card"><div class="card-header"><h2>Je Organisation (Zeitraum)</h2></div>' +
'<div class="table-wrap"><table><thead><tr><th>Organisation</th><th>Läufe ok/gesamt</th>' +
"<th>Ist-Kosten</th><th>Credits (geschätzt)</th><th>Gegenwert</th><th>Marge</th></tr></thead>" +
"<tbody>" + (orgZeilen || '<tr><td colspan="6" class="text-muted">Keine Daten</td></tr>') + "</tbody></table></div>" +
'<div class="card-body st-fussnote">Schätzung zu den heutigen Sätzen (billing_tariff), es gibt keine historisierten Buchungen. ' +
"Recherche-Anlage zählt als 3 Durchläufe (erster abgeschlossener Lauf je Recherche-Lage). " +
"Studio-Bausteine (analysis/factcheck) fehlen mangels Nutzungsdaten, der Gegenwert ist eher zu niedrig. " +
"Ist-Kosten enthalten auch Fehlläufe. 1 Credit = " + fmtUsd(d.tarif.usd_per_credit) + ".</div></div>";
// --- Nutzung ---
html += '<h3 class="st-gruppe">Nutzung</h3>';
var mb = {};
(d.monat_betrieb || []).forEach(function (r) { mb[r.monat] = r; });
function bReihe(feld) { return monate.map(function (m) { return mb[m] ? mb[m][feld] : 0; }); }
html += chartCard("Erfolgreiche Läufe je Monat",
chartBalken(monate, [
{ name: "Automatisch", farbe: C1(), werte: bReihe("auto") },
{ name: "Manuell", farbe: C2(), werte: bReihe("manual") },
], ""),
legende([{ name: "Automatisch", farbe: C1() }, { name: "Manuell", farbe: C2() }]));
html += chartCard("Gefundene Artikel je Monat",
chartBalken(monate, [
{ name: "Live-Monitoring", farbe: C1(), werte: reihe("adhoc", "artikel") },
{ name: "Recherche", farbe: C2(), werte: reihe("research", "artikel") },
], ""),
legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }]));
// --- Betrieb ---
html += '<h3 class="st-gruppe">Betrieb</h3>';
var dauerA = d.dauer_typ && d.dauer_typ.adhoc != null ? Math.round(d.dauer_typ.adhoc / 60) + " Min" : "-";
var dauerR = d.dauer_typ && d.dauer_typ.research != null ? Math.round(d.dauer_typ.research / 60) + " Min" : "-";
html += '<div class="stats-grid">' +
kpiKachel("Erfolgsquote", kpi.erfolgsquote != null ? kpi.erfolgsquote.toLocaleString("de-DE") + "%" : "-",
fmt(kpi.laeufe_ok) + " von " + fmt(kpi.laeufe_gesamt) + " Läufen") +
kpiKachel("Dauer Live-Lauf (ø)", dauerA, "") +
kpiKachel("Dauer Recherche-Durchlauf (ø)", dauerR, "") +
"</div>";
html += chartCard("Läufe nach Ausgang je Monat",
chartBalken(monate, [
{ name: "Erfolgreich", farbe: "var(--success)", werte: bReihe("completed") },
{ name: "Fehler", farbe: "var(--danger)", werte: bReihe("error") },
{ name: "Abgebrochen", farbe: "var(--text-muted)", werte: bReihe("cancelled") },
], ""),
legende([{ name: "Erfolgreich", farbe: "var(--success)" }, { name: "Fehler", farbe: "var(--danger)" }, { name: "Abgebrochen", farbe: "var(--text-muted)" }]));
var fehlerZeilen = (d.fehler_top || []).map(function (f) {
return "<tr><td>" + esc(f.grund) + "</td><td>" + fmt(f.n) + "</td></tr>";
}).join("");
html += '<div class="card"><div class="card-header"><h2>Häufigste Fehlerursachen</h2></div>' +
'<div class="table-wrap"><table><thead><tr><th>Ursache</th><th>Anzahl</th></tr></thead><tbody>' +
(fehlerZeilen || '<tr><td colspan="2" class="text-muted">Keine Fehler im Zeitraum</td></tr>') +
"</tbody></table></div></div>";
// --- Aktivität ---
html += '<h3 class="st-gruppe">Aktivität</h3>';
var akt = d.aktivitaet || {};
html += '<div class="stats-grid">' +
kpiKachel("Nutzer mit Login (30 Tage)", fmt(akt.nutzer_30d), "Momentaufnahme aus dem letzten Login") +
kpiKachel("Aktivitäts-Erfassung", akt.erfassung_seit ? "seit " + fmtDatum(akt.erfassung_seit) : "startet jetzt",
akt.erfassung_seit ? "je Nutzer und Tag ein Eintrag" : "MAU/DAU wachsen ab diesem Update") +
"</div>";
var mauWerte = (akt.mau || []).map(function (r) { return r.nutzer; });
var mauMax = Math.max.apply(null, mauWerte.concat([0]));
if (mauMax > 0) {
html += chartCard("Aktive Nutzer je Monat (MAU)",
chartBalken(monate, [{ name: "Aktive Nutzer", farbe: C1(), werte: mauWerte }], ""), "");
var dauLabels = (akt.dau || []).map(function (r) { return r.tag; });
var dauWerte = (akt.dau || []).map(function (r) { return r.nutzer; });
html += chartCard("Aktive Nutzer je Tag (DAU, letzte 30 Tage)",
dauLabels.length
? chartLinie(dauLabels, [{ name: "Aktive Nutzer", farbe: C1(), werte: dauWerte }], "", true)
: '<div class="st-leer">Noch keine Tageswerte</div>', "");
} else {
html += '<div class="card"><div class="card-body st-leer">Noch keine Aktivitätsdaten. ' +
"Die Erfassung (ein Eintrag je Nutzer und Tag) beginnt mit diesem Update, " +
"die MAU/DAU-Kurven erscheinen hier, sobald sich Nutzer im Monitor bewegen.</div></div>";
}
// --- Wachstum ---
html += '<h3 class="st-gruppe">Wachstum</h3>';
var w = {};
(d.wachstum || []).forEach(function (r) { w[r.monat] = r; });
function wReihe(feld) { return monate.map(function (m) { return w[m] ? w[m][feld] : 0; }); }
html += chartCard("Neuzugänge je Monat",
chartBalken(monate, [
{ name: "Organisationen", farbe: C1(), werte: wReihe("orgs") },
{ name: "Nutzer", farbe: C2(), werte: wReihe("users") },
{ name: "Lagen", farbe: C3(), werte: wReihe("lagen") },
], ""),
legende([{ name: "Organisationen", farbe: C1() }, { name: "Nutzer", farbe: C2() }, { name: "Lagen", farbe: C3() }]));
el.innerHTML = html;
// Datenstand
var ds = document.getElementById("st-datenstand");
if (ds) {
var teile = [];
if (d.datenstand.letzter_lauf) teile.push("Letzter Lauf " + fmtDatum(d.datenstand.letzter_lauf));
teile.push("Kostenerfassung ab 03/2026");
if (d.tarif && d.tarif.quelle === "defaults") teile.push("Sätze aus Rückfallwerten");
ds.textContent = "Datenstand: " + teile.join(" · ");
}
}
// ---------- Laden ----------
function lade() {
var el = document.getElementById("st-inhalt");
if (el) el.innerHTML = '<div class="st-leer">Lade Statistik …</div>';
API.get("/api/statistik?months=" + aktMonate).then(function (d) {
render(d);
}).catch(function (err) {
if (el) el.innerHTML = '<div class="st-leer">Statistik konnte nicht geladen werden: ' + esc(err.message) + "</div>";
});
}
function setzeZeitraum(monate, btn) {
aktMonate = monate;
document.querySelectorAll("#st-zeitraum button").forEach(function (b) { b.classList.remove("btn-primary"); b.classList.add("btn-secondary"); });
btn.classList.remove("btn-secondary");
btn.classList.add("btn-primary");
lade();
}
// ---------- Injektion (Muster rechner.js) ----------
var STYLES = "\n" +
"#sec-statistik { --st-c1:#a8863a; --st-c2:#3987e5; --st-c3:#199e70; }\n" +
'[data-theme="light"] #sec-statistik { --st-c1:#96791A; --st-c2:#2a78d6; --st-c3:#199e70; }\n' +
"#sec-statistik .st-gruppe { margin:26px 0 12px; font-size:16px; border-bottom:1px solid var(--border); padding-bottom:6px; }\n" +
"#sec-statistik .st-kopf { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }\n" +
"#sec-statistik .st-kopf h2 { font-size:20px; margin-right:auto; }\n" +
"#sec-statistik .st-datenstand { font-size:12px; color:var(--text-muted); margin-top:6px; }\n" +
"#sec-statistik .st-legende { display:flex; gap:14px; font-size:12px; color:var(--text-secondary); flex-wrap:wrap; }\n" +
"#sec-statistik .st-dot { display:inline-block; width:10px; height:10px; border-radius:3px; margin-right:5px; vertical-align:-1px; }\n" +
"#sec-statistik .st-leer { padding:22px; text-align:center; color:var(--text-muted); font-size:13px; }\n" +
"#sec-statistik .st-fussnote { font-size:12px; color:var(--text-muted); border-top:1px solid var(--border); }\n" +
"#sec-statistik .st-pos { color:var(--success); }\n" +
"#sec-statistik .st-neg { color:var(--danger); }\n" +
"#sec-statistik .card { margin-bottom:16px; }\n";
function init() {
var nav = document.querySelector("nav.nav-tabs");
var content = document.querySelector(".app-content");
if (!nav || !content || document.getElementById("sec-statistik")) return;
var style = document.createElement("style");
style.textContent = STYLES;
document.head.appendChild(style);
var sektion = document.createElement("div");
sektion.className = "section";
sektion.id = "sec-statistik";
sektion.innerHTML =
'<div class="st-kopf">' +
"<h2>Statistik</h2>" +
'<div id="st-zeitraum" style="display:flex; gap:6px;">' +
'<button type="button" class="btn btn-secondary btn-small" data-monate="6">6 Monate</button>' +
'<button type="button" class="btn btn-primary btn-small" data-monate="12">12 Monate</button>' +
'<button type="button" class="btn btn-secondary btn-small" data-monate="0">Alles</button>' +
"</div>" +
"</div>" +
'<div class="st-datenstand" id="st-datenstand"></div>' +
'<div id="st-inhalt" style="margin-top:14px;"></div>';
content.appendChild(sektion);
var btn = document.createElement("button");
btn.className = "nav-tab";
btn.setAttribute("data-section", "statistik");
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>Statistik';
// Zwischen Audit-Log und Verbrauchsrechner, egal in welcher Ladereihenfolge
var rechnerTab = nav.querySelector('.nav-tab[data-section="rechner"]');
nav.insertBefore(btn, rechnerTab || null);
btn.addEventListener("click", function () {
document.querySelectorAll("nav.nav-tabs .nav-tab").forEach(function (t) { t.classList.remove("active"); });
btn.classList.add("active");
document.querySelectorAll(".app-content > .section").forEach(function (s) { s.classList.remove("active"); });
var detail = document.getElementById("orgDetail");
if (detail) detail.classList.remove("active");
sektion.classList.add("active");
if (!geladen) { geladen = true; lade(); }
});
nav.addEventListener("click", function (e) {
var ziel = e.target.closest(".nav-tab");
if (!ziel || ziel === btn) return;
btn.classList.remove("active");
sektion.classList.remove("active");
});
sektion.querySelectorAll("#st-zeitraum button").forEach(function (b) {
b.addEventListener("click", function () { setzeZeitraum(parseInt(b.getAttribute("data-monate"), 10), b); });
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

Datei anzeigen

@@ -1,10 +1,11 @@
/* X-Recherche-Konten: Verwaltung des twscrape-Account-Pools */
/* Recherche-Zugänge: twscrape-Account-Pool (X) + Telegram-Session-Status */
"use strict";
let xScraperCache = [];
async function loadXScraperAccounts() {
setupXScraperForms();
loadTelegramStatus();
const tbody = document.getElementById("xScraperTable");
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">Lade...</td></tr>';
try {
@@ -15,10 +16,38 @@ async function loadXScraperAccounts() {
}
}
// Telegram-Session-Status (vom Monitor in die geteilte DB gemeldet),
// dargestellt als Tabellenzeile im selben Format wie die X-Konten
async function loadTelegramStatus() {
const tbody = document.getElementById("telegramStatusTable");
if (!tbody) return;
try {
const st = await API.get("/api/x-scraper/telegram-status");
if (!st.available) {
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">Noch keine Statusmeldung vom Monitor vorhanden. Der Monitor meldet beim Start und täglich um 04:30 Uhr.</td></tr>';
return;
}
const status = st.ok
? '<span style="color:var(--success,#2e7d32);">Aktiv</span>'
: '<span style="color:var(--danger,#c62828);">Problem</span> <span class="text-secondary" style="font-size:12px;">' + esc(st.error || "Session nicht autorisiert") + '</span>';
const aktion = st.ok
? '<span class="text-muted" title="Die Telegram-Anmeldung läuft nicht periodisch ab, hier ist normalerweise nichts zu tun.">—</span>'
: '<span class="text-secondary" style="font-size:12px;">Neu-Login nur auf dem Server möglich</span>';
tbody.innerHTML = `<tr>
<td>${esc(st.account || "—")}</td>
<td class="text-secondary">${esc(st.phone || "—")}</td>
<td>${status}</td>
<td class="text-secondary">—</td>
<td class="text-secondary">${formatDateTime(st.updated_at)}</td>
<td>${aktion}</td>
</tr>`;
} catch (err) {
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">Status konnte nicht geladen werden. ' + esc(err.message || "") + '</td></tr>';
}
}
function renderXScraperAccounts(list) {
const tbody = document.getElementById("xScraperTable");
const cnt = document.getElementById("xScraperCount");
if (cnt) cnt.textContent = list.length + (list.length === 1 ? " Konto" : " Konten");
if (!list.length) {
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">Keine X-Recherche-Konten. Mit „+ Konto hinzufügen" anlegen.</td></tr>';
return;
@@ -89,7 +118,8 @@ function confirmDeleteXScraper(username) {
} catch (err) {
showToast(err.message || "Konto konnte nicht entfernt werden", "error");
}
}
},
{ danger: true }
);
}

Datei anzeigen

@@ -49,17 +49,25 @@ def test_meta_types_have_required_fields(authed_client):
assert "label" in t
def test_meta_includes_specialized_categories(authed_client):
"""Phase 3b - die spezielleren Lagen-Themen muessen als Kategorien existieren."""
def test_meta_categories_exact_set(authed_client):
"""Bereinigte Kategorienliste - exakt gepinnt gegen stilles Driften.
telegram und x muessen drin sein (147 Quellen im Bestand), die frueher
definierten, aber nie belegten Lagen-Kategorien sind bewusst entfernt.
"""
r = authed_client.get("/api/sources/meta")
keys = {c["key"] for c in r.json()["categories"]}
assert "cybercrime" in keys
assert "ukraine-russland-krieg" in keys
assert "russische-staatspropaganda" in keys
assert keys == {
"nachrichtenagentur", "oeffentlich-rechtlich", "qualitaetszeitung",
"behoerde", "fachmedien", "think-tank", "international", "regional",
"boulevard", "telegram", "x", "sonstige",
}
assert "cybercrime" not in keys
assert "stimmungsbild" not in keys
def test_meta_includes_all_source_types(authed_client):
"""Alle 5 Source-Types muessen rauskommen."""
"""Alle 6 Source-Types muessen rauskommen (excluded ist bewusst entfernt)."""
r = authed_client.get("/api/sources/meta")
keys = {t["key"] for t in r.json()["types"]}
assert keys == {"rss_feed", "web_source", "telegram_channel", "podcast_feed", "excluded"}
assert keys == {"rss_feed", "web_source", "telegram_channel", "podcast_feed", "x_account", "pdf_document"}

Datei anzeigen

@@ -40,22 +40,36 @@ AUTH_PROTECTED = [
("DELETE", "/api/users/1"),
("GET", "/api/dashboard/stats"),
("GET", "/api/sources/meta"),
("GET", "/api/sources/global"),
("GET", "/api/sources/tasks/summary"),
("GET", "/api/sources"),
("POST", "/api/sources/global"),
("PUT", "/api/sources/global/1"),
("DELETE", "/api/sources/global/1"),
("GET", "/api/sources/global/stats"),
("GET", "/api/sources/tenant"),
("GET", "/api/sources/stats"),
("GET", "/api/sources/languages"),
("GET", "/api/sources/1/health"),
("POST", "/api/sources/tenant/1/promote"),
("POST", "/api/sources/tenant/bulk-promote"),
("POST", "/api/sources/discover"),
("POST", "/api/sources/discover/add"),
("GET", "/api/sources/health"),
("GET", "/api/sources/health/history"),
("GET", "/api/sources/suggestions"),
("PUT", "/api/sources/suggestions/1"),
("PUT", "/api/sources/suggestions/bulk"),
("POST", "/api/sources/health/run"),
("POST", "/api/sources/health/run-stream"),
("GET", "/api/sources/health/run-status"),
("POST", "/api/sources/health/search-fix/1"),
("GET", "/api/sources/classification/queue"),
("POST", "/api/sources/classification/bulk-classify"),
("POST", "/api/sources/classification/bulk-approve"),
("POST", "/api/sources/1/classification/approve"),
("POST", "/api/sources/1/classification/reject"),
("POST", "/api/sources/1/classification/reclassify"),
("POST", "/api/sources/external-reputation/sync"),
("POST", "/api/sources/global/upload-pdf"),
("GET", "/api/x-scraper/accounts"),
("GET", "/api/x-scraper/telegram-status"),
("GET", "/api/statistik"),
("GET", "/api/token-usage/overview"),
("GET", "/api/token-usage/1"),
("GET", "/api/token-usage/1/current"),

50
tests/test_health_run.py Normale Datei
Datei anzeigen

@@ -0,0 +1,50 @@
"""Tests für src/health_run.py - Zustandsübergänge des manuellen Health-Runs."""
from health_run import new_state, try_start, mark_finished, mark_failed
def test_initial_state_is_idle():
s = new_state()
assert s["running"] is False
assert s["started_at"] is None
assert s["finished_at"] is None
assert s["result"] is None
assert s["error"] is None
def test_try_start_sets_running_and_resets():
s = new_state()
s["result"] = {"checked": 1}
s["error"] = "alt"
assert try_start(s, now="2026-07-25T12:00:00") is True
assert s["running"] is True
assert s["started_at"] == "2026-07-25T12:00:00"
assert s["finished_at"] is None
assert s["result"] is None
assert s["error"] is None
def test_double_start_blocked():
s = new_state()
assert try_start(s) is True
assert try_start(s) is False
def test_mark_finished_allows_restart():
s = new_state()
try_start(s, now="2026-07-25T12:00:00")
mark_finished(s, {"checked": 430, "issues": 110, "suggestions": 3}, now="2026-07-25T12:05:00")
assert s["running"] is False
assert s["finished_at"] == "2026-07-25T12:05:00"
assert s["result"]["checked"] == 430
assert s["error"] is None
assert try_start(s) is True
def test_mark_failed_sets_error():
s = new_state()
try_start(s)
mark_failed(s, "Verbindung weg", now="2026-07-25T12:01:00")
assert s["running"] is False
assert s["error"] == "Verbindung weg"
assert s["result"] is None
assert try_start(s) is True

Datei anzeigen

@@ -4,7 +4,7 @@ from pydantic import ValidationError
from models import (
MagicLinkRequest, MagicLinkResponse,
VerifyTokenRequest, TokenResponse,
OrgCreate, LicenseCreate, UserCreate,
OrgCreate, LicenseCreate, LicenseParams, LicenseResponse, UserCreate,
)
@@ -46,6 +46,65 @@ def test_license_create_type_pattern():
LicenseCreate(organization_id=1, license_type="lifetime")
def test_license_params_credits_defaults():
"""Monatskontingent ist die Voreinstellung, einen Übertrag gibt es nicht mehr."""
p = LicenseParams(license_type="trial")
assert p.credits_period == "monthly"
assert not hasattr(p, "credits_rollover")
assert p.budget_warning_percent == 80
def test_license_params_warnschwelle_null_ist_aus():
p = LicenseParams(license_type="trial", budget_warning_percent=0)
assert p.budget_warning_percent == 0
with pytest.raises(ValidationError):
LicenseParams(license_type="trial", budget_warning_percent=101)
def test_license_params_period_pattern():
LicenseParams(license_type="trial", credits_period="monthly")
LicenseParams(license_type="trial", credits_period="total")
with pytest.raises(ValidationError):
LicenseParams(license_type="trial", credits_period="weekly")
def test_license_create_inherits_credits_fields():
lic = LicenseCreate(
organization_id=1, license_type="annual",
credits_total=10000, credits_period="monthly",
)
assert lic.credits_total == 10000
assert lic.credits_period == "monthly"
def test_org_create_without_license_still_valid():
"""Rückwärtskompatibilität, das Lizenz-Objekt ist optional."""
org = OrgCreate(name="Test", slug="test")
assert org.license is None
def test_org_create_with_nested_license():
org = OrgCreate(
name="Test", slug="test",
license={"license_type": "trial", "credits_total": 10000},
)
assert org.license.credits_total == 10000
assert org.license.credits_period == "monthly"
with pytest.raises(ValidationError):
OrgCreate(name="Test", slug="test", license={"license_type": "trial", "credits_period": "weekly"})
def test_license_response_without_new_columns():
"""Responses müssen auch von einer noch nicht migrierten DB validieren."""
r = LicenseResponse(
id=1, organization_id=1, license_type="trial", max_users=5,
valid_from="2026-07-25", valid_until=None, status="active",
notes=None, created_at="2026-07-25",
)
assert r.credits_period == "monthly"
assert not hasattr(r, "credits_carried")
def test_user_create_role_pattern():
UserCreate(email="a@b.de", role="member")
UserCreate(email="a@b.de", role="org_admin")

Datei anzeigen

@@ -40,12 +40,17 @@ def test_category_label_lookup():
def test_type_label_lookup():
assert type_label("rss_feed") == "RSS-Feed"
assert type_label("telegram_channel") == "Telegram-Kanal"
assert type_label("x_account") == "X-Konto"
assert type_label("pdf_document") == "PDF-Dokument"
assert type_label("does-not-exist") == "does-not-exist"
def test_category_includes_aktuelle_themen():
"""Phase 3b: Lagen-spezifische Kategorien (cybercrime etc.) müssen drin sein."""
def test_category_covers_bestand():
"""telegram und x muessen als Kategorien existieren (belegter Bestand),
die nie belegten Lagen-Kategorien sind bewusst entfernt."""
keys = {c["key"] for c in SOURCE_CATEGORIES}
assert "cybercrime" in keys
assert "ukraine-russland-krieg" in keys
assert "russische-staatspropaganda" in keys
assert "telegram" in keys
assert "x" in keys
assert "cybercrime" not in keys
assert "ukraine-russland-krieg" not in keys
assert "russische-staatspropaganda" not in keys

93
tests/test_statistik.py Normale Datei
Datei anzeigen

@@ -0,0 +1,93 @@
"""Tests für die puren Aggregationsfunktionen der Betriebsstatistik."""
from routers.statistik import (
baue_monatsachse, normalisiere_fehler, aggregiere_refresh,
berechne_credits, aggregiere_orgs, fuelle_monatsserie,
)
TARIF = {"monitor_adhoc": 45, "monitor_research": 40, "chat": 1, "enhance": 1, "globe": 1}
def test_monatsachse_lueckenlos_ueber_jahresgrenze():
assert baue_monatsachse("2025-11", "2026-02") == ["2025-11", "2025-12", "2026-01", "2026-02"]
def test_monatsachse_leer_bei_unsinn():
assert baue_monatsachse("2026-05", "2026-02") == []
assert baue_monatsachse("", "2026-02") == []
def test_fehler_normalisierung():
assert normalisiere_fehler("Endgültig fehlgeschlagen nach 3 Versuchen: Claude CLI Timeout nach 420s") == "Claude CLI Timeout nach 420s"
assert normalisiere_fehler("Verwaist (Neustart, x)") == "Verwaist (Neustart, automatisch bereinigt)"
assert normalisiere_fehler(None) == "Unbekannt"
def _lauf(monat="2026-03", typ="adhoc", status="completed", kosten=5.0, **kw):
return {"monat": monat, "typ": typ, "status": status, "trigger_type": "auto",
"total_cost_usd": kosten, "input_tokens": 100, "output_tokens": 50,
"articles_found": 3, "error_message": None, "dauer_s": 600.0, **kw}
def test_median_ignoriert_nullkosten():
"""Läufe mit 0 $ (Kostenerfassung fehlte) dürfen den Median nicht verzerren."""
rows = [_lauf(kosten=0.0), _lauf(kosten=4.0), _lauf(kosten=6.0)]
agg = aggregiere_refresh(rows, ["2026-03"])
assert agg["kpi"]["median_kosten_lauf"] == 5.0
assert agg["kpi"]["laeufe_ok"] == 3
def test_betrieb_und_fehler():
rows = [_lauf(), _lauf(status="error", kosten=0.0,
error_message="Endgültig fehlgeschlagen nach 3 Versuchen: Timeout X"),
_lauf(status="error", kosten=0.0, error_message="Timeout X")]
agg = aggregiere_refresh(rows, ["2026-03"])
b = agg["monat_betrieb"][0]
assert b["completed"] == 1 and b["error"] == 2
assert agg["fehler_top"][0] == {"grund": "Timeout X", "n": 2}
assert agg["kpi"]["erfolgsquote"] == 33.3
def test_aggregiere_refresh_leer():
agg = aggregiere_refresh([], [])
assert agg["kpi"]["laeufe_gesamt"] == 0
assert agg["kpi"]["erfolgsquote"] is None
assert agg["monat_typ"] == []
def test_credits_formel_erstlauf_dreifach():
"""Anlage einer Recherche = 3 Durchläufe, erscheint aber als 1 Log-Zeile."""
credits = berechne_credits(adhoc_ok=2, research_ok=3, research_erstlaeufe=1,
neben_aufrufe={"chat": 4}, tarif=TARIF)
# 2*45 + (3 + 2*1)*40 + 4*1 = 90 + 200 + 4
assert credits == 294
def test_credits_formel_ohne_erstlauf():
"""Grenzfall, der chronologisch erste Lauf war ein error. Dann zählt kein
Erstlauf-Zuschlag (die Heuristik greift erst beim ersten completed Lauf,
was die SQL-Seite über rn=1 auf completed-Zeilen abbildet)."""
assert berechne_credits(0, 2, 0, {}, TARIF) == 80
def test_orgs_marge():
rows_orgs = [{"tenant_id": 1, "ist_kosten": 10.0, "laeufe_ok": 2, "laeufe_gesamt": 3,
"adhoc_ok": 2, "research_ok": 0, "research_erstlaeufe": 0}]
rows_neben = [{"organization_id": 1, "source": "chat", "api_calls": 10, "kosten": 0.5}]
orgs = aggregiere_orgs(rows_orgs, rows_neben, {1: "Test-Org"}, TARIF, 0.20)
o = orgs[0]
assert o["credits_geschaetzt"] == 100 # 2*45 + 10*1
assert o["gegenwert_usd"] == 20.0
assert o["kosten_usd"] == 10.5
assert o["marge_usd"] == 9.5
def test_orgs_ohne_laeufe_taucht_trotzdem_auf():
orgs = aggregiere_orgs([], [], {5: "Leer-Org"}, TARIF, 0.20)
assert orgs[0]["org_id"] == 5
assert orgs[0]["credits_geschaetzt"] == 0
def test_fuelle_monatsserie():
rows = [{"monat": "2026-03", "n": 2}]
out = fuelle_monatsserie(rows, ["2026-02", "2026-03"], "monat", ["n"])
assert out == [{"monat": "2026-02", "n": 0}, {"monat": "2026-03", "n": 2}]