Promote develop → main (2026-07-25 22:22 UTC)
This commit was merged in pull request #16.
Dieser Commit ist enthalten in:
27
CLAUDE.md
27
CLAUDE.md
@@ -43,15 +43,19 @@ src/:
|
||||
database.py: "DB-Connection-Pool, Schema-Helper"
|
||||
models.py: "Pydantic Request/Response-Schemas"
|
||||
audit.py: "log_action, get_client_ip, row_to_dict, /api/audit Router"
|
||||
health_run.py: "In-Memory-Status des manuellen Health-Runs (pure Funktionen, unit-testbar). Der Knopf Jetzt prüfen ruft die geteilten Dienste source_health + source_suggester auf (POST /api/sources/health/run, Polling über GET /health/run-status). Zusätzlich zum Monitor-Nachtjob um 04:00. Status ist nach Neustart idle, partieller Stand heilt beim nächsten Lauf"
|
||||
|
||||
routers/:
|
||||
organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)"
|
||||
licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module)"
|
||||
licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module, Credits-Stellwerte inkl. Periode; Helper insert_license_row wird auch vom Org-Anlegen genutzt). Kein Credits-Übertrag mehr (Produktentscheidung 07/2026, ungenutzte Credits verfallen)"
|
||||
users.py: "CRUD User pro Org, Magic-Link-Einladung an info@aegis-sight.de"
|
||||
sources.py: "Grundquellen, Tenant-Quellen-Übersicht, Discovery, Health-Check, KI-Vorschläge"
|
||||
sources.py: "Vereinte Quellenliste (GET /api/sources?scope=all|global|tenant inkl. org_name + health_status), Stats/Languages, Einzelquellen-Health (GET /{id}/health), Aufgaben-Badge (GET /tasks/summary), Promote, Discovery, PDF-Upload, KI-Vorschläge, Klassifikation"
|
||||
dashboard.py: "Aggregat-Endpoints für Übersichts-Tab"
|
||||
token_usage.py: "Token-Verbrauch pro Org/Monat, Budget-Steuerung"
|
||||
audit.py: "Audit-Log-Abfrage, Filter"
|
||||
token_usage.py: "Token-Verbrauch pro Org/Monat, Credits-Stellwerte je Lizenz (PUT /budget). Laufzeitfelder (credits_used-Reset, credits_period_start, budget_warning_sent) verwaltet der MONITOR, das Portal setzt nur Stellwerte"
|
||||
pricing.py: "Preis-Empfehlung aus Ist-Kosten (GET /cost-basis) und pflegbare Preistabelle billing_tariff (GET/PUT /tariff). Die Tabelle ist die Quelle der Buchung im Monitor, der Rechner kann sie speichern"
|
||||
statistik.py: "Betriebsstatistik (GET /api/statistik?months=N). Kosten/Marge je Monat und Org, Betrieb, Fehler-Ranking, MAU/DAU aus user_activity_days, Wachstum. Aggregation als pure Funktionen (unit-testbar)"
|
||||
audit.py: "Audit-Log-Abfrage, Filter (action/resource_type/admin_id akzeptieren kommagetrennte Mehrfachwerte)"
|
||||
x_scraper.py: "Recherche-Zugänge: twscrape-X-Konten (eigener Store, X_ACCOUNTS_DB_PATH) + GET /telegram-status (liest system_status, gemeldet vom Monitor)"
|
||||
|
||||
email_utils/:
|
||||
sender.py: "Async SMTP Versand"
|
||||
@@ -59,17 +63,24 @@ src/:
|
||||
|
||||
static/:
|
||||
index.html: "Login (Passwort)"
|
||||
dashboard.html: "Hauptdashboard mit Tabs (Dashboard, Orgs, Lizenzen, Quellen, Audit)"
|
||||
dashboard.html: "Hauptdashboard mit Tabs (Dashboard, Orgs, Quellen, Audit). Quellen hat 3 Unterreiter (Quellenliste, Aufgaben, X-Recherche-Konten)"
|
||||
favicon.svg: "AegisSight Logo"
|
||||
css/: "Stylesheets (Dark Theme)"
|
||||
js/:
|
||||
app.js: "Hauptlogik, Login, Tab-Switching, Dashboard-Render"
|
||||
sources.js: "Grundquellen + Kundenquellen Management"
|
||||
source-health.js: "Quellen-Health & KI-Vorschläge"
|
||||
app.js: "Hauptlogik, Login, Tab-Switching, Dashboard-Render, ThemeManager (Dark/Light, localStorage portal_theme)"
|
||||
a11y.js: "Barrierefreiheits-Panel (Kontrast, Focus, Schrift, Animationen). IDENTISCHE Datei wie im Monitor-Repo, bei Aenderungen dort mitziehen"
|
||||
statistik.js: "Statistik-Tab, selbst-injizierend wie rechner.js. Inline-SVG-Charts (keine Bibliothek), Serienfarben mit dem dataviz-Validator gegen beide Themes geprüft"
|
||||
sources.js: "Vereinte Quellenliste (Grund- + Kundenquellen, Herkunfts-Filter, Health-Ausklapp mit Deaktivieren, Bulk-Promote, Modal, Discovery, PDF-Upload)"
|
||||
aufgaben.js: "Aufgaben-Posteingang (Health-Vorschläge + Klassifikations-Review + Verlauf, Badge via GET /tasks/summary, Sammel-Bearbeitung via PUT /suggestions/bulk)"
|
||||
x-scraper.js: "Recherche-Zugänge (X-Konten-Pool + Telegram-Session-Status-Karte)"
|
||||
audit.js: "Audit-Log Tab"
|
||||
|
||||
migrations/:
|
||||
einmal_migrationen: "Backfill-Skripte (DE-Übersetzungen, Umlaute, HTML-Strip etc.)"
|
||||
2026-07-25_user_activity.py: "Tabelle user_activity_days anlegen (MAU/DAU). Nötig für die Portal-Staging-DB, auf Live legt sie der Monitor an."
|
||||
2026-07-25_system_status.py: "Tabelle system_status anlegen (Key-Value, u.a. Telegram-Session-Status). Nötig für die Portal-Staging-DB, auf Live legt sie der Monitor an. Reihenfolge: Monitor VOR Portal promoten."
|
||||
2026-07-25_billing_tariff.py: "Preistabelle billing_tariff anlegen und mit den beschlossenen Sätzen befüllen (idempotent, überschreibt nichts). Nötig für die Portal-Staging-DB, auf Live legt sie der Monitor an."
|
||||
2026-07-25_credits_period.py: "Credits-Perioden-Spalten in licenses (idempotent, identisch zur Monitor-Migration). Nötig für die Portal-Staging-DB, auf Live legt sie normalerweise der Monitor an. Reihenfolge: Monitor VOR Portal promoten."
|
||||
```
|
||||
|
||||
## Datenbank-Tabellen (relevant fürs Portal)
|
||||
|
||||
@@ -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",
|
||||
|
||||
67
migrations/2026-07-25_billing_tariff.py
Normale Datei
67
migrations/2026-07-25_billing_tariff.py
Normale Datei
@@ -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))
|
||||
57
migrations/2026-07-25_credits_period.py
Normale Datei
57
migrations/2026-07-25_credits_period.py
Normale Datei
@@ -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))
|
||||
46
migrations/2026-07-25_system_status.py
Normale Datei
46
migrations/2026-07-25_system_status.py
Normale Datei
@@ -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))
|
||||
46
migrations/2026-07-25_user_activity.py
Normale Datei
46
migrations/2026-07-25_user_activity.py
Normale Datei
@@ -0,0 +1,46 @@
|
||||
"""Migration 2026-07-25: Tabelle user_activity_days anlegen (MAU/DAU).
|
||||
|
||||
Der Monitor legt die Tabelle beim Start selbst an und schreibt je Nutzer und
|
||||
Kalendertag einen Eintrag (auth.get_current_user). Die Portal-Staging-Umgebung
|
||||
nutzt eine EIGENE Datenbank ohne laufenden Monitor, deshalb dieses Skript.
|
||||
Idempotent (CREATE TABLE IF NOT EXISTS), es werden keine Daten eingefügt.
|
||||
|
||||
Ausführung:
|
||||
DB_PATH=/home/claude-dev/AegisSight-Monitor-staging/data/osint.db python3 migrations/2026-07-25_user_activity.py
|
||||
DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-07-25_user_activity.py
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
|
||||
def main(db_path: str) -> int:
|
||||
if not os.path.exists(db_path):
|
||||
print(f"FEHLER: DB nicht gefunden: {db_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
conn = sqlite3.connect(db_path, timeout=60)
|
||||
conn.execute("PRAGMA busy_timeout = 60000")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
|
||||
print(f"Migration auf {db_path}")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_activity_days (
|
||||
user_id INTEGER NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
tenant_id INTEGER,
|
||||
PRIMARY KEY (user_id, day)
|
||||
)
|
||||
""")
|
||||
print(" + user_activity_days angelegt (oder vorhanden)")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("Migration abgeschlossen.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
db_path = os.environ.get("DB_PATH", "/home/claude-dev/osint-data/osint.db")
|
||||
sys.exit(main(db_path))
|
||||
47
src/health_run.py
Normale Datei
47
src/health_run.py
Normale Datei
@@ -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
|
||||
23
src/main.py
23
src/main.py
@@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from config import STATIC_DIR, PORT
|
||||
from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit, translation, x_scraper, pricing
|
||||
from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit, translation, x_scraper, pricing, statistik
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -22,6 +22,26 @@ logger = logging.getLogger("verwaltung")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Schutz gegen falsche Deploy-Reihenfolge: Die Credits-Spalten in licenses
|
||||
# legt die Monitor-Migration an. Fehlen sie, scheitern Lizenz-Anlage und
|
||||
# Token-Tab mit "no such column". Hier nur ein deutlicher Log-Hinweis,
|
||||
# kein Hard-Fail.
|
||||
try:
|
||||
from database import get_db
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute("PRAGMA table_info(licenses)")
|
||||
cols = [row[1] for row in await cursor.fetchall()]
|
||||
if "credits_period" not in cols:
|
||||
logger.error(
|
||||
"licenses-Tabelle ohne Credits-Spalten (credits_period fehlt)! "
|
||||
"Zuerst den Monitor deployen (dessen Migration legt sie an) "
|
||||
"oder migrations/2026-07-25_credits_period.py gegen diese DB fahren."
|
||||
)
|
||||
finally:
|
||||
await db.close()
|
||||
except Exception as exc:
|
||||
logger.warning("Startup-Schema-Check uebersprungen: %s", exc)
|
||||
logger.info("Verwaltungsportal gestartet auf Port %s", PORT)
|
||||
yield
|
||||
logger.info("Verwaltungsportal beendet")
|
||||
@@ -45,6 +65,7 @@ app.include_router(audit.router)
|
||||
app.include_router(translation.router)
|
||||
app.include_router(x_scraper.router)
|
||||
app.include_router(pricing.router)
|
||||
app.include_router(statistik.router)
|
||||
|
||||
# --- Statische Dateien ---
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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
413
src/routers/statistik.py
Normale Datei
@@ -0,0 +1,413 @@
|
||||
"""Betriebsstatistik für den Statistik-Reiter.
|
||||
|
||||
Ein Endpoint liefert alle Serien und Kennzahlen in einem JSON. Die
|
||||
Aggregation passiert in puren Funktionen (ohne DB-Zugriff), damit sie sich
|
||||
als Unit-Tests prüfen lässt. Führende Quelle für Monitor-Läufe ist
|
||||
refresh_log (hat Lagentyp, Kosten, Dauer je Lauf), token_usage_monthly
|
||||
ergänzt die übrigen Quellen (globe, chat, enhance).
|
||||
|
||||
Ehrlichkeits-Regeln der Margen-Schätzung (keine historisierten Buchungen):
|
||||
- Bewertet wird zu den HEUTIGEN Sätzen aus billing_tariff.
|
||||
- Die Anlage einer Recherche zählt als 3 Durchläufe (Preismodell), erscheint
|
||||
im refresh_log aber nur als eine Zeile. Deshalb zählt der erste
|
||||
abgeschlossene Lauf je Recherche-Lage dreifach.
|
||||
- analysis/factcheck haben keine Nutzungsdaten und fehlen (Schätzung eher
|
||||
zu niedrig). Ist-Kosten enthalten auch Fehlläufe.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import statistics
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from auth import get_current_admin
|
||||
from database import get_db
|
||||
from routers.pricing import TARIFF_DEFAULTS, USD_PER_CREDIT
|
||||
|
||||
logger = logging.getLogger("verwaltung.statistik")
|
||||
router = APIRouter(prefix="/api/statistik", tags=["Statistik"])
|
||||
|
||||
_RETRY_PREFIX = re.compile(r"^Endgültig fehlgeschlagen nach \d+ Versuchen:\s*")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Pure Funktionen (ohne DB, unit-testbar)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def baue_monatsachse(von_ym: str, bis_ym: str) -> list[str]:
|
||||
"""Lückenlose Liste 'YYYY-MM' von von_ym bis bis_ym (einschließlich)."""
|
||||
if not von_ym or not bis_ym or von_ym > bis_ym:
|
||||
return []
|
||||
y, m = int(von_ym[:4]), int(von_ym[5:7])
|
||||
ey, em = int(bis_ym[:4]), int(bis_ym[5:7])
|
||||
achse = []
|
||||
while (y, m) <= (ey, em):
|
||||
achse.append(f"{y:04d}-{m:02d}")
|
||||
m += 1
|
||||
if m == 13:
|
||||
y, m = y + 1, 1
|
||||
return achse
|
||||
|
||||
|
||||
def normalisiere_fehler(msg: str | None) -> str:
|
||||
"""Fehlermeldungen für das Ranking zusammenfassen."""
|
||||
if not msg:
|
||||
return "Unbekannt"
|
||||
msg = _RETRY_PREFIX.sub("", msg).strip()
|
||||
if msg.startswith("Verwaist"):
|
||||
return "Verwaist (Neustart, automatisch bereinigt)"
|
||||
return msg[:120]
|
||||
|
||||
|
||||
def aggregiere_refresh(rows: list[dict], achse: list[str]) -> dict:
|
||||
"""Aggregiert refresh_log-Detailzeilen zu KPI, Monat×Typ, Betrieb, Fehlern.
|
||||
|
||||
Erwartete Row-Schlüssel: monat, typ, status, trigger_type, total_cost_usd,
|
||||
input_tokens, output_tokens, articles_found, error_message, dauer_s.
|
||||
Mediane und Durchschnittskosten nur über Läufe mit Kosten > 0 (die
|
||||
Kostenerfassung begann erst später, Nullen würden verzerren).
|
||||
"""
|
||||
typen = ("adhoc", "research")
|
||||
mt = {(m, t): {"laeufe": 0, "kosten": 0.0, "tokens_in": 0, "tokens_out": 0,
|
||||
"artikel": 0, "_kosten_liste": [], "_dauern": []}
|
||||
for m in achse for t in typen}
|
||||
betrieb = {m: {"completed": 0, "error": 0, "cancelled": 0, "auto": 0, "manual": 0}
|
||||
for m in achse}
|
||||
fehler: dict[str, int] = defaultdict(int)
|
||||
dauer_typ: dict[str, list] = {t: [] for t in typen}
|
||||
kpi = {"kosten_usd": 0.0, "laeufe_ok": 0, "laeufe_gesamt": 0,
|
||||
"artikel": 0, "tokens_in": 0, "tokens_out": 0, "_kosten_liste": []}
|
||||
|
||||
for r in rows:
|
||||
monat = r.get("monat")
|
||||
typ = r.get("typ") if r.get("typ") in typen else "adhoc"
|
||||
status = r.get("status")
|
||||
kosten = r.get("total_cost_usd") or 0.0
|
||||
|
||||
kpi["laeufe_gesamt"] += 1
|
||||
kpi["kosten_usd"] += kosten
|
||||
kpi["tokens_in"] += r.get("input_tokens") or 0
|
||||
kpi["tokens_out"] += r.get("output_tokens") or 0
|
||||
|
||||
if monat in betrieb:
|
||||
b = betrieb[monat]
|
||||
if status in ("completed", "error", "cancelled"):
|
||||
b[status] += 1
|
||||
if status == "completed":
|
||||
if r.get("trigger_type") == "auto":
|
||||
b["auto"] += 1
|
||||
else:
|
||||
b["manual"] += 1
|
||||
|
||||
if status == "error":
|
||||
fehler[normalisiere_fehler(r.get("error_message"))] += 1
|
||||
|
||||
if status != "completed":
|
||||
continue
|
||||
|
||||
kpi["laeufe_ok"] += 1
|
||||
kpi["artikel"] += r.get("articles_found") or 0
|
||||
if kosten > 0:
|
||||
kpi["_kosten_liste"].append(kosten)
|
||||
|
||||
dauer = r.get("dauer_s")
|
||||
if dauer is not None and 0 < dauer < 86400:
|
||||
dauer_typ[typ].append(dauer)
|
||||
|
||||
key = (monat, typ)
|
||||
if key in mt:
|
||||
z = mt[key]
|
||||
z["laeufe"] += 1
|
||||
z["kosten"] += kosten
|
||||
z["tokens_in"] += r.get("input_tokens") or 0
|
||||
z["tokens_out"] += r.get("output_tokens") or 0
|
||||
z["artikel"] += r.get("articles_found") or 0
|
||||
if kosten > 0:
|
||||
z["_kosten_liste"].append(kosten)
|
||||
if dauer is not None and 0 < dauer < 86400:
|
||||
z["_dauern"].append(dauer)
|
||||
|
||||
monat_typ = []
|
||||
for m in achse:
|
||||
for t in typen:
|
||||
z = mt[(m, t)]
|
||||
monat_typ.append({
|
||||
"monat": m, "typ": t,
|
||||
"laeufe": z["laeufe"],
|
||||
"kosten": round(z["kosten"], 2),
|
||||
"kosten_median": round(statistics.median(z["_kosten_liste"]), 2) if z["_kosten_liste"] else None,
|
||||
"tokens_in": z["tokens_in"], "tokens_out": z["tokens_out"],
|
||||
"artikel": z["artikel"],
|
||||
"dauer_avg_s": round(sum(z["_dauern"]) / len(z["_dauern"])) if z["_dauern"] else None,
|
||||
})
|
||||
|
||||
gesamt = kpi["laeufe_gesamt"]
|
||||
ergebnis_kpi = {
|
||||
"kosten_usd": round(kpi["kosten_usd"], 2),
|
||||
"laeufe_ok": kpi["laeufe_ok"],
|
||||
"laeufe_gesamt": gesamt,
|
||||
"erfolgsquote": round(kpi["laeufe_ok"] / gesamt * 100, 1) if gesamt else None,
|
||||
"median_kosten_lauf": round(statistics.median(kpi["_kosten_liste"]), 2) if kpi["_kosten_liste"] else None,
|
||||
"artikel": kpi["artikel"],
|
||||
"tokens_in": kpi["tokens_in"],
|
||||
"tokens_out": kpi["tokens_out"],
|
||||
}
|
||||
fehler_top = [{"grund": g, "n": n}
|
||||
for g, n in sorted(fehler.items(), key=lambda x: -x[1])[:5]]
|
||||
dauer_je_typ = {t: (round(sum(v) / len(v)) if v else None) for t, v in dauer_typ.items()}
|
||||
|
||||
return {
|
||||
"kpi": ergebnis_kpi,
|
||||
"monat_typ": monat_typ,
|
||||
"monat_betrieb": [{"monat": m, **betrieb[m]} for m in achse],
|
||||
"fehler_top": fehler_top,
|
||||
"dauer_typ": dauer_je_typ,
|
||||
}
|
||||
|
||||
|
||||
def berechne_credits(adhoc_ok: int, research_ok: int, research_erstlaeufe: int,
|
||||
neben_aufrufe: dict, tarif: dict) -> float:
|
||||
"""Geschätzter Credits-Gegenwert zu heutigen Sätzen.
|
||||
|
||||
Die Anlage einer Recherche (= erster abgeschlossener Lauf) zählt dreifach,
|
||||
deshalb +2 zusätzliche Durchläufe je Erstlauf. neben_aufrufe ist
|
||||
{quelle: api_calls} für chat/enhance/globe.
|
||||
"""
|
||||
credits = adhoc_ok * tarif.get("monitor_adhoc", 0)
|
||||
credits += (research_ok + 2 * research_erstlaeufe) * tarif.get("monitor_research", 0)
|
||||
for quelle, aufrufe in (neben_aufrufe or {}).items():
|
||||
credits += (aufrufe or 0) * tarif.get(quelle, 0)
|
||||
return round(credits, 2)
|
||||
|
||||
|
||||
def aggregiere_orgs(rows_orgs: list[dict], rows_neben: list[dict],
|
||||
org_namen: dict, tarif: dict, usd_per_credit: float) -> list[dict]:
|
||||
"""Baut die Organisations-Tabelle mit Ist-Kosten und Margen-Schätzung."""
|
||||
neben: dict[int, dict] = defaultdict(lambda: {"aufrufe": {}, "kosten": 0.0})
|
||||
for r in rows_neben:
|
||||
org = neben[r["organization_id"]]
|
||||
org["aufrufe"][r["source"]] = (org["aufrufe"].get(r["source"], 0) or 0) + (r["api_calls"] or 0)
|
||||
org["kosten"] += r["kosten"] or 0.0
|
||||
|
||||
ergebnis = []
|
||||
alle_ids = set(org_namen) | {r["tenant_id"] for r in rows_orgs} | set(neben)
|
||||
orgs_map = {r["tenant_id"]: r for r in rows_orgs}
|
||||
for org_id in sorted(alle_ids):
|
||||
o = orgs_map.get(org_id, {})
|
||||
n = neben.get(org_id, {"aufrufe": {}, "kosten": 0.0})
|
||||
ist_kosten = (o.get("ist_kosten") or 0.0) + n["kosten"]
|
||||
credits = berechne_credits(
|
||||
o.get("adhoc_ok") or 0, o.get("research_ok") or 0,
|
||||
o.get("research_erstlaeufe") or 0, n["aufrufe"], tarif,
|
||||
)
|
||||
gegenwert = round(credits * usd_per_credit, 2)
|
||||
ergebnis.append({
|
||||
"org_id": org_id,
|
||||
"name": org_namen.get(org_id, f"Organisation {org_id}"),
|
||||
"kosten_usd": round(ist_kosten, 2),
|
||||
"laeufe_ok": o.get("laeufe_ok") or 0,
|
||||
"laeufe_gesamt": o.get("laeufe_gesamt") or 0,
|
||||
"credits_geschaetzt": credits,
|
||||
"gegenwert_usd": gegenwert,
|
||||
"marge_usd": round(gegenwert - ist_kosten, 2),
|
||||
})
|
||||
ergebnis.sort(key=lambda x: -x["kosten_usd"])
|
||||
return ergebnis
|
||||
|
||||
|
||||
def fuelle_monatsserie(rows: list[dict], achse: list[str], schluessel: str,
|
||||
felder: list[str]) -> list[dict]:
|
||||
"""Füllt eine Monatsserie lückenlos mit Nullwerten auf."""
|
||||
vorhanden = {r["monat"]: r for r in rows}
|
||||
out = []
|
||||
for m in achse:
|
||||
r = vorhanden.get(m, {})
|
||||
out.append({"monat": m, **{f: (r.get(f) or 0) for f in felder}})
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Endpoint
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@router.get("")
|
||||
async def statistik(months: int = Query(12, ge=0, le=120),
|
||||
admin=Depends(get_current_admin)):
|
||||
"""Komplette Betriebsstatistik in einem Aufruf. months=0 heißt alles."""
|
||||
heute = datetime.now()
|
||||
heute_ym = heute.strftime("%Y-%m")
|
||||
start = None
|
||||
start_ym = None
|
||||
if months > 0:
|
||||
jahr, monat = heute.year, heute.month - (months - 1)
|
||||
while monat <= 0:
|
||||
monat += 12
|
||||
jahr -= 1
|
||||
start_ym = f"{jahr:04d}-{monat:02d}"
|
||||
start = start_ym + "-01"
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
# Q1: Detailzeilen der Monitor-Läufe
|
||||
q1 = ("SELECT strftime('%Y-%m', r.started_at) AS monat, i.type AS typ, "
|
||||
"r.status, r.trigger_type, r.total_cost_usd, r.input_tokens, "
|
||||
"r.output_tokens, r.articles_found, r.error_message, "
|
||||
"CASE WHEN r.completed_at IS NOT NULL THEN "
|
||||
"(julianday(r.completed_at) - julianday(r.started_at)) * 86400.0 END AS dauer_s "
|
||||
"FROM refresh_log r JOIN incidents i ON i.id = r.incident_id")
|
||||
p1: tuple = ()
|
||||
if start:
|
||||
q1 += " WHERE r.started_at >= ?"
|
||||
p1 = (start,)
|
||||
rows_refresh = [dict(r) for r in await (await db.execute(q1, p1)).fetchall()]
|
||||
|
||||
# Q2: Monatsserie je Quelle
|
||||
q2 = ("SELECT year_month AS monat, source AS quelle, "
|
||||
"SUM(total_cost_usd) AS kosten, SUM(api_calls) AS aufrufe "
|
||||
"FROM token_usage_monthly")
|
||||
p2: tuple = ()
|
||||
if start_ym:
|
||||
q2 += " WHERE year_month >= ?"
|
||||
p2 = (start_ym,)
|
||||
q2 += " GROUP BY year_month, source"
|
||||
rows_quelle = [dict(r) for r in await (await db.execute(q2, p2)).fetchall()]
|
||||
|
||||
# Q3: je Organisation (Erstlauf-Erkennung über ALLE Zeiten, Fenster außen)
|
||||
q3 = ("WITH research_first AS ("
|
||||
" SELECT r.id, ROW_NUMBER() OVER (PARTITION BY r.incident_id ORDER BY r.started_at) AS rn"
|
||||
" FROM refresh_log r JOIN incidents i ON i.id = r.incident_id"
|
||||
" WHERE r.status = 'completed' AND i.type = 'research') "
|
||||
"SELECT r.tenant_id, SUM(r.total_cost_usd) AS ist_kosten, "
|
||||
"SUM(CASE WHEN r.status='completed' THEN 1 ELSE 0 END) AS laeufe_ok, "
|
||||
"COUNT(*) AS laeufe_gesamt, "
|
||||
"SUM(CASE WHEN r.status='completed' AND i.type='adhoc' THEN 1 ELSE 0 END) AS adhoc_ok, "
|
||||
"SUM(CASE WHEN r.status='completed' AND i.type='research' THEN 1 ELSE 0 END) AS research_ok, "
|
||||
"SUM(CASE WHEN rf.rn = 1 THEN 1 ELSE 0 END) AS research_erstlaeufe "
|
||||
"FROM refresh_log r "
|
||||
"JOIN incidents i ON i.id = r.incident_id "
|
||||
"LEFT JOIN research_first rf ON rf.id = r.id")
|
||||
p3: tuple = ()
|
||||
if start:
|
||||
q3 += " WHERE r.started_at >= ?"
|
||||
p3 = (start,)
|
||||
q3 += " GROUP BY r.tenant_id"
|
||||
rows_orgs = [dict(r) for r in await (await db.execute(q3, p3)).fetchall()]
|
||||
|
||||
# Q4: Nicht-Monitor-Verbrauch je Organisation
|
||||
q4 = ("SELECT organization_id, source, SUM(api_calls) AS api_calls, "
|
||||
"SUM(total_cost_usd) AS kosten FROM token_usage_monthly "
|
||||
"WHERE source != 'monitor'")
|
||||
p4: tuple = ()
|
||||
if start_ym:
|
||||
q4 += " AND year_month >= ?"
|
||||
p4 = (start_ym,)
|
||||
q4 += " GROUP BY organization_id, source"
|
||||
rows_neben = [dict(r) for r in await (await db.execute(q4, p4)).fetchall()]
|
||||
|
||||
# Q5: Wachstum (drei kleine Abfragen)
|
||||
wachstum_rows: dict[str, list[dict]] = {}
|
||||
for art, tabelle in (("orgs", "organizations"), ("users", "users"), ("lagen", "incidents")):
|
||||
q5 = f"SELECT strftime('%Y-%m', created_at) AS monat, COUNT(*) AS n FROM {tabelle}"
|
||||
p5: tuple = ()
|
||||
if start:
|
||||
q5 += " WHERE created_at >= ?"
|
||||
p5 = (start,)
|
||||
q5 += " GROUP BY 1"
|
||||
wachstum_rows[art] = [dict(r) for r in await (await db.execute(q5, p5)).fetchall()]
|
||||
|
||||
# Q6: Meta, Tarif, Org-Namen
|
||||
letzter_lauf = (await (await db.execute("SELECT MAX(started_at) FROM refresh_log")).fetchone())[0]
|
||||
letzter_tum = (await (await db.execute("SELECT MAX(year_month) FROM token_usage_monthly")).fetchone())[0]
|
||||
tarif = dict(TARIFF_DEFAULTS)
|
||||
tarif_quelle = "defaults"
|
||||
try:
|
||||
for r in await (await db.execute("SELECT tariff_key, credits FROM billing_tariff")).fetchall():
|
||||
tarif[r["tariff_key"]] = r["credits"]
|
||||
tarif_quelle = "billing_tariff"
|
||||
except Exception:
|
||||
pass
|
||||
org_namen = {r["id"]: r["name"] for r in
|
||||
await (await db.execute("SELECT id, name FROM organizations")).fetchall()}
|
||||
|
||||
# Q7: Aktivität (Tabelle kann auf alten Ständen fehlen)
|
||||
mau_rows: list[dict] = []
|
||||
dau_rows: list[dict] = []
|
||||
erfassung_seit = None
|
||||
try:
|
||||
qm = "SELECT strftime('%Y-%m', day) AS monat, COUNT(DISTINCT user_id) AS nutzer FROM user_activity_days"
|
||||
pm: tuple = ()
|
||||
if start:
|
||||
qm += " WHERE day >= ?"
|
||||
pm = (start[:10],)
|
||||
qm += " GROUP BY 1"
|
||||
mau_rows = [dict(r) for r in await (await db.execute(qm, pm)).fetchall()]
|
||||
dau_start = (heute - timedelta(days=30)).strftime("%Y-%m-%d")
|
||||
dau_rows = [dict(r) for r in await (await db.execute(
|
||||
"SELECT day AS tag, COUNT(DISTINCT user_id) AS nutzer FROM user_activity_days "
|
||||
"WHERE day >= ? GROUP BY day ORDER BY day", (dau_start,))).fetchall()]
|
||||
erfassung_seit = (await (await db.execute("SELECT MIN(day) FROM user_activity_days")).fetchone())[0]
|
||||
except Exception:
|
||||
logger.warning("user_activity_days fehlt noch, MAU/DAU bleiben leer")
|
||||
|
||||
# Momentaufnahme aus last_login_at (verdaut beide Zeitstempelformate)
|
||||
nutzer_30d = (await (await db.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE is_active = 1 AND last_login_at IS NOT NULL "
|
||||
"AND datetime(last_login_at) >= datetime('now', '-30 days')")).fetchone())[0]
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
# Monatsachse: vom Fensterstart (oder ältestem Datenmonat) bis heute
|
||||
daten_monate = ([r["monat"] for r in rows_refresh if r.get("monat")] +
|
||||
[r["monat"] for r in rows_quelle] +
|
||||
[r["monat"] for art in wachstum_rows.values() for r in art if r.get("monat")])
|
||||
von_ym = start_ym or (min(daten_monate) if daten_monate else heute_ym)
|
||||
bis_ym = max([heute_ym] + daten_monate)
|
||||
achse = baue_monatsachse(von_ym, bis_ym)
|
||||
|
||||
agg = aggregiere_refresh(rows_refresh, achse)
|
||||
|
||||
# Monatsserie je Quelle in stabile Form bringen
|
||||
quellen = sorted({r["quelle"] for r in rows_quelle}) or []
|
||||
monat_quelle = []
|
||||
q_map = {(r["monat"], r["quelle"]): r for r in rows_quelle}
|
||||
for m in achse:
|
||||
for q in quellen:
|
||||
r = q_map.get((m, q), {})
|
||||
monat_quelle.append({"monat": m, "quelle": q,
|
||||
"kosten": round(r.get("kosten") or 0.0, 2),
|
||||
"aufrufe": r.get("aufrufe") or 0})
|
||||
|
||||
wachstum = []
|
||||
w_maps = {art: {r["monat"]: r["n"] for r in rows} for art, rows in wachstum_rows.items()}
|
||||
for m in achse:
|
||||
wachstum.append({"monat": m,
|
||||
"orgs": w_maps["orgs"].get(m, 0),
|
||||
"users": w_maps["users"].get(m, 0),
|
||||
"lagen": w_maps["lagen"].get(m, 0)})
|
||||
|
||||
mau_map = {r["monat"]: r["nutzer"] for r in mau_rows}
|
||||
aktivitaet = {
|
||||
"mau": [{"monat": m, "nutzer": mau_map.get(m, 0)} for m in achse],
|
||||
"dau": dau_rows,
|
||||
"erfassung_seit": erfassung_seit,
|
||||
"nutzer_30d": nutzer_30d,
|
||||
}
|
||||
|
||||
return {
|
||||
"window": {"months": months, "monate": achse},
|
||||
"datenstand": {"letzter_lauf": letzter_lauf, "letzter_tum_monat": letzter_tum,
|
||||
"kosten_ab": "2026-03",
|
||||
"erzeugt": heute.strftime("%Y-%m-%d %H:%M")},
|
||||
"tarif": {**tarif, "usd_per_credit": USD_PER_CREDIT, "quelle": tarif_quelle},
|
||||
"kpi": agg["kpi"],
|
||||
"monat_typ": agg["monat_typ"],
|
||||
"monat_quelle": monat_quelle,
|
||||
"monat_betrieb": agg["monat_betrieb"],
|
||||
"fehler_top": agg["fehler_top"],
|
||||
"dauer_typ": agg["dauer_typ"],
|
||||
"orgs": aggregiere_orgs(rows_orgs, rows_neben, org_namen, tarif, USD_PER_CREDIT),
|
||||
"wachstum": wachstum,
|
||||
"aktivitaet": aktivitaet,
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">☀︎</span>
|
||||
<div class="theme-switch-track">
|
||||
<div class="theme-switch-knob"></div>
|
||||
</div>
|
||||
<span class="theme-switch-icon theme-switch-moon">☽</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">← 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 & 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> — 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> — 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 →</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> — 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')">×</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')">×</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>
|
||||
|
||||
@@ -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;">✉</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
162
src/static/js/a11y.js
Normale Datei
@@ -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
@@ -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
560
src/static/js/aufgaben.js
Normale Datei
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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"}
|
||||
|
|
||||
${breakdownLine("error", "text-danger") || `<span class="text-danger">0 Fehler</span>`}${dErr}
|
||||
${breakdownLine("warning", "text-warning") || `<span class="text-warning">0 Warnungen</span>`}${dWarn}
|
||||
<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";
|
||||
}
|
||||
}
|
||||
@@ -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">ⓘ</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
440
src/static/js/statistik.js
Normale Datei
@@ -0,0 +1,440 @@
|
||||
"use strict";
|
||||
|
||||
// Statistik-Tab für das Verwaltungsportal. Selbst-injizierend nach dem
|
||||
// Muster von rechner.js, damit dashboard.html unverändert bleibt.
|
||||
// Charts sind handgerendertes Inline-SVG ohne Bibliothek. Die Serienfarben
|
||||
// sind mit dem dataviz-Palette-Validator gegen beide Themes geprüft
|
||||
// (Light #96791A/#2a78d6/#199e70, Dark #a8863a/#3987e5/#199e70, alle Checks PASS).
|
||||
|
||||
(function () {
|
||||
var geladen = false;
|
||||
var aktMonate = 12;
|
||||
|
||||
// ---------- Format-Helfer ----------
|
||||
|
||||
function fmt(n) { return Number(n || 0).toLocaleString("de-DE"); }
|
||||
function fmtUsd(n) {
|
||||
return "$" + Number(n || 0).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
function fmtMonat(ym) {
|
||||
if (!ym) return "";
|
||||
var namen = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
||||
return namen[parseInt(ym.slice(5, 7), 10) - 1] + " " + ym.slice(2, 4);
|
||||
}
|
||||
function fmtDatum(s) {
|
||||
if (!s) return "-";
|
||||
var d = String(s).slice(0, 10).split("-");
|
||||
return d.length === 3 ? d[2] + "." + d[1] + "." + d[0] : s;
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- SVG-Renderer ----------
|
||||
// Eine Achse pro Chart, rezessives Grid, 2px-Lücken zwischen Stapel-
|
||||
// Segmenten, Hover über grosszügige unsichtbare Trefferflächen mit <title>.
|
||||
|
||||
var W = 720, H = 230, ML = 52, MR = 8, MT = 10, MB = 26;
|
||||
|
||||
function niceMax(v) {
|
||||
if (v <= 0) return 1;
|
||||
var mag = Math.pow(10, Math.floor(Math.log10(v)));
|
||||
var norm = v / mag;
|
||||
var step = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
|
||||
return step * mag;
|
||||
}
|
||||
|
||||
function achsen(maxY, einheit) {
|
||||
var plotH = H - MT - MB;
|
||||
var out = "";
|
||||
for (var i = 0; i <= 3; i++) {
|
||||
var y = MT + plotH - (plotH * i) / 3;
|
||||
var wert = (maxY * i) / 3;
|
||||
out += '<line x1="' + ML + '" y1="' + y + '" x2="' + (W - MR) + '" y2="' + y +
|
||||
'" stroke="var(--border)" stroke-width="1"/>';
|
||||
out += '<text x="' + (ML - 6) + '" y="' + (y + 3) + '" text-anchor="end" font-size="10" fill="var(--text-muted)">' +
|
||||
esc(kurz(wert, einheit)) + "</text>";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function kurz(v, einheit) {
|
||||
var s;
|
||||
if (v >= 1000000) s = (v / 1000000).toLocaleString("de-DE", { maximumFractionDigits: 1 }) + " Mio";
|
||||
else if (v >= 10000) s = Math.round(v / 1000).toLocaleString("de-DE") + "k";
|
||||
else s = v.toLocaleString("de-DE", { maximumFractionDigits: v < 10 ? 1 : 0 });
|
||||
return einheit === "usd" ? s + " $" : s;
|
||||
}
|
||||
|
||||
function xLabels(labels) {
|
||||
var n = labels.length;
|
||||
if (!n) return "";
|
||||
var plotW = W - ML - MR;
|
||||
var schritt = n > 8 ? 2 : 1;
|
||||
var out = "";
|
||||
for (var i = 0; i < n; i++) {
|
||||
if (i % schritt !== 0) continue;
|
||||
var x = ML + (plotW * (i + 0.5)) / n;
|
||||
out += '<text x="' + x + '" y="' + (H - 8) + '" text-anchor="middle" font-size="10" fill="var(--text-muted)">' +
|
||||
esc(labels[i]) + "</text>";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// serien: [{name, farbe, werte[]}], gestapelt. einheit: 'usd' | ''
|
||||
function chartBalken(labels, serien, einheit) {
|
||||
var n = labels.length;
|
||||
if (!n) return '<div class="st-leer">Keine Daten im Zeitraum</div>';
|
||||
var summen = labels.map(function (_, i) {
|
||||
return serien.reduce(function (a, s) { return a + (s.werte[i] || 0); }, 0);
|
||||
});
|
||||
var maxY = niceMax(Math.max.apply(null, summen.concat([0])));
|
||||
var plotW = W - ML - MR, plotH = H - MT - MB;
|
||||
var breite = Math.min(40, (plotW / n) * 0.62);
|
||||
|
||||
var svg = achsen(maxY, einheit);
|
||||
for (var i = 0; i < n; i++) {
|
||||
var x = ML + (plotW * (i + 0.5)) / n - breite / 2;
|
||||
var yCursor = MT + plotH;
|
||||
var titel = [fmtMonat(labels[i]) || labels[i]];
|
||||
for (var s = 0; s < serien.length; s++) {
|
||||
var wert = serien[s].werte[i] || 0;
|
||||
titel.push(serien[s].name + ": " + (einheit === "usd" ? fmtUsd(wert) : fmt(wert)));
|
||||
if (wert <= 0) continue;
|
||||
var hoehe = (wert / maxY) * plotH;
|
||||
yCursor -= hoehe;
|
||||
// 2px-Lücke zwischen Segmenten, oberstes Segment leicht gerundet
|
||||
var segH = Math.max(0, hoehe - 2);
|
||||
if (segH > 0) {
|
||||
svg += '<rect x="' + x + '" y="' + yCursor + '" width="' + breite + '" height="' + segH +
|
||||
'" rx="2" fill="' + serien[s].farbe + '"/>';
|
||||
}
|
||||
}
|
||||
if (summen[i] <= 0) titel.push("Keine Daten");
|
||||
svg += '<rect x="' + (ML + (plotW * i) / n) + '" y="' + MT + '" width="' + (plotW / n) +
|
||||
'" height="' + plotH + '" fill="transparent"><title>' + esc(titel.join("\n")) + "</title></rect>";
|
||||
}
|
||||
svg += xLabels(labels.map(fmtMonat));
|
||||
return '<svg viewBox="0 0 ' + W + " " + H + '" style="width:100%;height:auto;display:block;" role="img">' + svg + "</svg>";
|
||||
}
|
||||
|
||||
// serien: [{name, farbe, werte[]}] mit null-Lücken. xIstTag = Tageslabels
|
||||
function chartLinie(labels, serien, einheit, xIstTag) {
|
||||
var n = labels.length;
|
||||
if (!n) return '<div class="st-leer">Keine Daten im Zeitraum</div>';
|
||||
var alle = [];
|
||||
serien.forEach(function (s) { s.werte.forEach(function (v) { if (v != null) alle.push(v); }); });
|
||||
if (!alle.length) return '<div class="st-leer">Keine Daten im Zeitraum</div>';
|
||||
var maxY = niceMax(Math.max.apply(null, alle));
|
||||
var plotW = W - ML - MR, plotH = H - MT - MB;
|
||||
|
||||
function px(i) { return ML + (plotW * (i + 0.5)) / n; }
|
||||
function py(v) { return MT + plotH - (v / maxY) * plotH; }
|
||||
|
||||
var svg = achsen(maxY, einheit);
|
||||
serien.forEach(function (s) {
|
||||
var punkte = [];
|
||||
for (var i = 0; i < n; i++) {
|
||||
if (s.werte[i] == null) continue;
|
||||
punkte.push(px(i).toFixed(1) + "," + py(s.werte[i]).toFixed(1));
|
||||
}
|
||||
if (punkte.length > 1) {
|
||||
svg += '<polyline points="' + punkte.join(" ") + '" fill="none" stroke="' + s.farbe +
|
||||
'" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
|
||||
}
|
||||
for (var j = 0; j < n; j++) {
|
||||
if (s.werte[j] == null) continue;
|
||||
svg += '<circle cx="' + px(j) + '" cy="' + py(s.werte[j]) + '" r="2.5" fill="' + s.farbe + '"/>';
|
||||
svg += '<circle cx="' + px(j) + '" cy="' + py(s.werte[j]) + '" r="9" fill="transparent"><title>' +
|
||||
esc((xIstTag ? fmtDatum(labels[j]) : fmtMonat(labels[j])) + "\n" + s.name + ": " +
|
||||
(einheit === "usd" ? fmtUsd(s.werte[j]) : fmt(s.werte[j]))) + "</title></circle>";
|
||||
}
|
||||
});
|
||||
svg += xLabels(xIstTag ? labels.map(function (t, i) { return i % Math.ceil(n / 8) === 0 ? String(t).slice(8, 10) + "." + String(t).slice(5, 7) + "." : ""; }) : labels.map(fmtMonat));
|
||||
return '<svg viewBox="0 0 ' + W + " " + H + '" style="width:100%;height:auto;display:block;" role="img">' + svg + "</svg>";
|
||||
}
|
||||
|
||||
function legende(eintraege) {
|
||||
return '<div class="st-legende">' + eintraege.map(function (e) {
|
||||
return '<span><span class="st-dot" style="background:' + e.farbe + '"></span>' + esc(e.name) + "</span>";
|
||||
}).join("") + "</div>";
|
||||
}
|
||||
|
||||
function kpiKachel(label, wert, sub) {
|
||||
return '<div class="stat-card"><div class="stat-label">' + esc(label) + '</div>' +
|
||||
'<div class="stat-value">' + wert + "</div>" +
|
||||
(sub ? '<div class="stat-sub">' + sub + "</div>" : "") + "</div>";
|
||||
}
|
||||
|
||||
function chartCard(titel, inhalt, legendeHtml) {
|
||||
return '<div class="card"><div class="card-header"><h2>' + esc(titel) + "</h2>" +
|
||||
(legendeHtml || "") + '</div><div class="card-body">' + inhalt + "</div></div>";
|
||||
}
|
||||
|
||||
// ---------- Rendering ----------
|
||||
|
||||
function C1() { return "var(--st-c1)"; }
|
||||
function C2() { return "var(--st-c2)"; }
|
||||
function C3() { return "var(--st-c3)"; }
|
||||
|
||||
function render(d) {
|
||||
var el = document.getElementById("st-inhalt");
|
||||
if (!el) return;
|
||||
var monate = d.window.monate || [];
|
||||
var mt = {};
|
||||
(d.monat_typ || []).forEach(function (r) { mt[r.monat + "|" + r.typ] = r; });
|
||||
function reihe(typ, feld) {
|
||||
return monate.map(function (m) { var r = mt[m + "|" + typ]; return r ? r[feld] : 0; });
|
||||
}
|
||||
function reiheNull(typ, feld) {
|
||||
return monate.map(function (m) { var r = mt[m + "|" + typ]; return r && r[feld] != null ? r[feld] : null; });
|
||||
}
|
||||
|
||||
// Übrige Quellen (globe/chat/enhance) je Monat zusammenfassen
|
||||
var nebenKosten = {};
|
||||
(d.monat_quelle || []).forEach(function (r) {
|
||||
if (r.quelle === "monitor") return;
|
||||
nebenKosten[r.monat] = (nebenKosten[r.monat] || 0) + (r.kosten || 0);
|
||||
});
|
||||
|
||||
var kpi = d.kpi || {};
|
||||
var html = "";
|
||||
|
||||
// --- Kosten & Marge ---
|
||||
html += '<h3 class="st-gruppe">Kosten & Marge</h3>';
|
||||
html += '<div class="stats-grid">' +
|
||||
kpiKachel("API-Kosten im Zeitraum", fmtUsd(kpi.kosten_usd), fmt(kpi.laeufe_gesamt) + " Läufe gesamt") +
|
||||
kpiKachel("Erfolgreiche Läufe", fmt(kpi.laeufe_ok), kpi.erfolgsquote != null ? kpi.erfolgsquote.toLocaleString("de-DE") + "% Erfolgsquote" : "") +
|
||||
kpiKachel("Median je Lauf", kpi.median_kosten_lauf != null ? fmtUsd(kpi.median_kosten_lauf) : "-", "nur Läufe mit Kosten") +
|
||||
kpiKachel("Artikel gefunden", fmt(kpi.artikel), fmt(kpi.tokens_in) + " Tokens ein / " + fmt(kpi.tokens_out) + " aus") +
|
||||
"</div>";
|
||||
|
||||
html += chartCard("API-Kosten je Monat (USD)",
|
||||
chartBalken(monate, [
|
||||
{ name: "Live-Monitoring", farbe: C1(), werte: reihe("adhoc", "kosten") },
|
||||
{ name: "Recherche", farbe: C2(), werte: reihe("research", "kosten") },
|
||||
{ name: "Übrige Quellen", farbe: C3(), werte: monate.map(function (m) { return Math.round((nebenKosten[m] || 0) * 100) / 100; }) },
|
||||
], "usd"),
|
||||
legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }, { name: "Übrige Quellen", farbe: C3() }]));
|
||||
|
||||
html += chartCard("Median-Kosten je Lauf (USD), der Frühwarner für Kosten-Anstieg",
|
||||
chartLinie(monate, [
|
||||
{ name: "Live-Monitoring", farbe: C1(), werte: reiheNull("adhoc", "kosten_median") },
|
||||
{ name: "Recherche", farbe: C2(), werte: reiheNull("research", "kosten_median") },
|
||||
], "usd"),
|
||||
legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }]));
|
||||
|
||||
// Orgs-Tabelle
|
||||
var orgZeilen = (d.orgs || []).map(function (o) {
|
||||
var margeCls = o.marge_usd >= 0 ? "st-pos" : "st-neg";
|
||||
return "<tr><td>" + esc(o.name) + "</td><td>" + fmt(o.laeufe_ok) + " / " + fmt(o.laeufe_gesamt) + "</td>" +
|
||||
"<td>" + fmtUsd(o.kosten_usd) + "</td><td>" + fmt(o.credits_geschaetzt) + "</td>" +
|
||||
"<td>" + fmtUsd(o.gegenwert_usd) + '</td><td class="' + margeCls + '">' + fmtUsd(o.marge_usd) + "</td></tr>";
|
||||
}).join("");
|
||||
html += '<div class="card"><div class="card-header"><h2>Je Organisation (Zeitraum)</h2></div>' +
|
||||
'<div class="table-wrap"><table><thead><tr><th>Organisation</th><th>Läufe ok/gesamt</th>' +
|
||||
"<th>Ist-Kosten</th><th>Credits (geschätzt)</th><th>Gegenwert</th><th>Marge</th></tr></thead>" +
|
||||
"<tbody>" + (orgZeilen || '<tr><td colspan="6" class="text-muted">Keine Daten</td></tr>') + "</tbody></table></div>" +
|
||||
'<div class="card-body st-fussnote">Schätzung zu den heutigen Sätzen (billing_tariff), es gibt keine historisierten Buchungen. ' +
|
||||
"Recherche-Anlage zählt als 3 Durchläufe (erster abgeschlossener Lauf je Recherche-Lage). " +
|
||||
"Studio-Bausteine (analysis/factcheck) fehlen mangels Nutzungsdaten, der Gegenwert ist eher zu niedrig. " +
|
||||
"Ist-Kosten enthalten auch Fehlläufe. 1 Credit = " + fmtUsd(d.tarif.usd_per_credit) + ".</div></div>";
|
||||
|
||||
// --- Nutzung ---
|
||||
html += '<h3 class="st-gruppe">Nutzung</h3>';
|
||||
var mb = {};
|
||||
(d.monat_betrieb || []).forEach(function (r) { mb[r.monat] = r; });
|
||||
function bReihe(feld) { return monate.map(function (m) { return mb[m] ? mb[m][feld] : 0; }); }
|
||||
|
||||
html += chartCard("Erfolgreiche Läufe je Monat",
|
||||
chartBalken(monate, [
|
||||
{ name: "Automatisch", farbe: C1(), werte: bReihe("auto") },
|
||||
{ name: "Manuell", farbe: C2(), werte: bReihe("manual") },
|
||||
], ""),
|
||||
legende([{ name: "Automatisch", farbe: C1() }, { name: "Manuell", farbe: C2() }]));
|
||||
|
||||
html += chartCard("Gefundene Artikel je Monat",
|
||||
chartBalken(monate, [
|
||||
{ name: "Live-Monitoring", farbe: C1(), werte: reihe("adhoc", "artikel") },
|
||||
{ name: "Recherche", farbe: C2(), werte: reihe("research", "artikel") },
|
||||
], ""),
|
||||
legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }]));
|
||||
|
||||
// --- Betrieb ---
|
||||
html += '<h3 class="st-gruppe">Betrieb</h3>';
|
||||
var dauerA = d.dauer_typ && d.dauer_typ.adhoc != null ? Math.round(d.dauer_typ.adhoc / 60) + " Min" : "-";
|
||||
var dauerR = d.dauer_typ && d.dauer_typ.research != null ? Math.round(d.dauer_typ.research / 60) + " Min" : "-";
|
||||
html += '<div class="stats-grid">' +
|
||||
kpiKachel("Erfolgsquote", kpi.erfolgsquote != null ? kpi.erfolgsquote.toLocaleString("de-DE") + "%" : "-",
|
||||
fmt(kpi.laeufe_ok) + " von " + fmt(kpi.laeufe_gesamt) + " Läufen") +
|
||||
kpiKachel("Dauer Live-Lauf (ø)", dauerA, "") +
|
||||
kpiKachel("Dauer Recherche-Durchlauf (ø)", dauerR, "") +
|
||||
"</div>";
|
||||
|
||||
html += chartCard("Läufe nach Ausgang je Monat",
|
||||
chartBalken(monate, [
|
||||
{ name: "Erfolgreich", farbe: "var(--success)", werte: bReihe("completed") },
|
||||
{ name: "Fehler", farbe: "var(--danger)", werte: bReihe("error") },
|
||||
{ name: "Abgebrochen", farbe: "var(--text-muted)", werte: bReihe("cancelled") },
|
||||
], ""),
|
||||
legende([{ name: "Erfolgreich", farbe: "var(--success)" }, { name: "Fehler", farbe: "var(--danger)" }, { name: "Abgebrochen", farbe: "var(--text-muted)" }]));
|
||||
|
||||
var fehlerZeilen = (d.fehler_top || []).map(function (f) {
|
||||
return "<tr><td>" + esc(f.grund) + "</td><td>" + fmt(f.n) + "</td></tr>";
|
||||
}).join("");
|
||||
html += '<div class="card"><div class="card-header"><h2>Häufigste Fehlerursachen</h2></div>' +
|
||||
'<div class="table-wrap"><table><thead><tr><th>Ursache</th><th>Anzahl</th></tr></thead><tbody>' +
|
||||
(fehlerZeilen || '<tr><td colspan="2" class="text-muted">Keine Fehler im Zeitraum</td></tr>') +
|
||||
"</tbody></table></div></div>";
|
||||
|
||||
// --- Aktivität ---
|
||||
html += '<h3 class="st-gruppe">Aktivität</h3>';
|
||||
var akt = d.aktivitaet || {};
|
||||
html += '<div class="stats-grid">' +
|
||||
kpiKachel("Nutzer mit Login (30 Tage)", fmt(akt.nutzer_30d), "Momentaufnahme aus dem letzten Login") +
|
||||
kpiKachel("Aktivitäts-Erfassung", akt.erfassung_seit ? "seit " + fmtDatum(akt.erfassung_seit) : "startet jetzt",
|
||||
akt.erfassung_seit ? "je Nutzer und Tag ein Eintrag" : "MAU/DAU wachsen ab diesem Update") +
|
||||
"</div>";
|
||||
var mauWerte = (akt.mau || []).map(function (r) { return r.nutzer; });
|
||||
var mauMax = Math.max.apply(null, mauWerte.concat([0]));
|
||||
if (mauMax > 0) {
|
||||
html += chartCard("Aktive Nutzer je Monat (MAU)",
|
||||
chartBalken(monate, [{ name: "Aktive Nutzer", farbe: C1(), werte: mauWerte }], ""), "");
|
||||
var dauLabels = (akt.dau || []).map(function (r) { return r.tag; });
|
||||
var dauWerte = (akt.dau || []).map(function (r) { return r.nutzer; });
|
||||
html += chartCard("Aktive Nutzer je Tag (DAU, letzte 30 Tage)",
|
||||
dauLabels.length
|
||||
? chartLinie(dauLabels, [{ name: "Aktive Nutzer", farbe: C1(), werte: dauWerte }], "", true)
|
||||
: '<div class="st-leer">Noch keine Tageswerte</div>', "");
|
||||
} else {
|
||||
html += '<div class="card"><div class="card-body st-leer">Noch keine Aktivitätsdaten. ' +
|
||||
"Die Erfassung (ein Eintrag je Nutzer und Tag) beginnt mit diesem Update, " +
|
||||
"die MAU/DAU-Kurven erscheinen hier, sobald sich Nutzer im Monitor bewegen.</div></div>";
|
||||
}
|
||||
|
||||
// --- Wachstum ---
|
||||
html += '<h3 class="st-gruppe">Wachstum</h3>';
|
||||
var w = {};
|
||||
(d.wachstum || []).forEach(function (r) { w[r.monat] = r; });
|
||||
function wReihe(feld) { return monate.map(function (m) { return w[m] ? w[m][feld] : 0; }); }
|
||||
html += chartCard("Neuzugänge je Monat",
|
||||
chartBalken(monate, [
|
||||
{ name: "Organisationen", farbe: C1(), werte: wReihe("orgs") },
|
||||
{ name: "Nutzer", farbe: C2(), werte: wReihe("users") },
|
||||
{ name: "Lagen", farbe: C3(), werte: wReihe("lagen") },
|
||||
], ""),
|
||||
legende([{ name: "Organisationen", farbe: C1() }, { name: "Nutzer", farbe: C2() }, { name: "Lagen", farbe: C3() }]));
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Datenstand
|
||||
var ds = document.getElementById("st-datenstand");
|
||||
if (ds) {
|
||||
var teile = [];
|
||||
if (d.datenstand.letzter_lauf) teile.push("Letzter Lauf " + fmtDatum(d.datenstand.letzter_lauf));
|
||||
teile.push("Kostenerfassung ab 03/2026");
|
||||
if (d.tarif && d.tarif.quelle === "defaults") teile.push("Sätze aus Rückfallwerten");
|
||||
ds.textContent = "Datenstand: " + teile.join(" · ");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Laden ----------
|
||||
|
||||
function lade() {
|
||||
var el = document.getElementById("st-inhalt");
|
||||
if (el) el.innerHTML = '<div class="st-leer">Lade Statistik …</div>';
|
||||
API.get("/api/statistik?months=" + aktMonate).then(function (d) {
|
||||
render(d);
|
||||
}).catch(function (err) {
|
||||
if (el) el.innerHTML = '<div class="st-leer">Statistik konnte nicht geladen werden: ' + esc(err.message) + "</div>";
|
||||
});
|
||||
}
|
||||
|
||||
function setzeZeitraum(monate, btn) {
|
||||
aktMonate = monate;
|
||||
document.querySelectorAll("#st-zeitraum button").forEach(function (b) { b.classList.remove("btn-primary"); b.classList.add("btn-secondary"); });
|
||||
btn.classList.remove("btn-secondary");
|
||||
btn.classList.add("btn-primary");
|
||||
lade();
|
||||
}
|
||||
|
||||
// ---------- Injektion (Muster rechner.js) ----------
|
||||
|
||||
var STYLES = "\n" +
|
||||
"#sec-statistik { --st-c1:#a8863a; --st-c2:#3987e5; --st-c3:#199e70; }\n" +
|
||||
'[data-theme="light"] #sec-statistik { --st-c1:#96791A; --st-c2:#2a78d6; --st-c3:#199e70; }\n' +
|
||||
"#sec-statistik .st-gruppe { margin:26px 0 12px; font-size:16px; border-bottom:1px solid var(--border); padding-bottom:6px; }\n" +
|
||||
"#sec-statistik .st-kopf { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }\n" +
|
||||
"#sec-statistik .st-kopf h2 { font-size:20px; margin-right:auto; }\n" +
|
||||
"#sec-statistik .st-datenstand { font-size:12px; color:var(--text-muted); margin-top:6px; }\n" +
|
||||
"#sec-statistik .st-legende { display:flex; gap:14px; font-size:12px; color:var(--text-secondary); flex-wrap:wrap; }\n" +
|
||||
"#sec-statistik .st-dot { display:inline-block; width:10px; height:10px; border-radius:3px; margin-right:5px; vertical-align:-1px; }\n" +
|
||||
"#sec-statistik .st-leer { padding:22px; text-align:center; color:var(--text-muted); font-size:13px; }\n" +
|
||||
"#sec-statistik .st-fussnote { font-size:12px; color:var(--text-muted); border-top:1px solid var(--border); }\n" +
|
||||
"#sec-statistik .st-pos { color:var(--success); }\n" +
|
||||
"#sec-statistik .st-neg { color:var(--danger); }\n" +
|
||||
"#sec-statistik .card { margin-bottom:16px; }\n";
|
||||
|
||||
function init() {
|
||||
var nav = document.querySelector("nav.nav-tabs");
|
||||
var content = document.querySelector(".app-content");
|
||||
if (!nav || !content || document.getElementById("sec-statistik")) return;
|
||||
|
||||
var style = document.createElement("style");
|
||||
style.textContent = STYLES;
|
||||
document.head.appendChild(style);
|
||||
|
||||
var sektion = document.createElement("div");
|
||||
sektion.className = "section";
|
||||
sektion.id = "sec-statistik";
|
||||
sektion.innerHTML =
|
||||
'<div class="st-kopf">' +
|
||||
"<h2>Statistik</h2>" +
|
||||
'<div id="st-zeitraum" style="display:flex; gap:6px;">' +
|
||||
'<button type="button" class="btn btn-secondary btn-small" data-monate="6">6 Monate</button>' +
|
||||
'<button type="button" class="btn btn-primary btn-small" data-monate="12">12 Monate</button>' +
|
||||
'<button type="button" class="btn btn-secondary btn-small" data-monate="0">Alles</button>' +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
'<div class="st-datenstand" id="st-datenstand"></div>' +
|
||||
'<div id="st-inhalt" style="margin-top:14px;"></div>';
|
||||
content.appendChild(sektion);
|
||||
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "nav-tab";
|
||||
btn.setAttribute("data-section", "statistik");
|
||||
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>Statistik';
|
||||
// Zwischen Audit-Log und Verbrauchsrechner, egal in welcher Ladereihenfolge
|
||||
var rechnerTab = nav.querySelector('.nav-tab[data-section="rechner"]');
|
||||
nav.insertBefore(btn, rechnerTab || null);
|
||||
|
||||
btn.addEventListener("click", function () {
|
||||
document.querySelectorAll("nav.nav-tabs .nav-tab").forEach(function (t) { t.classList.remove("active"); });
|
||||
btn.classList.add("active");
|
||||
document.querySelectorAll(".app-content > .section").forEach(function (s) { s.classList.remove("active"); });
|
||||
var detail = document.getElementById("orgDetail");
|
||||
if (detail) detail.classList.remove("active");
|
||||
sektion.classList.add("active");
|
||||
if (!geladen) { geladen = true; lade(); }
|
||||
});
|
||||
|
||||
nav.addEventListener("click", function (e) {
|
||||
var ziel = e.target.closest(".nav-tab");
|
||||
if (!ziel || ziel === btn) return;
|
||||
btn.classList.remove("active");
|
||||
sektion.classList.remove("active");
|
||||
});
|
||||
|
||||
sektion.querySelectorAll("#st-zeitraum button").forEach(function (b) {
|
||||
b.addEventListener("click", function () { setzeZeitraum(parseInt(b.getAttribute("data-monate"), 10), b); });
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
50
tests/test_health_run.py
Normale Datei
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
93
tests/test_statistik.py
Normale Datei
@@ -0,0 +1,93 @@
|
||||
"""Tests für die puren Aggregationsfunktionen der Betriebsstatistik."""
|
||||
from routers.statistik import (
|
||||
baue_monatsachse, normalisiere_fehler, aggregiere_refresh,
|
||||
berechne_credits, aggregiere_orgs, fuelle_monatsserie,
|
||||
)
|
||||
|
||||
TARIF = {"monitor_adhoc": 45, "monitor_research": 40, "chat": 1, "enhance": 1, "globe": 1}
|
||||
|
||||
|
||||
def test_monatsachse_lueckenlos_ueber_jahresgrenze():
|
||||
assert baue_monatsachse("2025-11", "2026-02") == ["2025-11", "2025-12", "2026-01", "2026-02"]
|
||||
|
||||
|
||||
def test_monatsachse_leer_bei_unsinn():
|
||||
assert baue_monatsachse("2026-05", "2026-02") == []
|
||||
assert baue_monatsachse("", "2026-02") == []
|
||||
|
||||
|
||||
def test_fehler_normalisierung():
|
||||
assert normalisiere_fehler("Endgültig fehlgeschlagen nach 3 Versuchen: Claude CLI Timeout nach 420s") == "Claude CLI Timeout nach 420s"
|
||||
assert normalisiere_fehler("Verwaist (Neustart, x)") == "Verwaist (Neustart, automatisch bereinigt)"
|
||||
assert normalisiere_fehler(None) == "Unbekannt"
|
||||
|
||||
|
||||
def _lauf(monat="2026-03", typ="adhoc", status="completed", kosten=5.0, **kw):
|
||||
return {"monat": monat, "typ": typ, "status": status, "trigger_type": "auto",
|
||||
"total_cost_usd": kosten, "input_tokens": 100, "output_tokens": 50,
|
||||
"articles_found": 3, "error_message": None, "dauer_s": 600.0, **kw}
|
||||
|
||||
|
||||
def test_median_ignoriert_nullkosten():
|
||||
"""Läufe mit 0 $ (Kostenerfassung fehlte) dürfen den Median nicht verzerren."""
|
||||
rows = [_lauf(kosten=0.0), _lauf(kosten=4.0), _lauf(kosten=6.0)]
|
||||
agg = aggregiere_refresh(rows, ["2026-03"])
|
||||
assert agg["kpi"]["median_kosten_lauf"] == 5.0
|
||||
assert agg["kpi"]["laeufe_ok"] == 3
|
||||
|
||||
|
||||
def test_betrieb_und_fehler():
|
||||
rows = [_lauf(), _lauf(status="error", kosten=0.0,
|
||||
error_message="Endgültig fehlgeschlagen nach 3 Versuchen: Timeout X"),
|
||||
_lauf(status="error", kosten=0.0, error_message="Timeout X")]
|
||||
agg = aggregiere_refresh(rows, ["2026-03"])
|
||||
b = agg["monat_betrieb"][0]
|
||||
assert b["completed"] == 1 and b["error"] == 2
|
||||
assert agg["fehler_top"][0] == {"grund": "Timeout X", "n": 2}
|
||||
assert agg["kpi"]["erfolgsquote"] == 33.3
|
||||
|
||||
|
||||
def test_aggregiere_refresh_leer():
|
||||
agg = aggregiere_refresh([], [])
|
||||
assert agg["kpi"]["laeufe_gesamt"] == 0
|
||||
assert agg["kpi"]["erfolgsquote"] is None
|
||||
assert agg["monat_typ"] == []
|
||||
|
||||
|
||||
def test_credits_formel_erstlauf_dreifach():
|
||||
"""Anlage einer Recherche = 3 Durchläufe, erscheint aber als 1 Log-Zeile."""
|
||||
credits = berechne_credits(adhoc_ok=2, research_ok=3, research_erstlaeufe=1,
|
||||
neben_aufrufe={"chat": 4}, tarif=TARIF)
|
||||
# 2*45 + (3 + 2*1)*40 + 4*1 = 90 + 200 + 4
|
||||
assert credits == 294
|
||||
|
||||
|
||||
def test_credits_formel_ohne_erstlauf():
|
||||
"""Grenzfall, der chronologisch erste Lauf war ein error. Dann zählt kein
|
||||
Erstlauf-Zuschlag (die Heuristik greift erst beim ersten completed Lauf,
|
||||
was die SQL-Seite über rn=1 auf completed-Zeilen abbildet)."""
|
||||
assert berechne_credits(0, 2, 0, {}, TARIF) == 80
|
||||
|
||||
|
||||
def test_orgs_marge():
|
||||
rows_orgs = [{"tenant_id": 1, "ist_kosten": 10.0, "laeufe_ok": 2, "laeufe_gesamt": 3,
|
||||
"adhoc_ok": 2, "research_ok": 0, "research_erstlaeufe": 0}]
|
||||
rows_neben = [{"organization_id": 1, "source": "chat", "api_calls": 10, "kosten": 0.5}]
|
||||
orgs = aggregiere_orgs(rows_orgs, rows_neben, {1: "Test-Org"}, TARIF, 0.20)
|
||||
o = orgs[0]
|
||||
assert o["credits_geschaetzt"] == 100 # 2*45 + 10*1
|
||||
assert o["gegenwert_usd"] == 20.0
|
||||
assert o["kosten_usd"] == 10.5
|
||||
assert o["marge_usd"] == 9.5
|
||||
|
||||
|
||||
def test_orgs_ohne_laeufe_taucht_trotzdem_auf():
|
||||
orgs = aggregiere_orgs([], [], {5: "Leer-Org"}, TARIF, 0.20)
|
||||
assert orgs[0]["org_id"] == 5
|
||||
assert orgs[0]["credits_geschaetzt"] == 0
|
||||
|
||||
|
||||
def test_fuelle_monatsserie():
|
||||
rows = [{"monat": "2026-03", "n": 2}]
|
||||
out = fuelle_monatsserie(rows, ["2026-02", "2026-03"], "monat", ["n"])
|
||||
assert out == [{"monat": "2026-02", "n": 0}, {"monat": "2026-03", "n": 2}]
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren