Commits vergleichen

...

56 Commits

Autor SHA1 Nachricht Datum
claude-dev
4532086e0e feat(orgs): KI-Verarbeitung organisationsweit einstellbar
Der Monitor löst den KI-Weg als Lage vor Organisation vor Servervorgabe auf
und liest dafür das Org-Setting ai_backend. Geschrieben werden konnte es
bisher nur direkt in der Datenbank, eine Oberfläche gab es nicht. Für einen
Kunden, der die DSGVO-Fassung will, ist das der praktisch wichtigste Weg,
denn der stellt einmal zentral um und nicht Lage für Lage.

Das Feld steht jetzt dort, wo die Pipeline-Sprache schon steht, also als
eigene Spalte in der Lizenztabelle der aktiven Lizenz, und zusätzlich im
Anlege-Dialog für neue Organisationen.

Drei Werte. Leer bedeutet Servervorgabe, cli den heutigen Weg über Anthropic,
bedrock die EU-Fassung über AWS Bedrock in Frankfurt mit Recherche über den
europäischen Suchindex staan.

Der Leerstring ist dabei ein gültiger Wert und keine fehlende Angabe. Der
Router prüft deshalb auf None statt auf Wahrheit, sonst ließe sich eine
gesetzte Vorgabe nie wieder auf die Servervorgabe zurücknehmen.

26 Prüfungen, ohne Datenbank und ohne Netz.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:31:06 +00:00
a032366d5d Release-Notes: Neue Zugänge, bessere Navigation & UI-Verbesserungen 2026-07-26 00:22:06 +02:00
claude-dev
485638ab3b fix(dashboard): Kachel Artikel-Uebersetzung erneut entfernt
Der Uebertrag-Commit 9bf3adb hat die parallel gelandete Entfernung
16a9b9c versehentlich rueckgaengig gemacht (Komplett-Datei-Upload aus
einem aelteren Arbeitsstand). Diese Wiederholung stellt den gewollten
Zustand wieder her, Karte + JS-Block sind wieder raus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 21:53:37 +00:00
claude-dev
9bf3adbacb feat(abrechnung): Credits-Uebertrag komplett entfernt
Produktentscheidung 07/2026: ungenutzte Credits verfallen zum
Periodenende. Uebertrag-Checkbox aus allen drei Formularen
(Kontingent bearbeiten, Lizenz anlegen, Org anlegen) samt
Ausgrau-Logik raus, credits_rollover/credits_carried aus Modellen,
Endpoints (overview/current/PUT budget) und insert_license_row raus,
Anzeige inkl. Uebertrag im Org-Detail raus. Migrationsskript legt die
beiden Spalten nicht mehr an. Gegenstueck zum Monitor-Commit f8499c4,
Promote-Reihenfolge wie immer Monitor VOR Portal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 21:51:02 +00:00
claude-dev
16a9b9c436 refactor(dashboard): Kachel Artikel-Uebersetzung entfernt
Die Kachel wird nicht mehr gebraucht, die Uebersetzungs-Einstellungen im
Monitor bleiben unveraendert (global TRANSLATOR_ENABLED=false, Override
pro Organisation weiterhin wirksam).

- Markup der Karte translationCard aus dashboard.html entfernt.
- Zugehoerigen JS-Block aus app.js entfernt (setupTranslation,
  renderTranslation, loadTranslationStatus, pollTranslation,
  startTranslation, cancelTranslation, formatDuration) samt der drei
  Aufrufstellen beim Start und beim Wechsel auf den Dashboard-Tab.

Die API-Endpoints unter /api/translation und translation_agent.py
bleiben bestehen, sie haben jetzt nur keine Oberflaeche mehr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:37:14 +00:00
claude-dev
c5e761899a feat(ui): schwebende Scroll-Leiste + breitere Inhaltsflaeche
- Die Quellenliste hat jetzt eine dauerhaft sichtbare Scroll-Leiste, die
  am unteren Bildschirmrand schwebt, solange die Tabelle im Bild ist
  (Proxy-Element, beidseitig synchronisiert, blendet sich aus wenn
  nichts zu scrollen ist). Scroll-Leisten der Tabellen sind sichtbarer
  gestylt, Daumen faerbt sich beim Ueberfahren gold.
- app-content von 1200 auf 1720 Pixel Maximalbreite. Ein breiteres
  Fenster zeigt jetzt wirklich mehr Tabelle statt leerer Raender.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:44:28 +00:00
claude-dev
c35a30a823 feat(quellen): leichteres Seitwaerts-Scrollen in der Quellenliste
- Name-Spalte (und bei Kundenquellen-Filter die Checkbox-Spalte) bleibt
  links angepinnt, die Aktionen-Spalte rechts. Solide Hintergruende und
  weiche Trennkanten, damit nichts durchscheint (Dark + Light).
- Kategorie-Ueberschriften und Ausklapp-Inhalte (Notizen, Health-Details)
  bleiben beim Scrollen im sichtbaren Bereich (position sticky).
- Ziehen-zum-Scrollen: die Tabelle laesst sich mit gedrueckter Maustaste
  greifen und seitwaerts ziehen (generischer Helper enableDragScroll,
  Knoepfe/Checkboxen bleiben normal bedienbar, nach echtem Ziehen wird
  der Klick geschluckt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:39:58 +00:00
claude-dev
bb11e05fed fix(zugaenge): echte Marken-Glyphen fuer X und Telegram
Das Lucide-X sah aus wie ein Schliessen-Symbol, der einfache
Papierflieger traf das Telegram-Logo nicht. Jetzt die bekannten
einfarbigen Marken-Pfade (X-Wortmarke, Telegram-Kreis mit Flieger),
beide in currentColor, damit sie in Dark und Light passen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:20:38 +00:00
claude-dev
842641562b feat(zugaenge): X-Zugang und Telegram-Zugang als gleichwertige Dienst-Karten
Beide Dienste erscheinen als Karte mit Lucide-Icon im Titel (X-Kreuz,
Telegram-Papierflieger) und derselben Tabellenstruktur (Benutzername,
E-Mail bzw. Telefon, Status, Anfragen, Letzte Nutzung/Pruefung, Aktionen).
Die nackte Konten-Zaehlung entfaellt, die X-Knoepfe ziehen in den
Kartenkopf. Telegram-Zeile zeigt Konto, Telefonnummer (Monitor-Commit
5450fd2), Status und letzte Pruefung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:16:21 +00:00
claude-dev
16250cd8f5 feat(zugaenge): Telegram-Session-Status im Reiter Recherche-Zugaenge
Der Reiter X-Recherche-Konten heisst jetzt Recherche-Zugaenge und zeigt
oben eine Telegram-Karte: Session gueltig/Problem, verbunden als, Stand.
Die Daten meldet der Monitor (Commit 7dae63e) in die geteilte Tabelle
system_status, das Portal liest nur an (GET /api/x-scraper/telegram-status,
tolerant wenn Tabelle/Eintrag fehlt). Migration fuer die
Portal-Staging-DB liegt bei. Promote-Reihenfolge: Monitor VOR Portal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:07:01 +00:00
claude-dev
14546f7c0a chore(sources): Erklaertext im Reiter X-Recherche-Konten entfernt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 18:59:23 +00:00
claude-dev
ae4fb106ad feat(sources): Sammel-Bearbeitung fuer Vorschlaege im Aufgaben-Reiter
Die Vorschlags-Tabelle hat jetzt eine Checkbox-Spalte mit Alle-auswaehlen
(wirkt auf die gefilterten Zeilen) und zwei Knoepfe Ausgewaehlte annehmen /
Ausgewaehlte ablehnen. Noetig geworden, weil ein Pruefaluf hunderte
Vorschlaege erzeugen kann und Einzelklicks mit Dialog dann nicht praktikabel
sind. Neuer Endpoint PUT /suggestions/bulk nutzt exakt dieselbe
Entscheidungslogik wie der Einzel-Endpoint (gemeinsamer Helper
_apply_suggestion_decision, inkl. Auto-Reject und Duplikat-Schutz),
bereits erledigte IDs werden uebersprungen statt als Fehler gewertet.
Ein Audit-Eintrag pro Sammelaktion mit Zaehlern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 18:54:43 +00:00
claude-dev
c5f63b1d14 fix(sources): Feinschliff nach Konsistenz-Pruefung + UI-Rueckmeldungen
Echte Fehler:
- esc() maskiert jetzt auch Anfuehrungszeichen. Quellennamen mit
  Apostroph (z.B. LExpress) brachen sonst die Zeilen-Knoepfe.
- formatDateTime parst SQLite-UTC-Zeitstempel jetzt als UTC statt
  Lokalzeit (Zeiten waren um den Zeitzonen-Offset verschoben).
- Audit-Filter Bis-Datum schliesst den gewaehlten Tag jetzt komplett ein.

UI-Rueckmeldungen:
- Beschreibung in der Vorschlags-Tabelle bricht mehrzeilig um statt
  abgeschnitten zu werden.
- Bestaetigen-Knopf im Dialog ist standardmaessig Gold, Rot nur noch bei
  destruktiven Aktionen (Org/Nutzer/Quelle loeschen, X-Konto entfernen).
- Alle >= 0.85 genehmigen ist ausgegraut, solange keine Klassifikationen
  ausstehen.

Konsistenz:
- GET /suggestions deckelt pending nicht mehr auf 50 (Badge und Tabelle
  liefen auseinander), bearbeitete weiterhin letzte 50.
- /stats zaehlt jetzt alle Quellen wie die Liste (nicht nur aktive),
  Grundquellen/Kundenquellen-Aufteilung wird immer angezeigt.
- Health-Spalte sortiert nach Schweregrad statt alphabetisch.
- Aufgaben-Badge laedt schon beim Seitenstart, nicht erst beim Reiterklick.
- Loesung-suchen-Knopf stellt nach Fehler das Icon wieder her, native
  confirm()-Dialoge durch showConfirm ersetzt.
- Tote Endpoints GET /health (Alt-Liste) und GET /classification/stats
  entfernt, tote CSS-Bloecke (activity-*, badge-health-*,
  review-conf-filter) geloescht.
- Smoke-Test deckt jetzt alle Klassifikations-/Upload-/Sync-Endpoints ab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 18:48:16 +00:00
claude-dev
bc3b0bb9ee feat(ui): Filter als Checkbox-Dropdowns (Knopf klappt Mehrfachauswahl auf)
Auf Nutzerwunsch statt offen liegender Checkbox-Reihen: jeder Filter ist
jetzt ein Dropdown-Knopf, der beim Klick eine Checkbox-Liste aufklappt.
Der Knopf zeigt die Anzahl der gesetzten Haken als goldene Plakette,
Klick daneben schliesst das Panel, nichts angehakt = alle. Gilt ueberall
(Quellenliste, Aufgaben, Audit-Log), da zentral in renderFilterGroup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 18:27:42 +00:00
claude-dev
2df2fa1cfa feat(ui): alle Filter als Checkbox-Gruppen statt Dropdowns + Aufgaben-Filter
- Neuer wiederverwendbarer Helper renderFilterGroup (app.js) + filter-bar-CSS.
  Mehrfachauswahl per Checkbox, nichts angehakt = alle, Gold-Akzent.
- Quellenliste: Herkunft/Typ/Status/Kategorie/Sprache als Checkbox-Leiste
  unter der Action-Bar. Checkbox-Spalte + Bulk-Promote erscheinen, wenn der
  Herkunfts-Filter ausschliesslich auf Kundenquellen steht.
- Aufgaben ist jetzt filterbar: Vorschlagstyp und Prioritaet fuer die
  Vorschlags-Tabelle, Konfidenz-Stufen (ab 85, 70 bis 85, 50 bis 70,
  unter 50 Prozent) fuer die Klassifikations-Karten. Das alte
  Mindest-Konfidenz-Dropdown entfaellt, die Queue laedt einmal komplett
  und filtert clientseitig.
- Audit-Log: Aktion/Ressource/Admin als Checkbox-Gruppen, Backend
  akzeptiert dafuer kommagetrennte Mehrfachwerte (IN-Filter,
  Einzelwert bleibt kompatibel). Ressourcen-ID und Datumsfelder bleiben
  Eingabefelder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 18:23:27 +00:00
claude-dev
14e766173c feat(sources): Knopf Jetzt pruefen startet den Health-Check aus dem Portal
- POST /api/sources/health/run (202, bei laufendem Run 409) stoesst den
  Lauf als BackgroundTask an: eigene DB-Connection, run_health_checks +
  generate_suggestions aus den geteilten Diensten, exakt nach dem Vorbild
  des Monitor-Nachtjobs daily_source_health_check. Kein Code-Duplikat.
- GET /api/sources/health/run-status liefert den In-Memory-Status
  (health_run.py, pure Funktionen mit Doppelstart-Sperre, unit-getestet).
  Nach einem Neustart ist der Status idle, ein partieller Stand wird vom
  naechsten Lauf archiviert und ueberschrieben (wie beim Nachtjob).
- Frontend: Knopf Jetzt pruefen + Zuletzt geprueft in der Stats-Leiste
  der Quellenliste, Polling alle 4 s, Abschluss-Toast mit geprueft/
  Probleme/neue Vorschlaege, danach Liste + Aufgaben-Badge neu laden.
  Beim Oeffnen des Reiters wird ein laufender Run aus anderer Session
  erkannt und weiterverfolgt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 18:14:14 +00:00
claude-dev
bcfdac158e feat(sources): Aufgaben-Posteingang ersetzt Quellen-Health und Klassifikation
- Neuer Unterreiter Aufgaben buendelt Health-Vorschlaege (Tabelle inkl.
  Loesung-suchen), Klassifikations-Review-Karten und den eingeklappten
  Verlauf (erledigte Vorschlaege + letzte Prueflaeufe). Die dritte
  Navigationsebene (healthSubTabs) entfaellt komplett.
- Badge zeigt die Summe aus offenen Vorschlaegen und ausstehenden
  Klassifikationen, gespeist vom neuen billigen GET /api/sources/tasks/summary
  (gleiche Bedingung wie /classification/stats, laeuft nie auseinander).
  Badge laedt jetzt schon beim Klick auf den Quellen-Hauptreiter.
- aufgaben.js NEU (uebernimmt Vorschlags-/Verlauf-Rendering aus
  source-health.js und den kompletten Klassifikations-Block aus sources.js),
  source-health.js GELOESCHT (60s-Cache, Checks-Tabelle mit eigener
  Pagination und Filtern entfaellt, die Details stecken jetzt als
  Ausklapp-Zeile in der Quellenliste).
- Quellen hat damit 3 statt 5 Unterreiter: Quellenliste, Aufgaben,
  X-Recherche-Konten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 18:11:19 +00:00
claude-dev
bbbcc51fc2 feat(sources): vereinte Quellenliste (Grund- + Kundenquellen in einer Tabelle)
- GET /api/sources mit scope=all|global|tenant ersetzt GET /global und
  GET /tenant. Liefert org_name (LEFT JOIN organizations) und wie bisher
  health_status + Artikel-Aktivitaet fuer ALLE Quellen.
- GET /global/stats wird GET /stats (zaehlt jetzt beide Herkuenfte,
  neu by_origin und last_check), GET /global/languages wird GET /languages.
- PUT /global/{id} akzeptiert jetzt auch Kundenquellen (Guard ohne
  tenant_id-Filter), damit der Deaktivieren-Knopf fuer beide funktioniert.
  DELETE bleibt bewusst global-only.
- Neu GET /{source_id}/health fuer die Ausklapp-Details je Quelle.
- Frontend: EIN Render-/Filter-/Sortier-Pfad statt zwei (Tenant-Kette
  komplett entfernt, ~200 Zeilen). Herkunfts-Filter, Organisations-Spalte,
  Checkbox-Spalte + Bulk-Promote nur bei Herkunft Kundenquellen.
- Health-Badge ist klickbar und klappt eine gemeinsame Detailzeile aus
  (Notizen + Checks mit Meldung und Zeitpunkt) mit Deaktivieren-Knopf.
  Klickweg kaputte Quelle finden und deaktivieren: 3 statt 8 bis 10 Klicks.
- Unterreiter Kundenquellen entfaellt, erster Reiter heisst Quellenliste.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 18:06:35 +00:00
claude-dev
1a08facb64 fix(sources): Meta-Reparatur (telegram/x/x_account/pdf_document) + toten Code entfernt
- source_meta.py kennt jetzt den echten Bestand: Kategorien telegram (107 Quellen)
  und x (40), Typen x_account und pdf_document. Die 10 nie belegten
  Lagen-Kategorien und der ungenutzte Typ excluded sind raus.
- Pydantic-Pattern source_type um x_account ergaenzt, excluded raus.
  Behebt 422 beim Speichern von 40 X-Account-Quellen.
- Modal-Selects (Typ, Kategorie, PDF-Kategorie) werden aus /api/sources/meta
  befuellt statt hartkodiert. Submit sendet category/source_type nur noch,
  wenn nicht leer. Behebt stilles Loeschen der Kategorie bei Telegram-Quellen.
- source_suggester: Import auf shared.agents.claude_client angepasst
  (war im Portal seit jeher kaputt, Konvention wie source_classifier).
- Toter Code raus: POST /health/run-stream (147 Z. SSE-Duplikat),
  runHealthCheck() im Frontend, excluded_counts-CTE + Sperren-Spalte
  (immer 0), Trend-Delta (braucht 2 Runs, es gibt 1), Alignment-Chips
  (source_alignments ist leer), doppeltes formatDateTime, redundanter
  setupHealthTab-Listener, toter healthContent-Zugriff.
- Tests: Kategorien-/Typen-Sets neu gepinnt, Smoke-Karteileichen entfernt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 17:58:08 +00:00
claude-dev
68fc1794a9 feat(org-detail): Slug aus der Oberfläche, Info-Zeile im Kopf entfernt
Der Slug ist eine rein technische Kennung und verschwindet aus Tabelle,
Kopfzeile und Anlege-Formular. Beim Anlegen wird er unsichtbar aus dem
Namen erzeugt, bei Kollision hängt das Portal selbstständig eine Nummer
an. Die Info-Zeile unter dem Org-Namen (Slug, Nutzer, Lizenz,
Kontingent) entfällt komplett, dieselben Werte stehen direkt darunter
in der Übersicht und der Bearbeiten-Karte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 17:01:40 +00:00
claude-dev
15cea7cb3b feat(dashboard): Karte Letzte Aktivität entfernt
Die Detailansicht dafür ist das Audit-Log, die Dashboard-Dublette ist
auf Nutzerwunsch raus. Bald ablaufende Lizenzen läuft jetzt volle Breite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:57:40 +00:00
claude-dev
4302a11d1e feat(dashboard): Kosten-Kachel nennt den Monat beim Namen (z.B. Juli 26)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:56:37 +00:00
claude-dev
7336ba8ad0 feat(dashboard): Kosten-Kachel zeigt den aktuellen Monat
Die Kachel heißt jetzt API-Kosten aktueller Monat (Läufe des Monats und
Lebenszeit-Summe klein in der Unterzeile) statt der nackten
Gesamtsumme. Datenquelle ist der Statistik-Endpoint in einem Aufruf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:54:40 +00:00
claude-dev
9d76fb994a feat(dashboard): Vorfälle-Kachel entfernt
Lagen werden ausschließlich im Monitor verwaltet, die Kachel im Portal
war ein reiner Zähler ohne Ziel und ist auf Nutzerwunsch raus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:47:40 +00:00
claude-dev
888a405a54 feat(org-detail): Bereich Verbrauch & Kontingent heißt jetzt Übersicht
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:45:27 +00:00
claude-dev
50a8237e96 feat(org-detail): Verbrauchs-Korrektur direkt an der Kachel, Zahlenfelder ohne Pfeile
Die Korrektur des Verbrauchszählers sitzt jetzt direkt an der Kachel
"Verbrauchte Credits", wo man den Zähler sieht, als Eingabefeld mit
Korrigieren-Knopf und Sicherheitsrückfrage. Das Feld im Kontingent-
Formular entfällt dafür. Außerdem sind die Hochzähl-Pfeile an allen
Zahlenfeldern des Portals entfernt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:33:24 +00:00
claude-dev
73ae7be76d feat(org-detail): Verbrauch korrigieren sichtbar statt eingeklappt
Auf Nutzerwunsch ist das Korrektur-Feld (Kulanz, Fehlbereinigung) jetzt
ein normales sichtbares Formularfeld neben der Warnschwelle, mit
deutlichem Hinweistext, statt in einem Aufklapp-Bereich zu stecken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:30:01 +00:00
claude-dev
e302261314 feat(org-detail): Altmodus-Felder aus allen Formularen entfernt
Kosten pro Credit (USD) und Budget-Limit (USD) gehörten zum
abgeschalteten Altmodus (BILLING_MODE=actual) und sind aus dem
Kontingent-Formular, dem Lizenz-Modal und der Org-Anlage entfernt.
Der Erweitert-Bereich heißt jetzt "Verbrauch korrigieren" und enthält
nur noch das Eingriffsfeld für den Verbrauchszähler. Die DB-Spalten,
die API-Felder und der Rückfallmodus im Monitor bleiben unangetastet,
falls der Altmodus je reaktiviert wird, setzt man die Werte per API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:26:51 +00:00
claude-dev
e9395f33eb feat(org-detail): Nutzer-Verwaltung in die Lizenz- und Kontingent-Karte
Die Karte heißt jetzt "Lizenz, Nutzer & Credits-Kontingent bearbeiten"
und enthält alles Einstellbare einer Organisation untereinander,
Lizenz-Tabelle, Nutzer-Verwaltung (Limit-Anzeige, Nutzer anlegen,
Nutzerliste) und das Kontingent-Formular. Der separate Nutzer-Block
entfällt, das Org-Detail ist damit ein einziger Bereich.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:57:57 +00:00
claude-dev
83af0d6897 feat(org-detail): Widerrufen-Knopf entfernt, Warnschwelle 0 = aus
Zwei Rückmeldungen des Nutzers.

- Der Widerrufen-Knopf in der Lizenz-Tabelle ist raus. Eine neue Lizenz
  widerruft die alte automatisch, und einen Kunden abschalten erledigt
  der prominente Org-Status-Umschalter. Der API-Endpoint /revoke bleibt.
- Die Warnschwelle lässt sich jetzt abschalten, 0 = aus, in allen drei
  Formularen (Budget, Neue Lizenz, Org-Anlage) samt Modell- und
  Endpoint-Validierung. Gegenstück im Monitor ist Commit 17b1886
  (bisher fiel eine 0 dort still auf die Voreinstellung 80 zurück).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:52:40 +00:00
claude-dev
db0a8c1d9e feat(org-detail): Org-Status als prominenter Umschalter in der Kopfzeile
Statt des kleinen Auswahlfelds neben dem Namen jetzt ein deutlich
sichtbarer Zwei-Knopf-Umschalter (Aktiv grün mit Haken, Deaktiviert rot
mit Sperr-Symbol), der aktive Zustand ist farbig gefüllt, Klick
speichert sofort wie bisher.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:49:38 +00:00
claude-dev
f301e22c6b feat(org-detail): Max Nutzer inline editierbar, Org-Status in die Kopfzeile
Zwei Verbesserungen auf Nutzer-Rückmeldung.

- Max Nutzer war nach dem Anlegen einer Lizenz nirgends mehr änderbar.
  In der aktiven Lizenz-Zeile ist es jetzt ein Zahlenfeld mit
  Sofort-Speicherung (der generische Lizenz-Updater akzeptiert max_users
  mit Prüfung 1 bis 1000), die Limit-Anzeige über der Nutzerliste zieht
  mit.
- "Status" (Lizenz) direkt neben "Org-Status" war verwirrend. Der
  Org-Status sitzt jetzt als Umschalter in der Kopfzeile der
  Organisation (an der Stelle des bisherigen Abzeichens), die Tabelle
  behält nur noch Lizenz-Status und Pipeline-Sprache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:46:51 +00:00
claude-dev
2c3087e389 feat(org-detail): Einstellungen-Block aufgelöst, alles direkt einstellbar
Der frühere Bereich Einstellungen ist weg, seine Inhalte sind dorthin
gewandert, wo man sie sieht.

- Org-Status (Aktiv/Deaktiviert) und Pipeline-Sprache sind neue Spalten
  in der Lizenz-Tabelle, in der aktiven Zeile direkt umschaltbar mit
  Sofort-Speicherung und Toast, widerrufene Zeilen zeigen einen Strich.
- Umbenennen (kleines Modal) und Organisation löschen (mit Rückfrage)
  sitzen als Knöpfe in der Kopfzeile der Organisation.
- Das Org-Detail besteht damit nur noch aus zwei Bereichen, Verbrauch &
  Kontingent und Nutzer. Kein Backend-Change nötig, PUT /api/orgs/{id}
  konnte Name, Status und Sprache schon immer einzeln ändern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:42:42 +00:00
claude-dev
863e8835e4 feat(org-detail): Verbrauch weiter aufgeräumt, Lizenz und Kontingent in einer Karte
Auf Nutzerwunsch vier Änderungen im Org-Detail.

1. Kundensicht (Credits) und interner Einkauf (USD) stehen in EINER
   Karten-Reihe (Verbraucht, Verbleibend, API-Kosten Monat). Die Karte
   "Budget-Limit (USD, Altmodus)" entfällt in der Anzeige, das Feld
   bleibt im Erweitert-Bereich des Formulars erhalten.
2. Die Laufzeitwerte-Karte (Übertrag, laufende Periode, Verfügbar
   gesamt) ist raus. Der Übertrag bleibt im Verbleibend-Wert als
   Zusatz "(inkl. X Übertrag)" sichtbar.
3. "Lizenz & Credits-Kontingent bearbeiten" steht jetzt an zweiter
   Stelle direkt unter der Karten-Reihe und enthält BEIDES, die
   Lizenz-Verwaltung (Tabelle, Neue Lizenz, Verlängern, Widerrufen)
   und darunter das Kontingent-Formular. Der eigene Lizenzen-Block
   entfällt, der Monatliche Verlauf rückt ans Ende.
4. Die Sprungmarken-Leiste (Verbrauch/Nutzer/Lizenzen/Einstellungen)
   oben im Org-Detail ist entfernt, die Seite ist kurz genug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:22:45 +00:00
claude-dev
25ec80c8e4 feat(statistik): Hauptreiter Statistik mit kompletter Betriebsstatistik
Neuer Reiter zwischen Audit-Log und Verbrauchsrechner, Anlass war die
Dashboard-Kachel API-Kosten gesamt, die nirgendwo hinführte (sie springt
jetzt hierher). Kennzahlen-Set nach Betreiber-Best-Practices für
nutzungsbasierte KI-Produkte (Kostenverlauf, Marge je Kunde, Betrieb).

- GET /api/statistik?months=N liefert alles in einem JSON. Kosten und
  Läufe je Monat und Lagentyp (führende Quelle refresh_log mit
  lückenlosem incidents-Join), Quellen-Split aus token_usage_monthly,
  Erfolgsquote, normalisiertes Fehler-Ranking, Dauer je Typ, Wachstum,
  MAU/DAU aus user_activity_days und je Organisation Ist-Kosten plus
  Margen-Schätzung zu heutigen Sätzen (Recherche-Anlage zählt dreifach,
  weil der Multi-Pass nur eine Log-Zeile erzeugt). Aggregation als pure,
  unit-getestete Funktionen, Monatsachse lückenlos gefüllt, Mediane nur
  über Läufe mit Kosten (Erfassung begann erst 03/2026).
- statistik.js injiziert sich selbst nach dem rechner.js-Muster,
  Platzierung doppelt gesichert (Ladereihenfolge mit async=false plus
  insertBefore). Charts als Inline-SVG ohne Bibliothek, eine Achse je
  Chart, Hover-Titel mit großen Trefferflächen, Legenden, Serienfarben
  je Theme mit dem dataviz-Palette-Validator geprüft (alle Checks PASS).
  Zeitraum 6/12/Alles, Datenstand-Hinweis, ehrliche Fußnote unter der
  Margen-Tabelle, Leerzustand für die junge Aktivitäts-Erfassung.
- Migration user_activity_days für die Portal-Staging-DB (auf Live legt
  sie der Monitor an, dort läuft die Erfassung in get_current_user).
- 10 neue Unit-Tests plus Auth-Smoke für den Endpoint, 96 Tests grün
  (die zwei bekannten shared-Import-Fehlschläge bestehen unverändert).

Gegenstück im Monitor: Commit d367c60 (Aktivitäts-Erfassung).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:09:34 +00:00
claude-dev
5fd4e64b4c docs: pricing-Router und billing_tariff-Migration in der Projektstruktur
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:32:47 +00:00
claude-dev
c4ea949f91 feat(pricing): Sätze kommen aus der Preistabelle, Rechner kann sie speichern
Die Credits-Sätze sind im Portal nicht mehr hart codiert. Neuer Endpoint
GET /api/pricing/tariff liest die geteilte Tabelle billing_tariff (mit
den beschlossenen Werten als Rückfallebene, falls die Tabelle fehlt),
PUT /api/pricing/tariff schreibt sie mit Validierung und Audit-Eintrag.

- Die Infozeile im Verbrauch-Bereich rendert ihre Zahlen aus der Tabelle,
  der statische Text bleibt nur als Rückfallebene stehen.
- Die Rechner-Regler starten auf den aktiven Preisen, der Hinweis nennt
  die tatsächlich abgerechneten Sätze, und der neue Knopf "Diese Sätze
  als aktive Preise speichern" schreibt sie nach Rückfrage in die
  Tabelle. Der Monitor bucht ab der nächsten Aktion mit den neuen Werten.
- Migration migrations/2026-07-25_billing_tariff.py für die eigene
  Staging-DB (auf Live legt die Tabelle der Monitor an und befüllt sie
  mit seinen wirksamen Konfigurationswerten).

Gegenstück im Monitor: Commit 027244a (billing_tariff als Quelle der
Buchung). Promote-Reihenfolge bleibt, erst Monitor, dann Verwaltung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:32:25 +00:00
claude-dev
a639579366 feat(struktur): weniger Klicks, Org-Detail als eine Seite, Kontingent in der Übersicht
Drei Vereinfachungen auf Nutzerwunsch.

1. Das Org-Detail hat keine Unterreiter mehr. Verbrauch & Kontingent,
   Nutzer, Lizenzen und Einstellungen stehen untereinander auf einer
   Seite (Verbrauch optisch zuerst, per CSS-Order ohne Block-Verschiebung),
   die bisherigen Reiter sind Sprungmarken, die nur scrollen. Alle
   Bereiche laden sofort, der Org-Kopf zeigt den Kontingent-Stand als
   Kurzinfo.
2. Die Organisations-Tabelle bekommt eine Kontingent-Spalte mit
   Verbraucht/Verfügbar und Mini-Balken (gelb ab 50, rot ab 80 Prozent),
   Datenquelle ist die vorhandene Overview-API. Ganze Zeile klickbar.
3. Der globale Hauptreiter "Lizenzen" entfällt, er zeigte nur die
   Ablauf-Liste, die bereits auf dem Dashboard steht. Die Einträge dort
   öffnen jetzt direkt die jeweilige Organisation, die Kachel "Aktive
   Lizenzen" führt zur Organisations-Verwaltung. loadExpiringLicenses
   bleibt als abgesicherter No-op für Alt-Aufrufer erhalten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:21:57 +00:00
claude-dev
7751657444 feat(dashboard): Kacheln und Karten verlinken auf ihre Bereiche
Die Statistik-Kacheln springen jetzt zu ihren Zielen. Organisationen und
Nutzer zur Organisations-Verwaltung, Aktive Lizenzen zum Lizenzen-Tab,
Vorfälle öffnen den Monitor in einem neuen Tab (im Portal gibt es keine
Vorfalls-Seite). Die Karte "Bald ablaufende Lizenzen" führt zum
Lizenzen-Tab, "Letzte Aktivität" zum Audit-Log. Hover-Optik mit
Gold-Rahmen und Chevron, bedienbar auch per Tastatur (Enter/Leertaste).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:07:55 +00:00
claude-dev
8fd087ea0c feat(design): AegisSight-Design, Lucide-Icons und aufgeräumter Verbrauchs-Bereich
Drei Pakete auf Nutzerwunsch.

1. Design-Angleichung an das AegisSight-Design-System. Navy/Gold-Token
   statt Slate/Amber, Light- UND Dark-Theme mit goldenem Schiebeschalter
   oben rechts (Dark = Standard, Wahl persistiert), Barrierefreiheits-
   Panel über js/a11y.js (identische Datei wie im Monitor), echtes Logo
   mit heller Plakette in Kopfzeile und Login, Poppins/Inter-Typografie,
   Radius 6/8 nach Styleguide. Automatisch erzeugte Light-Theme-
   Überschreibungen für alle Badge-Pastelltöne.
2. Lucide-Icons (Inline-SVG, keine externen Quellen) für alle
   Navigations-Tabs inkl. nachgeladenem Verbrauchsrechner, die
   Anlege-Knöpfe, Abmelden und Speichern.
3. Verbrauchs-Bereich aufgeräumt. Reiter heißt jetzt Verbrauch,
   Statistik in Kundensicht (Credits) und Interne Kosten (Einkauf, USD)
   getrennt, Leiste heißt Kontingent-Auslastung, Sätze-Infozeile mit
   Sprung zum Verbrauchsrechner, Altmodus-Felder (USD) und die
   Verbrauchs-Korrektur liegen in einem eingeklappten Bereich
   Erweitert mit Erklärtext. Der Rechner trägt einen deutlichen
   Simulations-Hinweis. Lizenz verlängern nutzt ein eigenes Modal
   statt des Browser-prompt().

Cache-Buster auf 20260725 gebumpt, alle Restfarben tokenisiert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:57:59 +00:00
claude-dev
eeb74c74c1 fix(kontingent): Übertrag in den Folgemonat standardmäßig aus
Auf Nutzerwunsch findet ohne bewusste Entscheidung kein Übertrag statt.
Modell-Default, beide Checkboxen (Lizenz-Modal, Org-Modal) und der
Modell-Test stehen jetzt auf aus, der Übertrag bleibt pro Kunde
zuschaltbar. Der DB-Spalten-Default stand ohnehin schon auf 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:22:54 +00:00
claude-dev
e0c39de21f feat(kontingent): Credits-Stellwerte je Lizenz + Lizenz beim Org-Anlegen
Bedienseite für das Credits-Monatskontingent des Monitors. Das Portal
setzt jetzt alle Stellwerte, der Monitor bleibt Herr der Laufzeitfelder.

- Lizenz-Modal und Budget-Formular um Abrechnungszeitraum (monthly/total),
  Übertrag-Checkbox und Warnschwelle erweitert, Vorbelegungen auf das
  beschlossene Modell (10.000 Credits, monatlich, Übertrag an, 0,20 USD).
- Neue Organisation kann direkt eine Lizenz mit Kontingent anlegen
  (OrgCreate.license, verschachtelt). Org- und Lizenz-Insert laufen in
  EINER Transaktion, schlägt der Lizenz-Teil fehl, entsteht keine
  halbe Organisation. Gemeinsamer Helper insert_license_row statt
  Duplikation der Widerruf- und Audit-Logik.
- PUT /api/token-usage/budget validiert credits_period (monthly|total)
  und budget_warning_percent (1 bis 100), credits_rollover wird wie
  unlimited_budget normalisiert. Laufzeitfelder bleiben bewusst
  außerhalb der Whitelist.
- Overview und Org-Detail rechnen den Rest jetzt gegen verfügbar =
  Kontingent + Übertrag, dieselbe Bezugsgröße wie der Hard-Stop im
  Monitor. Neuer read-only Block Laufzeitwerte im Token-Subtab.
- Startup-Check loggt einen deutlichen Fehler, wenn die licenses-Tabelle
  die Credits-Spalten noch nicht hat (falsche Deploy-Reihenfolge).
- Migration migrations/2026-07-25_credits_period.py (idempotent,
  wortgleich mit der Monitor-Migration) für die Portal-Staging-DB,
  auf Live gilt: Monitor VOR Portal promoten.
- 13 neue Modell-Tests, pytest grün (die zwei bekannten
  shared-Import-Fehlschläge bestehen unverändert seit vorher).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:11:02 +00:00
c95903fb3a Release-Notes: Neuer Verbrauchsrechner im Verwaltungsbereich 2026-07-24 22:37:48 +02:00
Claude
5c34bc7638 fix(rechner): Aktualisierung in den Regler-Beschriftungen sichtbar machen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:31:20 +00:00
Claude
607da8f43d feat(rechner): Saetze per Regler einstellbar mit Empfehlung aus 30-Tage-API-Kosten
Neuer Router pricing.py (GET /api/pricing/cost-basis), liest die geteilte
Live-DB read-only und liefert Median-Kosten je Lagentyp plus Credit-Empfehlung.
main.py um den Router erweitert. Frontend rechner.js mit Reglern statt fester
Saetze und Empfehlungsanzeige je Aktion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:19:58 +00:00
Claude
6ce42a8809 feat(rechner): Grundwerte-Karten mit grossen Credits-Saetzen ueber dem Rechner
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:06:55 +00:00
Claude
706479721a feat(rechner): Verbrauchsrechner-Tab mit Posten-Modell (Credits-Saetze 45/40/120/1)
Eigenstaendiges Modul rechner.js, injiziert Tab, Bereich und Styles selbst.
dashboard.html und main.py bleiben unveraendert, damit der Staging-Deploy
nicht mit dem lokalen KB-Arbeitsstand kollidiert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:35:07 +00:00
52f5debe44 Release-Notes: X-Recherche-Konten im Verwaltungsportal verwalten 2026-05-22 14:41:16 +02:00
claude-dev
8c75a70655 feat(x-scraper): X-Recherche-Konten im Verwaltungsportal verwalten
Neuer Sub-Tab "X-Recherche-Konten" unter Quellen: die X-Login-Konten,
mit denen der Monitor bei X scrapt (twscrape-Account-Pool), anzeigen,
hinzufuegen, Cookies erneuern, aktiv/inaktiv schalten, entfernen, plus
Sperren-Reset.

- neuer Router x_scraper.py, verwaltet den twscrape-Pool ueber dessen API
- X_ACCOUNTS_DB_PATH in config.py
- twscrape als Abhaengigkeit (git-main-Pin)
- Sub-Tab, Tabelle und zwei Modals in dashboard.html, Logik in x-scraper.js

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:22:35 +00:00
6bfff67c2f Promote develop → main (2026-05-22 11:13 UTC) 2026-05-22 13:13:41 +02:00
746b1bcd81 Release-Notes: Interne Verbesserungen 2026-05-22 13:13:39 +02:00
7ec153ca49 Release-Notes: Interne Verbesserungen 2026-05-22 13:13:27 +02:00
claude-dev
a27fe44b0b Revert "feat(sources): X-Account-Verwaltung im Verwaltungsportal"
This reverts commit bd476edb13.
2026-05-22 11:12:28 +00:00
6c623a8ae5 Promote develop → main (2026-05-22 11:09 UTC) 2026-05-22 13:09:25 +02:00
240222cb2a Release-Notes: X-Konten direkt im Verwaltungsportal verwalten 2026-05-22 13:09:23 +02:00
claude-dev
bd476edb13 feat(sources): X-Account-Verwaltung im Verwaltungsportal
Neuer Sub-Tab "X-Accounts" unter Quellen: die als Recherchequelle
eingebundenen X-Accounts anzeigen, hinzufuegen, bearbeiten und entfernen.
Schreibt source_type=x_account in die geteilte sources-Tabelle, von wo
der Monitor sie pro Lage nutzt.

- x_account im source_type-Pattern von GlobalSourceCreate/Update
- primary_language in Create/Update plus INSERT (Keyword-Matching)
- x_account-Typ und x-Kategorie in source_meta.py
- Sub-Tab, Tabelle und Modal in dashboard.html, Logik in sources.js

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:06:07 +00:00
40 geänderte Dateien mit 5978 neuen und 2337 gelöschten Zeilen

Datei anzeigen

@@ -43,15 +43,19 @@ src/:
database.py: "DB-Connection-Pool, Schema-Helper"
models.py: "Pydantic Request/Response-Schemas"
audit.py: "log_action, get_client_ip, row_to_dict, /api/audit Router"
health_run.py: "In-Memory-Status des manuellen Health-Runs (pure Funktionen, unit-testbar). Der Knopf Jetzt prüfen ruft die geteilten Dienste source_health + source_suggester auf (POST /api/sources/health/run, Polling über GET /health/run-status). Zusätzlich zum Monitor-Nachtjob um 04:00. Status ist nach Neustart idle, partieller Stand heilt beim nächsten Lauf"
routers/:
organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)"
licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module)"
organizations.py: "CRUD Mandanten (organizations + Org-Settings inkl. output_language und ai_backend + Token-Budget)"
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)
@@ -208,6 +219,7 @@ tests:
test_models.py: Pydantic-Validierung (MagicLink, Org, License, User)
test_source_meta.py: Single Source of Truth Konsistenz
test_imports.py: alle Backend-Module importierbar (Syntax-Catchnet)
test_org_ki_vorgabe.py: organisationsweiter KI-Weg (ai_backend), Modelle, Router und Oberflaeche. Der Leerstring bedeutet Servervorgabe und muss von einer fehlenden Angabe unterscheidbar bleiben
philosophie:
- reine Unit-Tests, kein DB-Zugriff, kein HTTP-Server

Datei anzeigen

@@ -1,4 +1,55 @@
[
{
"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",
"title": "Neuer Verbrauchsrechner im Verwaltungsbereich",
"items": [
"Neuer Rechner-Tab zeigt Kosten übersichtlich nach Verbrauchsposten (Credits-Basierte Abrechnung)",
"Übersichtskarten mit aktuellen Credit-Sätzen direkt über dem Rechner",
"Credit-Sätze per Regler individuell anpassbar – mit Empfehlung aus den letzten 30 Tagen",
"Änderungen an den Reglern werden sofort in den Beschriftungen sichtbar"
]
},
{
"version": "2026-05-22T12:41Z",
"date": "2026-05-22",
"title": "X-Recherche-Konten im Verwaltungsportal verwalten",
"items": [
"Recherche-Konten für X (ehemals Twitter) können jetzt direkt im Verwaltungsportal hinzugefügt, bearbeitet und entfernt werden."
]
},
{
"version": "2026-05-22T11:13Z",
"date": "2026-05-22",
"title": "Interne Verbesserungen",
"items": []
},
{
"version": "2026-05-22T11:13Z",
"date": "2026-05-22",
"title": "Interne Verbesserungen",
"items": []
},
{
"version": "2026-05-22T11:09Z",
"date": "2026-05-22",
"title": "X-Konten direkt im Verwaltungsportal verwalten",
"items": [
"X-Konten können jetzt zentral über das Verwaltungsportal angelegt und verwaltet werden."
]
},
{
"version": "2026-05-22T09:37Z",
"date": "2026-05-22",

Datei anzeigen

@@ -0,0 +1,67 @@
"""Migration 2026-07-25: Preistabelle billing_tariff anlegen und befüllen.
Die Tabelle ist die pflegbare Quelle der Credits-Sätze je Aktion. Der Monitor
legt sie beim Start selbst an (init_db, mit seinen Konfigurationswerten als
Erstbefüllung). Die Portal-Staging-Umgebung nutzt aber eine EIGENE Datenbank,
auf der kein Monitor läuft, deshalb braucht sie dieses Skript. Idempotent,
vorhandene Sätze werden NICHT überschrieben (INSERT OR IGNORE).
Ausführung:
DB_PATH=/home/claude-dev/AegisSight-Monitor-staging/data/osint.db python3 migrations/2026-07-25_billing_tariff.py
DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-07-25_billing_tariff.py
"""
import os
import sqlite3
import sys
# Beschlossene Verkaufssätze (23.07.2026), identisch zu den Monitor-Defaults
SEED = {
"monitor_adhoc": 45.0,
"monitor_research": 40.0,
"analysis": 12.0,
"factcheck": 12.0,
"chat": 1.0,
"enhance": 1.0,
"globe": 1.0,
}
def main(db_path: str) -> int:
if not os.path.exists(db_path):
print(f"FEHLER: DB nicht gefunden: {db_path}", file=sys.stderr)
return 1
conn = sqlite3.connect(db_path, timeout=60)
conn.execute("PRAGMA busy_timeout = 60000")
conn.execute("PRAGMA journal_mode = WAL")
print(f"Migration auf {db_path}")
conn.execute("""
CREATE TABLE IF NOT EXISTS billing_tariff (
tariff_key TEXT PRIMARY KEY,
credits REAL NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
print(" + billing_tariff angelegt (oder vorhanden)")
for key, credits in SEED.items():
cur = conn.execute(
"INSERT OR IGNORE INTO billing_tariff (tariff_key, credits) VALUES (?, ?)",
(key, credits),
)
if cur.rowcount:
print(f" + Satz {key} = {credits}")
else:
print(f" = Satz {key} war bereits gesetzt")
conn.commit()
conn.close()
print("Migration abgeschlossen.")
return 0
if __name__ == "__main__":
db_path = os.environ.get("DB_PATH", "/home/claude-dev/osint-data/osint.db")
sys.exit(main(db_path))

Datei anzeigen

@@ -0,0 +1,57 @@
"""Migration 2026-07-25: Credits-Perioden-Spalten in licenses.
Legt die fünf Spalten des Credits-Monatskontingents an, exakt dieselben
Statements wie die Monitor-Migration (AegisSight-Monitor, src/database.py,
Block "Credits-Periode"). Idempotent, ein zweiter Lauf ist ein No-op.
Hintergrund: Der Monitor legt diese Spalten beim Start selbst an. Die
Portal-Staging-Umgebung nutzt aber eine EIGENE Datenbank, auf der kein
Monitor läuft, deshalb braucht sie dieses Skript. Auf der Live-DB ist es
nur ein Fallback für den Fall, dass das Portal vor dem Monitor deployt wird.
Ausführung:
DB_PATH=/home/claude-dev/AegisSight-Monitor-staging/data/osint.db python3 migrations/2026-07-25_credits_period.py
DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-07-25_credits_period.py
"""
import os
import sqlite3
import sys
# Spaltenname -> ALTER-Statement, wortgleich mit der Monitor-Migration.
# credits_rollover/credits_carried gab es hier kurzzeitig, der Uebertrag
# wurde als Produktentscheidung 07/2026 entfernt (verfaellt zum Periodenende).
COLUMNS = {
"credits_period": "ALTER TABLE licenses ADD COLUMN credits_period TEXT DEFAULT 'monthly'",
"credits_period_start": "ALTER TABLE licenses ADD COLUMN credits_period_start TEXT",
"budget_warning_sent": "ALTER TABLE licenses ADD COLUMN budget_warning_sent INTEGER DEFAULT 0",
}
def main(db_path: str) -> int:
if not os.path.exists(db_path):
print(f"FEHLER: DB nicht gefunden: {db_path}", file=sys.stderr)
return 1
conn = sqlite3.connect(db_path, timeout=60)
conn.execute("PRAGMA busy_timeout = 60000")
conn.execute("PRAGMA journal_mode = WAL")
print(f"Migration auf {db_path}")
cols = [c[1] for c in conn.execute("PRAGMA table_info(licenses)")]
for name, stmt in COLUMNS.items():
if name not in cols:
conn.execute(stmt)
print(f" + licenses.{name} Spalte hinzugefügt")
else:
print(f" = licenses.{name} war bereits da")
conn.commit()
conn.close()
print("Migration abgeschlossen.")
return 0
if __name__ == "__main__":
db_path = os.environ.get("DB_PATH", "/home/claude-dev/osint-data/osint.db")
sys.exit(main(db_path))

Datei anzeigen

@@ -0,0 +1,46 @@
"""Migration 2026-07-25: Tabelle system_status anlegen (Telegram-Session-Status).
Der Monitor legt die Tabelle beim Start selbst an und meldet dort u.a. den
Status seiner Telegram-Session (Key telegram_session). Die
Portal-Staging-Umgebung nutzt eine EIGENE Datenbank ohne laufenden Monitor,
deshalb dieses Skript. Idempotent (CREATE TABLE IF NOT EXISTS), es werden
keine Daten eingefügt.
Ausführung:
DB_PATH=/home/claude-dev/AegisSight-Monitor-staging/data/osint.db python3 migrations/2026-07-25_system_status.py
DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-07-25_system_status.py
"""
import os
import sqlite3
import sys
def main(db_path: str) -> int:
if not os.path.exists(db_path):
print(f"FEHLER: DB nicht gefunden: {db_path}", file=sys.stderr)
return 1
conn = sqlite3.connect(db_path, timeout=60)
conn.execute("PRAGMA busy_timeout = 60000")
conn.execute("PRAGMA journal_mode = WAL")
print(f"Migration auf {db_path}")
conn.execute("""
CREATE TABLE IF NOT EXISTS system_status (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
print(" + system_status angelegt (oder vorhanden)")
conn.commit()
conn.close()
print("Migration abgeschlossen.")
return 0
if __name__ == "__main__":
db_path = os.environ.get("DB_PATH", "/home/claude-dev/osint-data/osint.db")
sys.exit(main(db_path))

Datei anzeigen

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

Datei anzeigen

@@ -9,3 +9,5 @@ httpx>=0.28
feedparser>=6.0
# PDF-Upload-Validierung
pypdf>=5.0
# X-Scraper-Konten-Verwaltung (twscrape-Account-Pool)
twscrape @ git+https://github.com/vladkens/twscrape.git@206f0942fe41149da28530399f7c772ec00be17a

Datei anzeigen

@@ -8,6 +8,10 @@ STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
# Gemeinsame Datenbank (gleiche wie OSINT-Monitor)
DB_PATH = os.environ.get("DB_PATH", "/mnt/gitea/osint-data/osint.db")
# twscrape-Account-Store: die X-Login-Konten, mit denen der Monitor bei X
# recherchiert. Geteilt mit dem Monitor (gleicher Pfad-Default).
X_ACCOUNTS_DB_PATH = os.environ.get("X_ACCOUNTS_DB_PATH", "/home/claude-dev/.x-scraper/accounts.db")
# JWT (eigener Secret fuer Verwaltungsportal)
JWT_SECRET = os.environ.get("PORTAL_JWT_SECRET")
if not JWT_SECRET:

47
src/health_run.py Normale Datei
Datei anzeigen

@@ -0,0 +1,47 @@
"""In-Memory-Status für den manuellen Health-Run des Portals.
Pure Funktionen ohne DB und IO, damit die Zustandsübergänge unit-testbar sind.
Der Status lebt nur im Prozess. Nach einem Neustart ist er wieder idle, ein
dabei abgebrochener Lauf hinterlässt höchstens einen partiellen Stand in
source_health_checks, den der nächste Lauf archiviert und überschreibt
(identisch zum Verhalten des Monitor-Nachtjobs um 04:00 Uhr).
"""
from datetime import datetime
def new_state() -> dict:
return {
"running": False,
"started_at": None,
"finished_at": None,
"result": None,
"error": None,
}
def _now_iso(now=None) -> str:
return now if now is not None else datetime.now().isoformat(timespec="seconds")
def try_start(state: dict, now=None) -> bool:
"""Setzt den Lauf auf running. False, wenn schon einer läuft."""
if state.get("running"):
return False
state["running"] = True
state["started_at"] = _now_iso(now)
state["finished_at"] = None
state["result"] = None
state["error"] = None
return True
def mark_finished(state: dict, result: dict, now=None) -> None:
state["running"] = False
state["finished_at"] = _now_iso(now)
state["result"] = result
def mark_failed(state: dict, error: str, now=None) -> None:
state["running"] = False
state["finished_at"] = _now_iso(now)
state["error"] = error

Datei anzeigen

@@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from config import STATIC_DIR, PORT
from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit, translation
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")
@@ -43,6 +63,9 @@ app.include_router(sources.router)
app.include_router(token_usage.router)
app.include_router(audit.router)
app.include_router(translation.router)
app.include_router(x_scraper.router)
app.include_router(pricing.router)
app.include_router(statistik.router)
# --- Statische Dateien ---
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

Datei anzeigen

@@ -22,16 +22,44 @@ 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)$")
# KI-Weg der Organisation. Leer heisst, es gilt die Servervorgabe.
ai_backend: str = Field(default="", pattern="^(cli|bedrock|)$")
# Optional direkt eine Lizenz mit Credits-Kontingent anlegen
license: Optional[LicenseParams] = None
class OrgUpdate(BaseModel):
name: Optional[str] = Field(default=None, max_length=200)
is_active: Optional[bool] = None
output_language: Optional[str] = Field(default=None, pattern="^(de|en)$")
ai_backend: Optional[str] = Field(default=None, pattern="^(cli|bedrock|)$")
class OrgResponse(BaseModel):
@@ -46,18 +74,11 @@ class OrgResponse(BaseModel):
globe_access: bool = False
network_access: bool = False
output_language: str = "de"
ai_backend: str = ""
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 +96,10 @@ class LicenseResponse(BaseModel):
cost_per_credit: Optional[float] = None
budget_warning_percent: Optional[int] = None
unlimited_budget: bool = False
# Neue Kontingent-Felder, alle optional mit Default, damit Responses auch
# von einer noch nicht migrierten Datenbank validieren.
credits_period: Optional[str] = "monthly"
credits_period_start: Optional[str] = None
created_at: str
globe_access: bool = False
network_access: bool = False

Datei anzeigen

@@ -2,7 +2,7 @@
import json
import logging
from typing import Optional
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from auth import get_current_admin
from database import db_dependency
import aiosqlite
@@ -25,7 +25,7 @@ async def list_audit(
action: Optional[str] = None,
resource_type: Optional[str] = None,
resource_id: Optional[int] = None,
admin_id: Optional[int] = None,
admin_id: Optional[str] = None,
from_ts: Optional[str] = None,
to_ts: Optional[str] = None,
limit: int = 200,
@@ -33,7 +33,11 @@ async def list_audit(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Audit-Log-Eintraege auflisten mit Filter + Pagination."""
"""Audit-Log-Eintraege auflisten mit Filter + Pagination.
action, resource_type und admin_id akzeptieren kommagetrennte Mehrfachwerte
(Checkbox-Filter im Frontend), ein Einzelwert funktioniert weiterhin.
"""
if limit < 1 or limit > 1000:
limit = 200
if offset < 0:
@@ -42,21 +46,34 @@ async def list_audit(
where = []
params = []
if action:
where.append("action = ?")
params.append(action)
vals = [v.strip() for v in action.split(",") if v.strip()]
if vals:
where.append(f"action IN ({','.join('?' for _ in vals)})")
params.extend(vals)
if resource_type:
where.append("resource_type = ?")
params.append(resource_type)
vals = [v.strip() for v in resource_type.split(",") if v.strip()]
if vals:
where.append(f"resource_type IN ({','.join('?' for _ in vals)})")
params.extend(vals)
if resource_id is not None:
where.append("resource_id = ?")
params.append(resource_id)
if admin_id is not None:
where.append("admin_id = ?")
params.append(admin_id)
if admin_id:
try:
ids = [int(v) for v in admin_id.split(",") if v.strip()]
except ValueError:
raise HTTPException(status_code=422, detail="admin_id muss eine Zahl oder kommagetrennte Zahlen sein")
if ids:
where.append(f"admin_id IN ({','.join('?' for _ in ids)})")
params.extend(ids)
if from_ts:
where.append("ts >= ?")
params.append(from_ts)
if to_ts:
# Datums-Only-Wert (aus <input type=date>) soll den gewählten Tag
# komplett EINschließen, sonst fiele er wegen ts <= 'YYYY-MM-DD' raus.
if len(to_ts) == 10:
to_ts = to_ts + " 23:59:59"
where.append("ts <= ?")
params.append(to_ts)

Datei anzeigen

@@ -1,7 +1,7 @@
"""Lizenz-CRUD."""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status, Request
from models import LicenseCreate, LicenseResponse
from models import LicenseCreate, LicenseParams, LicenseResponse
from auth import get_current_admin
from database import db_dependency
from audit import log_action, get_client_ip, row_to_dict
@@ -10,6 +10,88 @@ import aiosqlite
router = APIRouter(prefix="/api/licenses", tags=["licenses"])
async def insert_license_row(
db: aiosqlite.Connection, org_id: int, params: LicenseParams
) -> tuple[int, list[dict]]:
"""Widerruft aktive Lizenzen der Org und legt die neue Zeile an.
Committet bewusst NICHT, die Transaktionsgrenze bestimmt der Aufrufer.
So kann das Org-Anlegen Organisation und Lizenz atomar anlegen.
Gibt (neue Lizenz-ID, Snapshots der widerrufenen Lizenzen) zurueck,
die Audit-Eintraege schreibt audit_license_creation() nach dem Commit.
"""
cursor = await db.execute(
"SELECT * FROM licenses WHERE organization_id = ? AND status = 'active'",
(org_id,),
)
revoked_lics = [dict(r) for r in await cursor.fetchall()]
if revoked_lics:
await db.execute(
"UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'",
(org_id,),
)
now = datetime.now(timezone.utc)
valid_from = now.isoformat()
valid_until = None
if params.license_type == "permanent":
valid_until = None
elif params.duration_days:
valid_until = (now + timedelta(days=params.duration_days)).isoformat()
elif params.license_type == "trial":
valid_until = (now + timedelta(days=14)).isoformat()
elif params.license_type == "annual":
valid_until = (now + timedelta(days=365)).isoformat()
# Bei unlimited_budget: Kontingent-Felder ignorieren
if params.unlimited_budget:
token_budget_usd = None
credits_total = None
cost_per_credit = None
else:
token_budget_usd = params.token_budget_usd
credits_total = params.credits_total
cost_per_credit = params.cost_per_credit
# credits_period_start bleibt bewusst NULL, der Monitor setzt die
# Periodenmarke traege bei der naechsten Lizenzpruefung selbst.
cursor = await db.execute(
"""INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status,
token_budget_usd, credits_total, cost_per_credit, budget_warning_percent, unlimited_budget,
credits_period)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)""",
(org_id, params.license_type, params.max_users, valid_from, valid_until,
token_budget_usd, credits_total, cost_per_credit, params.budget_warning_percent,
1 if params.unlimited_budget else 0,
params.credits_period),
)
return cursor.lastrowid, revoked_lics
async def audit_license_creation(
db: aiosqlite.Connection, admin: dict, ip: str, lic_id: int, revoked_lics: list[dict]
) -> dict:
"""Schreibt die Audit-Eintraege fuers Lizenz-Anlegen (nach dem Commit)."""
for old_lic in revoked_lics:
new_lic = dict(old_lic)
new_lic["status"] = "revoked"
await log_action(
db, admin, ip,
action="update", resource_type="license", resource_id=old_lic["id"],
before=old_lic, after=new_lic,
)
cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (lic_id,))
new_lic = dict(await cursor.fetchone())
await log_action(
db, admin, ip,
action="create", resource_type="license", resource_id=lic_id,
after=new_lic,
)
return new_lic
@router.get("", response_model=list[LicenseResponse])
async def list_licenses(
org_id: int = None,
@@ -39,68 +121,12 @@ async def create_license(
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
# Bestehende aktive Lizenz widerrufen + Snapshot fuer Audit
cursor = await db.execute(
"SELECT * FROM licenses WHERE organization_id = ? AND status = 'active'",
(data.organization_id,),
)
revoked_lics = [dict(r) for r in await cursor.fetchall()]
if revoked_lics:
await db.execute(
"UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'",
(data.organization_id,),
)
for old_lic in revoked_lics:
new_lic = dict(old_lic)
new_lic["status"] = "revoked"
await log_action(
db, admin, get_client_ip(request),
action="update", resource_type="license", resource_id=old_lic["id"],
before=old_lic, after=new_lic,
)
now = datetime.now(timezone.utc)
valid_from = now.isoformat()
valid_until = None
if data.license_type == "permanent":
valid_until = None
elif data.duration_days:
valid_until = (now + timedelta(days=data.duration_days)).isoformat()
elif data.license_type == "trial":
valid_until = (now + timedelta(days=14)).isoformat()
elif data.license_type == "annual":
valid_until = (now + timedelta(days=365)).isoformat()
# Bei unlimited_budget: Credits/Cost/Budget ignorieren
if data.unlimited_budget:
token_budget_usd = None
credits_total = None
cost_per_credit = None
else:
token_budget_usd = data.token_budget_usd
credits_total = data.credits_total
cost_per_credit = data.cost_per_credit
cursor = await db.execute(
"""INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status,
token_budget_usd, credits_total, cost_per_credit, budget_warning_percent, unlimited_budget)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)""",
(data.organization_id, data.license_type, data.max_users, valid_from, valid_until,
token_budget_usd, credits_total, cost_per_credit, data.budget_warning_percent,
1 if data.unlimited_budget else 0),
)
lic_id = cursor.lastrowid
lic_id, revoked_lics = await insert_license_row(db, data.organization_id, data)
await db.commit()
cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (lic_id,))
new_lic = dict(await cursor.fetchone())
await log_action(
db, admin, get_client_ip(request),
action="create", resource_type="license", resource_id=lic_id,
after=new_lic,
return await audit_license_creation(
db, admin, get_client_ip(request), lic_id, revoked_lics
)
return new_lic
@router.put("/{license_id}/revoke")

Datei anzeigen

@@ -34,6 +34,14 @@ async def _enrich_org(db: aiosqlite.Connection, row: aiosqlite.Row) -> dict:
lang_row = await cursor.fetchone()
org["output_language"] = lang_row["value"] if lang_row else "de"
# ai_backend aus organization_settings (leer = Servervorgabe)
cursor = await db.execute(
"SELECT value FROM organization_settings WHERE organization_id = ? AND key = 'ai_backend'",
(org["id"],),
)
ai_row = await cursor.fetchone()
org["ai_backend"] = (ai_row["value"] if ai_row else "") or ""
return org
@@ -64,11 +72,22 @@ 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
from shared.services.org_settings import set_org_setting
await set_org_setting(db, org_id, "output_language", data.output_language)
await set_org_setting(db, org_id, "ai_backend", data.ai_backend or "")
cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,))
new_row_obj = await cursor.fetchone()
@@ -77,6 +96,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)
@@ -123,6 +147,12 @@ async def update_organization(
from shared.services.org_settings import set_org_setting
await set_org_setting(db, org_id, "output_language", data.output_language)
# KI-Weg. Der Leerstring ist ein gueltiger Wert und bedeutet
# "Servervorgabe", deshalb hier die Pruefung auf None statt auf Wahrheit.
if data.ai_backend is not None:
from shared.services.org_settings import set_org_setting
await set_org_setting(db, org_id, "ai_backend", data.ai_backend)
after = await row_to_dict(db, "organizations", org_id)
await log_action(
db, admin, get_client_ip(request),

152
src/routers/pricing.py Normale Datei
Datei anzeigen

@@ -0,0 +1,152 @@
"""Preis-Empfehlung aus den echten API-Kosten der letzten Tage.
Reine Leseabfrage. Die Empfehlung ist eine betreiberweite Kennzahl (Median der
tatsaechlichen Refresh-Kosten je Lagentyp), deshalb wird die geteilte Live-DB
read-only gelesen, unabhaengig vom DB_PATH des jeweiligen Portals.
"""
import logging
import math
import os
import statistics
from collections import defaultdict
from datetime import datetime, timedelta
import aiosqlite
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"])
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)):
"""Median der echten Refresh-Kosten je Lagentyp und daraus abgeleitete
Credit-Empfehlung (Median USD geteilt durch 0,20 USD, aufgerundet)."""
days = max(1, min(365, days))
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
result = {"days": days, "usd_per_credit": USD_PER_CREDIT, "typen": {}}
try:
db = await aiosqlite.connect(f"file:{COST_BASIS_DB}?mode=ro", uri=True)
try:
cursor = await db.execute(
"SELECT i.type, r.total_cost_usd "
"FROM refresh_log r JOIN incidents i ON i.id = r.incident_id "
"WHERE r.status = 'completed' AND r.started_at >= ?",
(cutoff,),
)
rows = await cursor.fetchall()
finally:
await db.close()
except Exception as e:
logger.warning("cost-basis DB-Fehler: %s", e)
result["fehler"] = "Kostendaten nicht verfuegbar"
return result
gruppen = defaultdict(list)
for typ, kosten in rows:
gruppen[typ or "adhoc"].append(kosten or 0.0)
for db_key, label in (("adhoc", "live"), ("research", "research")):
werte = gruppen.get(db_key, [])
if werte:
median = statistics.median(werte)
result["typen"][label] = {
"median_usd": round(median, 2),
"n": len(werte),
"empfohlen": int(math.ceil(median / USD_PER_CREDIT)) if median > 0 else 0,
}
else:
result["typen"][label] = {"median_usd": None, "n": 0, "empfohlen": None}
return result

Datei anzeigen

@@ -7,7 +7,6 @@ import re
import uuid
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Request, UploadFile, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from typing import Optional
import aiosqlite
@@ -16,7 +15,8 @@ from auth import get_current_admin
from database import db_dependency, get_db
from audit import log_action, get_client_ip
from source_meta import get_meta
from config import HEALTH_CHECK_USER_AGENT, HEALTH_CHECK_TIMEOUT_S, DB_PATH
from health_run import new_state, try_start, mark_finished, mark_failed
from config import DB_PATH
from shared.source_rules import (
discover_source,
discover_all_feeds,
@@ -113,12 +113,45 @@ async def get_sources_meta(admin: dict = Depends(get_current_admin)):
return get_meta()
@router.get("/tasks/summary")
async def tasks_summary(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Billiger Zähler für den Aufgaben-Badge im Quellen-Reiter.
classification_pending nutzt DIESELBE Bedingung wie /classification/queue,
damit Badge und Review-Karten nie auseinanderlaufen.
"""
suggestions_pending = 0
cur = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_suggestions'"
)
if await cur.fetchone():
cur = await db.execute(
"SELECT COUNT(*) AS cnt FROM source_suggestions WHERE status = 'pending'"
)
suggestions_pending = (await cur.fetchone())["cnt"]
cur = await db.execute(
"""SELECT COUNT(*) AS cnt FROM sources
WHERE status = 'active' AND proposed_political_orientation IS NOT NULL"""
)
classification_pending = (await cur.fetchone())["cnt"]
return {
"suggestions_pending": suggestions_pending,
"classification_pending": classification_pending,
"total": suggestions_pending + classification_pending,
}
class GlobalSourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
url: Optional[str] = None
domain: Optional[str] = None
source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$")
source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|telegram_channel|podcast_feed|pdf_document|x_account)$")
category: str = Field(default="sonstige")
status: str = Field(default="active", pattern="^(active|inactive)$")
notes: Optional[str] = None
@@ -131,7 +164,7 @@ class GlobalSourceUpdate(BaseModel):
name: Optional[str] = Field(default=None, max_length=200)
url: Optional[str] = None
domain: Optional[str] = None
source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$")
source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|telegram_channel|podcast_feed|pdf_document|x_account)$")
category: Optional[str] = None
status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
notes: Optional[str] = None
@@ -145,18 +178,27 @@ class GlobalSourceUpdate(BaseModel):
alignments: Optional[list[str]] = None
@router.get("/global")
async def list_global_sources(
@router.get("")
async def list_sources(
scope: str = "all",
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Grundquellen auflisten (tenant_id IS NULL).
"""Vereinte Quellenliste (Grund- und Kundenquellen).
Liefert pro Quelle den worst-case Health-Status aus source_health_checks
(error > warning > ok > unknown). Damit kann das Frontend ein Inline-Badge
pro Zeile zeigen, ohne separate Health-Tab-Abfrage.
scope = all | global | tenant. Liefert pro Quelle den worst-case
Health-Status aus source_health_checks (error > warning > ok > unknown)
und den Organisationsnamen bei Kundenquellen (org_name, NULL bei global).
"""
cursor = await db.execute("""
scope_where = {
"all": "1=1",
"global": "s.tenant_id IS NULL",
"tenant": "s.tenant_id IS NOT NULL",
}.get(scope)
if scope_where is None:
raise HTTPException(status_code=422, detail="scope muss all, global oder tenant sein")
cursor = await db.execute(f"""
WITH article_stats AS (
-- Match per source-Name (case-insensitive). source_url im articles ist die
-- Artikel-URL, nicht die Feed-URL - daher matcht das nicht mit sources.url.
@@ -168,14 +210,6 @@ async def list_global_sources(
AND source IS NOT NULL
GROUP BY LOWER(source)
),
excluded_counts AS (
SELECT LOWER(ued.domain) AS dom,
COUNT(DISTINCT u.organization_id) AS cnt
FROM user_excluded_domains ued
JOIN users u ON u.id = ued.user_id
WHERE ued.domain IS NOT NULL
GROUP BY LOWER(ued.domain)
),
health_agg AS (
SELECT source_id,
MAX(CASE WHEN status = 'error' THEN 3
@@ -186,6 +220,7 @@ async def list_global_sources(
GROUP BY source_id
)
SELECT s.*,
o.name AS org_name,
CASE ha.rank
WHEN 3 THEN 'error'
WHEN 2 THEN 'warning'
@@ -193,20 +228,15 @@ async def list_global_sources(
ELSE NULL
END AS health_status,
COALESCE(ast.a7d, 0) AS articles_7d,
COALESCE(ast.a30d, 0) AS articles_30d,
COALESCE(ec.cnt, 0) AS tenant_excluded_count
COALESCE(ast.a30d, 0) AS articles_30d
FROM sources s
LEFT JOIN organizations o ON o.id = s.tenant_id
LEFT JOIN article_stats ast ON ast.s_lower = LOWER(s.name)
LEFT JOIN excluded_counts ec ON ec.dom = LOWER(s.domain)
LEFT JOIN health_agg ha ON ha.source_id = s.id
WHERE s.tenant_id IS NULL
WHERE {scope_where}
ORDER BY s.category, s.source_type, s.name
""")
rows = [dict(row) for row in await cursor.fetchall()]
alignments_map = await _load_alignments_for(db, [r["id"] for r in rows])
for r in rows:
r["alignments"] = alignments_map.get(r["id"], [])
return rows
return [dict(row) for row in await cursor.fetchall()]
@router.post("/global", status_code=201)
@@ -256,13 +286,17 @@ async def update_global_source(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Grundquelle bearbeiten — inkl. Klassifikation + alignments."""
"""Quelle bearbeiten (Grund- und Kundenquellen) — inkl. Klassifikation + alignments.
Kundenquellen sind absichtlich mit erlaubt, damit der Deaktivieren-Knopf
der vereinten Liste (status=inactive) fuer beide Herkuenfte funktioniert.
"""
cursor = await db.execute(
"SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
"SELECT * FROM sources WHERE id = ?", (source_id,)
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
before = dict(row)
before_alignments = sorted((await _load_alignments_for(db, [source_id])).get(source_id, []))
@@ -347,31 +381,35 @@ async def delete_global_source(
@router.get("/global/languages")
async def get_global_languages(
@router.get("/languages")
async def get_languages(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Distinct language-Werte aus Grundquellen - für Frontend-Filter-Dropdown."""
"""Distinct language-Werte aller Quellen - für Frontend-Filter-Dropdown."""
cur = await db.execute("""
SELECT DISTINCT language
FROM sources
WHERE tenant_id IS NULL AND language IS NOT NULL AND language != ''
WHERE language IS NOT NULL AND language != ''
ORDER BY language
""")
return [r["language"] for r in await cur.fetchall()]
@router.get("/global/stats")
async def get_global_stats(
@router.get("/stats")
async def get_sources_stats(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Aggregierte Stats für die Grundquellen-Stats-Bar oben im Tab."""
"""Aggregierte Stats für die Stats-Bar der vereinten Quellenliste.
Zählt Grund- UND Kundenquellen, aktive wie inaktive (die Liste zeigt
ebenfalls alle). by_origin liefert die Aufteilung, last_check den
Zeitpunkt des jüngsten Health-Checks.
"""
cur = await db.execute("""
SELECT source_type, COUNT(*) AS count, COALESCE(SUM(article_count), 0) AS articles
FROM sources
WHERE tenant_id IS NULL AND status = 'active'
GROUP BY source_type
""")
by_type = {}
@@ -383,8 +421,17 @@ async def get_global_stats(
total += d["count"]
total_articles += d["articles"]
# Health-Counter
cur = await db.execute("""
SELECT CASE WHEN tenant_id IS NULL THEN 'global' ELSE 'tenant' END AS origin,
COUNT(*) AS cnt
FROM sources
GROUP BY origin
""")
by_origin = {dict(r)["origin"]: dict(r)["cnt"] for r in await cur.fetchall()}
# Health-Counter + Zeitpunkt des letzten Checks
health = {"errors": 0, "warnings": 0, "ok": 0}
last_check = None
cur = await db.execute("""
SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'
""")
@@ -393,7 +440,6 @@ async def get_global_stats(
SELECT h.status AS hs, COUNT(DISTINCT h.source_id) AS cnt
FROM source_health_checks h
JOIN sources s ON s.id = h.source_id
WHERE s.tenant_id IS NULL AND s.status = 'active'
GROUP BY h.status
""")
for r in await cur.fetchall():
@@ -404,31 +450,21 @@ async def get_global_stats(
health["warnings"] = d["cnt"]
elif d["hs"] == "ok":
health["ok"] = d["cnt"]
cur = await db.execute("SELECT MAX(checked_at) AS lc FROM source_health_checks")
row = await cur.fetchone()
if row:
last_check = dict(row)["lc"]
return {
"by_type": by_type,
"by_origin": by_origin,
"total": total,
"total_articles": total_articles,
"health": health,
"last_check": last_check,
}
@router.get("/tenant")
async def list_tenant_sources(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle tenant-spezifischen Quellen mit Org-Name auflisten."""
cursor = await db.execute("""
SELECT s.*, o.name as org_name
FROM sources s
LEFT JOIN organizations o ON o.id = s.tenant_id
WHERE s.tenant_id IS NOT NULL
ORDER BY o.name, s.category, s.name
""")
return [dict(row) for row in await cursor.fetchall()]
@router.post("/tenant/{source_id}/promote")
async def promote_to_global(
source_id: int,
@@ -678,107 +714,6 @@ async def add_discovered_sources(
# --- Health-Check & Vorschläge ---
@router.get("/health")
async def get_health(
limit: int = 100,
offset: int = 0,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Health-Check-Ergebnisse abrufen.
Default-Limit 100, sortiert nach Status (errors first, dann warnings, dann ok).
Counters (errors/warnings/ok/total_checks) beziehen sich auf den GESAMTEN
Datenbestand, nicht nur auf die zurückgegebene Page. Damit kann das Frontend
den vollen Status anzeigen, ohne alle Zeilen rendern zu müssen.
has_more zeigt an, ob es weitere Items zum Nachladen gibt.
all_orgs liefert die Liste aller Tenants mit Health-Checks (für Filter-Dropdown).
"""
limit = max(1, min(int(limit or 100), 5000))
offset = max(0, int(offset or 0))
# Prüfen ob Tabelle existiert
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'"
)
if not await cursor.fetchone():
return {
"last_check": None, "total_checks": 0,
"errors": 0, "warnings": 0, "ok": 0,
"checks": [], "all_orgs": [],
"limit": limit, "offset": offset, "has_more": False,
}
# Aggregate über GESAMTEN Bestand. Eine GROUP-BY-Query nach (check_type, status)
# liefert sowohl die Top-Counters als auch das feine Breakdown für die UI.
cursor = await db.execute(
"SELECT check_type, status, COUNT(*) AS n FROM source_health_checks GROUP BY check_type, status"
)
breakdown = {} # {check_type: {status: count}}
error_count = 0
warning_count = 0
ok_count = 0
for row in await cursor.fetchall():
ct = row["check_type"]
st = row["status"]
breakdown.setdefault(ct, {})[st] = row["n"]
if st == "error":
error_count += row["n"]
elif st == "warning":
warning_count += row["n"]
elif st == "ok":
ok_count += row["n"]
total_checks = error_count + warning_count + ok_count
# Paginierte Daten
cursor = await db.execute("""
SELECT
h.source_id, s.name, s.domain, s.tenant_id, s.language,
o.name AS org_name,
h.check_type, h.status, h.message
FROM source_health_checks h
JOIN sources s ON s.id = h.source_id
LEFT JOIN organizations o ON o.id = s.tenant_id
ORDER BY
CASE h.status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
s.name
LIMIT ? OFFSET ?
""", (limit, offset))
checks = [dict(row) for row in await cursor.fetchall()]
# Org-Liste (alle Tenants mit Health-Checks, für Frontend-Filter-Dropdown)
cursor = await db.execute("""
SELECT DISTINCT s.tenant_id AS id, o.name AS name
FROM source_health_checks h
JOIN sources s ON s.id = h.source_id
LEFT JOIN organizations o ON o.id = s.tenant_id
WHERE s.tenant_id IS NOT NULL
ORDER BY o.name
""")
all_orgs = [dict(row) for row in await cursor.fetchall()]
cursor = await db.execute("SELECT MAX(checked_at) as last_check FROM source_health_checks")
row = await cursor.fetchone()
last_check = row["last_check"] if row else None
return {
"last_check": last_check,
"total_checks": total_checks,
"errors": error_count,
"warnings": warning_count,
"ok": ok_count,
"breakdown": breakdown,
"checks": checks,
"all_orgs": all_orgs,
"limit": limit,
"offset": offset,
"has_more": (offset + len(checks)) < total_checks,
}
@router.get("/health/history")
async def get_health_history(
limit: int = 20,
@@ -810,12 +745,92 @@ async def get_health_history(
return [dict(row) for row in await cursor.fetchall()]
# Status des manuellen Health-Runs. In-Memory: nach einem Neustart wieder idle,
# ein dabei abgebrochener Lauf hinterlässt höchstens einen partiellen
# source_health_checks-Stand, den der nächste Lauf archiviert und heilt.
_HEALTH_RUN_STATE = new_state()
async def _health_run_background():
"""Manueller Health-Run, exakt nach dem Vorbild des Monitor-Nachtjobs
daily_source_health_check (eigene Connection, Checks + Vorschläge)."""
from shared.services.source_health import run_health_checks
from shared.services.source_suggester import generate_suggestions
db = await get_db()
try:
result = await run_health_checks(db)
suggestions = await generate_suggestions(db)
mark_finished(_HEALTH_RUN_STATE, {**result, "suggestions": suggestions})
logger.info(
"Manueller Health-Run: %s geprüft, %s Probleme, %s neue Vorschläge",
result.get("checked"), result.get("issues"), suggestions,
)
except Exception as e:
logger.error("Manueller Health-Run fehlgeschlagen: %s", e, exc_info=True)
mark_failed(_HEALTH_RUN_STATE, str(e))
finally:
await db.close()
@router.post("/health/run", status_code=202)
async def start_health_run(
background_tasks: BackgroundTasks,
admin: dict = Depends(get_current_admin),
):
"""Health-Check + Vorschläge manuell anstoßen (läuft im Hintergrund weiter)."""
if not try_start(_HEALTH_RUN_STATE):
raise HTTPException(status_code=409, detail="Es läuft bereits eine Prüfung")
background_tasks.add_task(_health_run_background)
return {"status": "started", "started_at": _HEALTH_RUN_STATE["started_at"]}
@router.get("/health/run-status")
async def health_run_status(admin: dict = Depends(get_current_admin)):
"""Aktueller Zustand des manuellen Health-Runs (Polling-Ziel des Frontends)."""
return _HEALTH_RUN_STATE
@router.get("/{source_id}/health")
async def get_source_health(
source_id: int,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Health-Checks einer einzelnen Quelle (für die Ausklapp-Zeile der Liste).
Sortiert error > warning > ok, damit das Wichtigste oben steht.
"""
cursor = await db.execute("SELECT id FROM sources WHERE id = ?", (source_id,))
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
cursor = await db.execute("""
SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'
""")
if not await cursor.fetchone():
return {"source_id": source_id, "checks": []}
cursor = await db.execute("""
SELECT check_type, status, message, checked_at
FROM source_health_checks
WHERE source_id = ?
ORDER BY CASE status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
check_type
""", (source_id,))
return {"source_id": source_id, "checks": [dict(row) for row in await cursor.fetchall()]}
@router.get("/suggestions")
async def get_suggestions(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Vorschläge abrufen (pending zuerst, dann letzte 20 bearbeitete)."""
"""Alle OFFENEN Vorschläge plus die letzten 50 bearbeiteten.
Kein Deckel auf pending, sonst liefe der Aufgaben-Badge
(tasks/summary zählt ungedeckelt) der Tabelle davon.
"""
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_suggestions'"
)
@@ -824,44 +839,36 @@ async def get_suggestions(
cursor = await db.execute("""
SELECT * FROM source_suggestions
ORDER BY
CASE status WHEN 'pending' THEN 0 ELSE 1 END,
created_at DESC
WHERE status = 'pending'
ORDER BY created_at DESC
""")
pending = [dict(row) for row in await cursor.fetchall()]
cursor = await db.execute("""
SELECT * FROM source_suggestions
WHERE status != 'pending'
ORDER BY created_at DESC
LIMIT 50
""")
return [dict(row) for row in await cursor.fetchall()]
return pending + [dict(row) for row in await cursor.fetchall()]
class SuggestionAction(BaseModel):
accept: bool
@router.put("/suggestions/{suggestion_id}")
async def update_suggestion(
suggestion_id: int,
action: SuggestionAction,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Vorschlag annehmen oder ablehnen."""
async def _apply_suggestion_decision(db: aiosqlite.Connection, suggestion: dict, accept: bool):
"""Führt die Annahme/Ablehnung EINES pending-Vorschlags aus (ohne commit/Audit).
Liefert (new_status, result_action). Wird vom Einzel- und vom
Sammel-Endpoint identisch genutzt.
"""
import json as _json
cursor = await db.execute(
"SELECT * FROM source_suggestions WHERE id = ?", (suggestion_id,)
)
suggestion = await cursor.fetchone()
if not suggestion:
raise HTTPException(status_code=404, detail="Vorschlag nicht gefunden")
suggestion = dict(suggestion)
if suggestion["status"] != "pending":
raise HTTPException(status_code=400, detail=f"Vorschlag bereits {suggestion['status']}")
new_status = "accepted" if action.accept else "rejected"
suggestion_id = suggestion["id"]
new_status = "accepted" if accept else "rejected"
result_action = None
if action.accept:
if accept:
stype = suggestion["suggestion_type"]
data = _json.loads(suggestion["suggested_data"]) if suggestion["suggested_data"] else {}
@@ -924,6 +931,75 @@ async def update_suggestion(
"UPDATE source_suggestions SET status = ?, reviewed_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_status, suggestion_id),
)
return new_status, result_action
class BulkSuggestionAction(BaseModel):
suggestion_ids: list[int] = Field(min_length=1, max_length=1000)
accept: bool
@router.put("/suggestions/bulk")
async def bulk_update_suggestions(
action: BulkSuggestionAction,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Mehrere Vorschläge auf einmal annehmen oder ablehnen.
Nicht mehr pendende IDs (schon bearbeitet, per Auto-Reject erledigt
oder unbekannt) werden übersprungen und gezählt, nicht als Fehler gewertet.
"""
accepted = 0
rejected = 0
skipped = 0
for sid in action.suggestion_ids:
cursor = await db.execute("SELECT * FROM source_suggestions WHERE id = ?", (sid,))
row = await cursor.fetchone()
if not row:
skipped += 1
continue
suggestion = dict(row)
if suggestion["status"] != "pending":
skipped += 1
continue
new_status, _ = await _apply_suggestion_decision(db, suggestion, action.accept)
if new_status == "accepted":
accepted += 1
else:
rejected += 1
await db.commit()
await log_action(
db, admin, get_client_ip(request),
action="update", resource_type="source",
before={"suggestion_bulk": len(action.suggestion_ids), "accept": action.accept},
after={"accepted": accepted, "rejected": rejected, "skipped": skipped},
)
return {"accepted": accepted, "rejected": rejected, "skipped": skipped}
@router.put("/suggestions/{suggestion_id}")
async def update_suggestion(
suggestion_id: int,
action: SuggestionAction,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Vorschlag annehmen oder ablehnen."""
cursor = await db.execute(
"SELECT * FROM source_suggestions WHERE id = ?", (suggestion_id,)
)
suggestion = await cursor.fetchone()
if not suggestion:
raise HTTPException(status_code=404, detail="Vorschlag nicht gefunden")
suggestion = dict(suggestion)
if suggestion["status"] != "pending":
raise HTTPException(status_code=400, detail=f"Vorschlag bereits {suggestion['status']}")
new_status, result_action = await _apply_suggestion_decision(db, suggestion, action.accept)
await db.commit()
await log_action(
db, admin, get_client_ip(request),
@@ -936,154 +1012,6 @@ async def update_suggestion(
return {"status": new_status, "action": result_action}
@router.post("/health/run-stream")
async def run_health_check_stream(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Health-Check mit Fortschrittsanzeige (SSE-Stream)."""
import json as _json
import asyncio
# Quellen laden
cursor = await db.execute(
"SELECT id, name, url, domain, source_type, article_count, last_seen_at "
"FROM sources WHERE status = 'active'" # tenant + global
)
sources = [dict(row) for row in await cursor.fetchall()]
sources_with_url = [s for s in sources if s["url"]]
total = len(sources_with_url)
async def generate():
import httpx
import feedparser
# Phase 1: Erreichbarkeit
yield f"data: {_json.dumps({'phase': 'check', 'checked': 0, 'total': total, 'current': ''})}\n\n"
# Bisherigen Stand archivieren, dann frisch
run_id = uuid.uuid4().hex[:12]
await db.execute(
"INSERT INTO source_health_history "
"(run_id, source_id, check_type, status, message, details, checked_at) "
"SELECT ?, source_id, check_type, status, message, details, checked_at "
"FROM source_health_checks",
(run_id,),
)
await db.execute("DELETE FROM source_health_checks")
await db.commit()
issues_found = 0
checked = 0
async with httpx.AsyncClient(
timeout=HEALTH_CHECK_TIMEOUT_S, follow_redirects=True,
headers={"User-Agent": HEALTH_CHECK_USER_AGENT},
) as client:
for source in sources_with_url:
try:
checks = []
try:
resp = await client.get(source["url"])
if resp.status_code >= 400:
checks.append({"type": "reachability", "status": "error",
"message": f"HTTP {resp.status_code} - nicht erreichbar"})
else:
checks.append({"type": "reachability", "status": "ok", "message": "Erreichbar"})
if source["source_type"] in ("rss_feed", "podcast_feed"):
text = resp.text[:20000]
if "<rss" not in text and "<feed" not in text and "<channel" not in text:
checks.append({"type": "feed_validity", "status": "error",
"message": "Kein RSS/Atom-Feed"})
else:
feed = await asyncio.to_thread(feedparser.parse, text)
if feed.get("bozo") and not feed.entries:
checks.append({"type": "feed_validity", "status": "error",
"message": "Feed fehlerhaft"})
elif not feed.entries:
checks.append({"type": "feed_validity", "status": "warning",
"message": "Feed leer"})
else:
checks.append({"type": "feed_validity", "status": "ok",
"message": f"Feed OK ({len(feed.entries)} Eintr.)"})
except httpx.TimeoutException:
checks.append({"type": "reachability", "status": "error", "message": "Timeout (15s)"})
except httpx.ConnectError:
checks.append({"type": "reachability", "status": "error", "message": "Verbindung fehlgeschlagen"})
except Exception as e:
checks.append({"type": "reachability", "status": "error", "message": f"{type(e).__name__}"})
for c in checks:
await db.execute(
"INSERT INTO source_health_checks (source_id, check_type, status, message) VALUES (?, ?, ?, ?)",
(source["id"], c["type"], c["status"], c["message"]))
if c["status"] != "ok":
issues_found += 1
except Exception:
pass
checked += 1
status_icon = "ok"
if any(c["status"] == "error" for c in checks):
status_icon = "error"
elif any(c["status"] == "warning" for c in checks):
status_icon = "warning"
yield f"data: {_json.dumps({'phase': 'check', 'checked': checked, 'total': total, 'current': source['name'], 'status': status_icon})}\n\n"
# Stale + Duplikate (schnell, kein Fortschritt noetig)
for source in sources:
if source["source_type"] in ("excluded", "web_source"):
continue
article_count = source.get("article_count") or 0
if article_count == 0:
await db.execute(
"INSERT INTO source_health_checks (source_id, check_type, status, message) VALUES (?, 'stale', 'warning', 'Noch nie Artikel geliefert')",
(source["id"],))
issues_found += 1
elif source.get("last_seen_at"):
try:
from datetime import datetime
last_dt = datetime.fromisoformat(source["last_seen_at"])
age = (datetime.now() - last_dt).days
if age > 30:
await db.execute(
"INSERT INTO source_health_checks (source_id, check_type, status, message) VALUES (?, 'stale', 'warning', ?)",
(source["id"], f"Letzter Artikel vor {age} Tagen"))
issues_found += 1
except (ValueError, TypeError):
pass
# Duplikate
url_map = {}
for s in sources:
if not s["url"]:
continue
url_norm = s["url"].lower().rstrip("/")
if url_norm in url_map:
existing = url_map[url_norm]
await db.execute(
"INSERT INTO source_health_checks (source_id, check_type, status, message) VALUES (?, 'duplicate', 'warning', ?)",
(s["id"], f"Doppelte URL wie '{existing['name']}' (ID {existing['id']})"))
issues_found += 1
else:
url_map[url_norm] = s
await db.commit()
# Phase 2: Vorschlaege
yield f"data: {_json.dumps({'phase': 'suggestions', 'checked': checked, 'total': total})}\n\n"
from shared.services.source_suggester import generate_suggestions
suggestion_count = await generate_suggestions(db)
# Fertig
yield f"data: {_json.dumps({'phase': 'done', 'checked': checked, 'total': total, 'issues': issues_found, 'suggestions': suggestion_count})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/health/search-fix/{source_id}")
async def search_fix_for_source(
source_id: int,
@@ -1206,31 +1134,6 @@ Nur das JSON, kein anderer Text."""
# === Klassifikations-Review (LLM-Vorschlaege approve/reject/reclassify) ===
@router.get("/classification/stats")
async def classification_stats(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Counts pro classification_source-Wert + Anzahl Pending-Reviews (alle Quellen)."""
cursor = await db.execute(
"""SELECT classification_source, COUNT(*) as cnt
FROM sources
WHERE status = 'active'
GROUP BY classification_source"""
)
by_source = {row["classification_source"] or "legacy": row["cnt"] for row in await cursor.fetchall()}
cursor = await db.execute(
"""SELECT COUNT(*) as cnt FROM sources
WHERE status = 'active' AND proposed_political_orientation IS NOT NULL"""
)
pending = (await cursor.fetchone())["cnt"]
return {
"by_classification_source": by_source,
"pending_review": pending,
"total": sum(by_source.values()),
}
@router.get("/classification/queue")
async def classification_queue(
limit: int = 50,

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

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

Datei anzeigen

@@ -20,6 +20,7 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
o.id, o.name, o.slug,
l.credits_total, l.credits_used, l.cost_per_credit,
l.token_budget_usd, l.budget_warning_percent, l.unlimited_budget,
l.credits_period, l.credits_period_start,
COALESCE(SUM(r.total_cost_usd), 0) as total_cost,
COALESCE(SUM(r.input_tokens), 0) as total_input_tokens,
COALESCE(SUM(r.output_tokens), 0) as total_output_tokens,
@@ -37,8 +38,12 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
credits_total = row["credits_total"] or 0
credits_used = row["credits_used"] or 0
unlimited = bool(row["unlimited_budget"])
credits_remaining = None if unlimited else (max(0, int(credits_total - credits_used)) if credits_total else None)
percent_used = None if unlimited else (round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None)
# Verfuegbar = Kontingent, dieselbe Bezugsgroesse wie der
# Hard-Stop im Monitor. Ungenutzte Credits verfallen zum
# Periodenende (kein Uebertrag, Produktentscheidung 07/2026).
credits_available = credits_total
credits_remaining = None if unlimited else (max(0, int(credits_available - credits_used)) if credits_available else None)
percent_used = None if unlimited else (round((credits_used / credits_available) * 100, 1) if credits_available > 0 else None)
budget_usd = row["token_budget_usd"]
cost = row["total_cost"]
budget_percent = None if unlimited else (round((cost / budget_usd) * 100, 1) if budget_usd and budget_usd > 0 else None)
@@ -51,6 +56,9 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
"credits_used": round(credits_used, 1),
"credits_remaining": credits_remaining,
"credits_percent_used": percent_used,
"credits_available": credits_available,
"credits_period": row["credits_period"] or "monthly",
"credits_period_start": row["credits_period_start"],
"token_budget_usd": budget_usd,
"total_cost_usd": round(cost, 2),
"budget_percent_used": budget_percent,
@@ -122,13 +130,18 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
}
cursor = await db.execute(
"SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent, unlimited_budget FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
"SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent, unlimited_budget, "
"credits_period, credits_period_start "
"FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
(org_id,))
lic = await cursor.fetchone()
unlimited = bool(lic["unlimited_budget"]) if lic else False
credits_total = lic["credits_total"] if lic else None
credits_used = lic["credits_used"] if lic else 0
# Verfuegbar = Kontingent, dieselbe Bezugsgroesse wie der Hard-Stop
# im Monitor (kein Uebertrag, Produktentscheidung 07/2026).
credits_available = credits_total or 0
return {
"year_month": year_month,
@@ -144,8 +157,11 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
"unlimited_budget": unlimited,
"credits_total": credits_total,
"credits_used": round(credits_used, 1) if credits_used else 0,
"credits_remaining": None if unlimited else (max(0, int(credits_total - credits_used)) if credits_total else None),
"credits_percent_used": None if unlimited else (round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None),
"credits_remaining": None if unlimited else (max(0, int(credits_available - credits_used)) if credits_available else None),
"credits_percent_used": None if unlimited else (round((credits_used / credits_available) * 100, 1) if credits_available > 0 else None),
"credits_available": credits_available,
"credits_period": (lic["credits_period"] or "monthly") if lic else "monthly",
"credits_period_start": lic["credits_period_start"] if lic else None,
"token_budget_usd": lic["token_budget_usd"] if lic else None,
"cost_per_credit": lic["cost_per_credit"] if lic else None,
"budget_warning_percent": lic["budget_warning_percent"] if lic else 80,
@@ -164,9 +180,31 @@ async def update_budget(license_id: int, data: dict, request: Request, admin=Dep
if not before:
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
# Vorab-Validierung, der Endpoint nimmt ein rohes dict entgegen.
# Ein Tippfehler wie 'weekly' wuerde sonst still in der DB landen und
# der Monitor behandelte die Lizenz wie 'total' (kein Monatsreset).
if "credits_period" in data and data["credits_period"] not in ("monthly", "total"):
raise HTTPException(status_code=400, detail="credits_period muss 'monthly' oder 'total' sein")
if "budget_warning_percent" in data:
try:
pct = int(data["budget_warning_percent"])
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="budget_warning_percent muss eine Zahl sein")
if not 0 <= pct <= 100:
raise HTTPException(status_code=400, detail="budget_warning_percent muss zwischen 0 und 100 liegen (0 = Warnung aus)")
data["budget_warning_percent"] = pct
if "max_users" in data:
try:
mu = int(data["max_users"])
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="max_users muss eine Zahl sein")
if not 1 <= mu <= 1000:
raise HTTPException(status_code=400, detail="max_users muss zwischen 1 und 1000 liegen")
data["max_users"] = mu
fields = []
values = []
for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent"):
for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent", "credits_period", "max_users"):
if key in data:
fields.append(f"{key} = ?")
values.append(data[key])
@@ -179,6 +217,9 @@ async def update_budget(license_id: int, data: dict, request: Request, admin=Dep
fields.append("unlimited_budget = ?")
values.append(1 if data["unlimited_budget"] else 0)
# Bewusst NICHT aenderbar: credits_period_start und budget_warning_sent
# sind Laufzeitfelder, die verwaltet der Monitor.
if not fields:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")

256
src/routers/x_scraper.py Normale Datei
Datei anzeigen

@@ -0,0 +1,256 @@
"""X-Scraper-Konten: Verwaltung des twscrape-Account-Pools.
Das sind die X-Login-Konten, mit denen der Monitor bei X recherchiert
(scrapen). Sie liegen im twscrape-Account-Store (config.X_ACCOUNTS_DB_PATH),
nicht in der Verwaltungs-Datenbank. twscrape wird lazy importiert, damit das
Portal auch ohne installiertes twscrape startet.
"""
import logging
import os
from datetime import datetime, timezone
from typing import Optional
import aiosqlite
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from auth import get_current_admin
from audit import log_action, get_client_ip
from config import X_ACCOUNTS_DB_PATH
from database import db_dependency
logger = logging.getLogger("verwaltung.x_scraper")
router = APIRouter(prefix="/api/x-scraper", tags=["x-scraper"])
def _get_pool():
"""twscrape-AccountsPool oeffnen. Wirft HTTPException wenn nicht verfuegbar."""
try:
os.makedirs(os.path.dirname(X_ACCOUNTS_DB_PATH), exist_ok=True)
except Exception:
pass
try:
from twscrape import API
except ImportError:
raise HTTPException(status_code=503, detail="twscrape ist nicht installiert")
return API(X_ACCOUNTS_DB_PATH).pool
def _summary(acc) -> dict:
"""Account-Objekt auf ein anzeigbares Dict reduzieren -- ohne Geheimnisse."""
now = datetime.now(timezone.utc)
locked = False
locked_until = None
for ts in (acc.locks or {}).values():
if ts and ts > now:
locked = True
if locked_until is None or ts > locked_until:
locked_until = ts
return {
"username": acc.username,
"email": acc.email if acc.email and acc.email != "_" else None,
"active": bool(acc.active),
"locked": locked,
"locked_until": locked_until.isoformat() if locked_until else None,
"has_cookies": bool(acc.cookies),
"total_requests": sum((acc.stats or {}).values()),
"last_used": acc.last_used.isoformat() if acc.last_used else None,
"error_msg": acc.error_msg or None,
}
class XScraperCreate(BaseModel):
username: str = Field(min_length=1, max_length=100)
password: str = Field(default="", max_length=200)
email: str = Field(default="", max_length=200)
email_password: str = Field(default="", max_length=200)
cookies: str = Field(min_length=1, max_length=4000)
class XScraperCookies(BaseModel):
cookies: str = Field(min_length=1, max_length=4000)
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)."""
pool = _get_pool()
try:
accounts = await pool.get_all()
except Exception as e:
logger.error("X-Scraper get_all fehlgeschlagen: %s", e)
raise HTTPException(status_code=500, detail="Konten konnten nicht geladen werden")
return [_summary(a) for a in accounts]
@router.post("/accounts", status_code=201)
async def add_account(
data: XScraperCreate,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Neues X-Scraper-Konto anlegen."""
pool = _get_pool()
username = data.username.strip().lstrip("@")
if not username:
raise HTTPException(status_code=422, detail="Benutzername ist erforderlich")
if await pool.get_account(username) is not None:
raise HTTPException(status_code=409, detail=f"Konto '{username}' existiert bereits")
try:
await pool.add_account(
username=username,
password=data.password or "_",
email=data.email or "_",
email_password=data.email_password or "_",
cookies=data.cookies.strip(),
)
except Exception as e:
logger.error("X-Scraper add_account fehlgeschlagen: %s", e)
raise HTTPException(status_code=500, detail="Konto konnte nicht angelegt werden")
acc = await pool.get_account(username)
if acc is None:
raise HTTPException(status_code=500, detail="Konto wurde nicht gespeichert, bitte Cookies pruefen")
await log_action(
db, admin, get_client_ip(request), action="create",
resource_type="x_scraper_account", after={"username": username, "email": data.email},
)
return _summary(acc)
@router.post("/accounts/{username}/cookies")
async def refresh_cookies(
username: str,
data: XScraperCookies,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Cookies eines bestehenden Kontos erneuern (Login auffrischen)."""
pool = _get_pool()
acc = await pool.get_account(username)
if acc is None:
raise HTTPException(status_code=404, detail="Konto nicht gefunden")
# twscrape hat keine Update-Methode -- Konto mit frischen Cookies neu anlegen.
pw, em, emp = acc.password, acc.email, acc.email_password
try:
await pool.delete_accounts([username])
await pool.add_account(
username=username, password=pw, email=em,
email_password=emp, cookies=data.cookies.strip(),
)
except Exception as e:
logger.error("X-Scraper Cookie-Refresh fehlgeschlagen: %s", e)
raise HTTPException(status_code=500, detail="Cookies konnten nicht erneuert werden")
acc = await pool.get_account(username)
if acc is None:
raise HTTPException(status_code=500, detail="Konto nach Cookie-Refresh nicht gefunden")
await log_action(
db, admin, get_client_ip(request), action="update",
resource_type="x_scraper_account", after={"username": username, "change": "cookies"},
)
return _summary(acc)
@router.post("/accounts/{username}/active")
async def set_active(
username: str,
data: XScraperActive,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Konto aktiv oder inaktiv schalten."""
pool = _get_pool()
if await pool.get_account(username) is None:
raise HTTPException(status_code=404, detail="Konto nicht gefunden")
try:
await pool.set_active(username, data.active)
except Exception as e:
logger.error("X-Scraper set_active fehlgeschlagen: %s", e)
raise HTTPException(status_code=500, detail="Status konnte nicht geaendert werden")
await log_action(
db, admin, get_client_ip(request), action="update",
resource_type="x_scraper_account", after={"username": username, "active": data.active},
)
acc = await pool.get_account(username)
return _summary(acc)
@router.delete("/accounts/{username}", status_code=204)
async def delete_account(
username: str,
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""X-Scraper-Konto entfernen."""
pool = _get_pool()
if await pool.get_account(username) is None:
raise HTTPException(status_code=404, detail="Konto nicht gefunden")
try:
await pool.delete_accounts([username])
except Exception as e:
logger.error("X-Scraper delete fehlgeschlagen: %s", e)
raise HTTPException(status_code=500, detail="Konto konnte nicht entfernt werden")
await log_action(
db, admin, get_client_ip(request), action="delete",
resource_type="x_scraper_account", before={"username": username},
)
@router.post("/reset-locks")
async def reset_locks(
request: Request,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle temporaeren Sperren der Konten zuruecksetzen."""
pool = _get_pool()
try:
await pool.reset_locks()
except Exception as e:
logger.error("X-Scraper reset_locks fehlgeschlagen: %s", e)
raise HTTPException(status_code=500, detail="Sperren konnten nicht zurueckgesetzt werden")
await log_action(
db, admin, get_client_ip(request), action="update",
resource_type="x_scraper_account", after={"change": "reset_locks"},
)
return {"status": "ok"}

Datei anzeigen

@@ -5,7 +5,7 @@ import re
import aiosqlite
from agents.claude_client import call_claude
from shared.agents.claude_client import call_claude
from config import CLAUDE_MODEL_FAST
logger = logging.getLogger("osint.source_suggester")

Datei anzeigen

@@ -27,17 +27,9 @@ SOURCE_CATEGORIES: list[CategoryEntry] = [
{"key": "international", "label": "International"},
{"key": "regional", "label": "Regional"},
{"key": "boulevard", "label": "Boulevard"},
{"key": "stimmungsbild", "label": "Forum / Stimmungsbild"},
{"key": "telegram", "label": "Telegram"},
{"key": "x", "label": "X (Twitter)"},
{"key": "sonstige", "label": "Sonstige"},
{"key": "cybercrime", "label": "Cybercrime / Hacktivismus"},
{"key": "cybercrime-leaks", "label": "Cybercrime / Leaks"},
{"key": "ukraine-russland-krieg", "label": "Ukraine-Russland-Krieg"},
{"key": "irankonflikt", "label": "Irankonflikt"},
{"key": "osint-international", "label": "OSINT International"},
{"key": "extremismus-deutschland", "label": "Extremismus Deutschland"},
{"key": "russische-staatspropaganda", "label": "Russische Staatspropaganda"},
{"key": "russische-opposition", "label": "Russische Opposition / Exilmedien"},
{"key": "syrien-nahost", "label": "Syrien / Nahost"},
]
@@ -46,7 +38,8 @@ SOURCE_TYPES: list[TypeEntry] = [
{"key": "web_source", "label": "Webquelle"},
{"key": "telegram_channel", "label": "Telegram-Kanal"},
{"key": "podcast_feed", "label": "Podcast-Feed"},
{"key": "excluded", "label": "Ausgeschlossen"},
{"key": "x_account", "label": "X-Konto"},
{"key": "pdf_document", "label": "PDF-Dokument"},
]

Datei anzeigen

@@ -1,32 +1,63 @@
/* AegisSight Dark Theme - Verwaltungsportal */
/* AegisSight Design System - Verwaltungsportal (Navy/Gold, Dark = Standard, Light per Umschalter) */
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent: #f0b429;
--accent-hover: #d4a017;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
--border: #334155;
--radius: 8px;
--radius-lg: 12px;
--bg-primary: #0B1121;
--bg-secondary: #151D2E;
--bg-tertiary: #1A2440;
--bg-hover: #1A2440;
--text-primary: #E8ECF4;
--text-secondary: #8896AB;
--text-muted: #95A3B8;
--accent: #C8A851;
--accent-hover: #B5923E;
--success: #10B981;
--warning: #F59E0B;
--danger: #EF4444;
--info: #7C8DB5;
--border: #1E2D45;
--radius: 6px;
--radius-lg: 8px;
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.5);
--font-title: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
/* Das Navy-Logo liegt im Dark-Theme auf einer hellen Plakette */
--logo-plaque: rgba(232, 236, 240, 0.9);
}
/* Light-Theme. Akzent wie im Monitor auf das dunklere Gold abgesenkt,
#C8A851 schafft auf hellem Grund den WCAG-Kontrast nicht. */
[data-theme="light"] {
--bg-primary: #F4F5F7;
--bg-secondary: #FFFFFF;
--bg-tertiary: #E8EBF0;
--bg-hover: #E8EBF0;
--text-primary: #1A202C;
--text-secondary: #4A5568;
--text-muted: #A0AEC0;
--accent: #96791A;
--accent-hover: #7D6516;
--success: #059669;
--warning: #D97706;
--danger: #DC2626;
--info: #4A5568;
--border: #E2E8F0;
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
--logo-plaque: transparent;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, sans-serif;
font-family: var(--font-body);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
h1, h2, h3, .logo, .stat-value, .token-stat-value {
font-family: var(--font-title);
}
/* --- Login Page --- */
.login-page {
display: flex;
@@ -143,7 +174,7 @@ input:focus, select:focus, textarea:focus {
color: var(--text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: #475569;
background: var(--border);
}
.btn-danger {
@@ -189,6 +220,9 @@ input:focus, select:focus, textarea:focus {
}
.app-header .logo {
display: flex;
align-items: center;
gap: 10px;
color: var(--accent);
font-size: 18px;
font-weight: 700;
@@ -198,9 +232,19 @@ input:focus, select:focus, textarea:focus {
color: var(--text-secondary);
font-weight: 400;
font-size: 13px;
margin-left: 8px;
margin-left: 2px;
}
/* Navy-Logo bleibt auch im Dark-Theme, liegt dort auf heller Plakette */
.logo-plaque {
display: flex;
align-items: center;
background: var(--logo-plaque);
border-radius: 8px;
padding: 3px 6px;
}
.logo-plaque img { height: 24px; display: block; }
.header-right {
display: flex;
align-items: center;
@@ -213,7 +257,8 @@ input:focus, select:focus, textarea:focus {
}
.app-content {
max-width: 1200px;
/* Breite Bildschirme ausnutzen. Ein groesseres Fenster zeigt mehr Tabelle. */
max-width: 1720px;
margin: 0 auto;
padding: 24px;
}
@@ -237,7 +282,11 @@ input:focus, select:focus, textarea:focus {
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 7px;
}
.nav-tab svg { flex-shrink: 0; }
.nav-tab:hover {
color: var(--text-primary);
@@ -538,36 +587,6 @@ tr:hover td {
font-weight: 500;
}
/* --- Recent activity --- */
.activity-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
font-size: 13px;
}
.activity-icon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex-shrink: 0;
}
.activity-icon.org {
background: rgba(59, 130, 246, 0.2);
color: #93c5fd;
}
.activity-icon.user {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
}
/* --- Org detail panel --- */
.detail-panel {
display: none;
@@ -654,21 +673,6 @@ tr:hover td {
/* --- Health & Suggestion Badges --- */
.badge-health-error {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.badge-health-warning {
background: rgba(245, 158, 11, 0.2);
color: #fcd34d;
}
.badge-health-ok {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
}
.badge-suggestion-add_source {
background: rgba(59, 130, 246, 0.2);
color: #93c5fd;
@@ -872,6 +876,193 @@ input[type="date"].filter-select { padding: 6px 10px; }
.health-badge-ok { background: rgba(16, 185, 129, 0.15); color: #10b981; }
.health-badge-unknown { background: rgba(148, 163, 184, 0.15); color: #94a3b8; }
/* Klickbares Health-Badge oeffnet die Detailzeile */
.health-badge-btn { cursor: pointer; }
.health-badge-btn:hover { filter: brightness(1.25); text-decoration: underline; }
/* "Jetzt pruefen"-Knopf in der Stats-Leiste */
.health-run-item { display: inline-flex; align-items: center; gap: 8px; }
.health-run-item #healthRunInfo { font-size: 12px; }
/* Checkbox-Dropdowns (Standard fuer alle Filter im Portal) */
.filter-bar {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.filter-dd { position: relative; }
.filter-dd-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
}
.filter-dd-btn:hover { background: var(--bg-tertiary); }
.filter-dd-btn.has-selection { border-color: var(--accent); }
.filter-dd-count {
background: var(--accent);
color: #1A1A1A; /* Text auf Gold immer dunkel (ON_ACCENT) */
border-radius: 999px;
font-size: 11px;
font-weight: 700;
padding: 0 6px;
line-height: 16px;
}
.filter-dd-caret { font-size: 10px; color: var(--text-secondary); }
.filter-dd-panel {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 60;
min-width: 210px;
max-height: 320px;
overflow-y: auto;
padding: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
flex-direction: column;
gap: 2px;
}
.filter-dd.open .filter-dd-panel { display: flex; }
[data-theme="light"] .filter-dd-panel { box-shadow: 0 8px 24px rgba(26, 26, 26, 0.18); }
.filter-check {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
user-select: none;
white-space: nowrap;
}
.filter-check:hover { background: var(--bg-tertiary); }
.filter-check input {
accent-color: var(--accent);
cursor: pointer;
margin: 0;
flex-shrink: 0;
}
/* Checkbox-Spalte: nur sichtbar bei Herkunfts-Filter "Kundenquellen" */
.select-col { display: none; }
table.show-select .select-col { display: table-cell; }
/* === Quellenliste: leichteres Seitwaerts-Scrollen === */
/* Name links und Aktionen rechts bleiben beim Scrollen angepinnt.
Zeilen sind transparent, angepinnte Zellen brauchen deshalb solide
Hintergruende, sonst scheint der Inhalt darunter durch. */
#unifiedTable th.sticky-name,
#unifiedTable td.sticky-name {
position: sticky;
left: 0;
z-index: 2;
background: var(--bg-secondary);
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: 6px 0 8px -6px rgba(0, 0, 0, 0.45);
}
#unifiedTable th.sticky-actions,
#unifiedTable td.sticky-actions {
position: sticky;
right: 0;
z-index: 2;
background: var(--bg-secondary);
white-space: nowrap;
box-shadow: -6px 0 8px -6px rgba(0, 0, 0, 0.45);
}
#unifiedTable.show-select th.select-col,
#unifiedTable.show-select td.select-col {
position: sticky;
left: 0;
z-index: 2;
width: 40px;
background: var(--bg-secondary);
}
#unifiedTable.show-select th.sticky-name,
#unifiedTable.show-select td.sticky-name { left: 40px; }
#unifiedTable tr:hover td.sticky-name,
#unifiedTable tr:hover td.sticky-actions,
#unifiedTable.show-select tr:hover td.select-col {
background: var(--bg-tertiary);
}
/* Kategorie-Zeile und Ausklapp-Inhalte bleiben im sichtbaren Bereich */
.cat-header-sticky {
position: sticky;
left: 12px;
display: inline-flex;
align-items: center;
}
#unifiedTable .src-notes-cell > div {
position: sticky;
left: 16px;
max-width: 1080px;
}
/* Ziehen-zum-Scrollen */
.drag-scroll { cursor: grab; }
.drag-scroll.drag-scrolling {
cursor: grabbing;
user-select: none;
}
/* Schwebende Scroll-Leiste: bleibt am unteren Bildschirmrand sichtbar,
solange die Tabelle im Bild ist (Proxy, synchron zum echten Container) */
.hscroll-proxy {
position: sticky;
bottom: 0;
overflow-x: auto;
overflow-y: hidden;
height: 14px;
z-index: 5;
background: var(--bg-secondary);
}
.hscroll-proxy > div { height: 1px; }
.hscroll-proxy::-webkit-scrollbar,
.table-wrap::-webkit-scrollbar { height: 11px; }
.hscroll-proxy::-webkit-scrollbar-thumb,
.table-wrap::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 6px;
border: 2px solid var(--bg-secondary);
}
.hscroll-proxy::-webkit-scrollbar-thumb:hover,
.table-wrap::-webkit-scrollbar-thumb:hover { background: var(--accent); }
.hscroll-proxy::-webkit-scrollbar-track,
.table-wrap::-webkit-scrollbar-track { background: var(--bg-secondary); }
/* Health-Details in der Ausklapp-Zeile */
.src-notes-text { margin-bottom: 8px; }
.health-detail-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 4px 0 8px;
}
.health-detail-row {
display: flex;
align-items: baseline;
gap: 12px;
padding: 3px 0;
font-size: 13px;
}
.health-detail-type { min-width: 120px; font-weight: 600; }
.health-detail-msg { flex: 1; }
.health-detail-ts { white-space: nowrap; font-size: 12px; }
/* === Audit-Spur (Phase 5) === */
.modal.modal-large {
max-width: 720px;
@@ -947,20 +1138,6 @@ input[type="date"].filter-select { padding: 6px 10px; }
.activity-cell.activity-zero {
color: #475569;
}
.exclude-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.exclude-badge.exclude-zero {
background: transparent;
color: #475569;
font-weight: 400;
}
/* === Klassifikations-Review === */
.sources-tab-badge {
@@ -996,13 +1173,6 @@ input[type="date"].filter-select { padding: 6px 10px; }
font-size: 13px;
color: var(--text-primary);
}
.review-conf-filter {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.review-toolbar-actions { display: flex; gap: 6px; }
.review-list { display: flex; flex-direction: column; gap: 8px; }
@@ -1100,24 +1270,303 @@ input[type="date"].filter-select { padding: 6px 10px; }
letter-spacing: 0.3px;
text-transform: uppercase;
}
.alignment-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.alignment-chip {
display: inline-flex;
/* === Theme-Umschalter (goldener Schiebeschalter, wie im Monitor) === */
.theme-switch {
display: flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
gap: 6px;
cursor: pointer;
transition: all 0.12s ease;
user-select: none;
-webkit-user-select: none;
}
.alignment-chip:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.alignment-chip.active {
.theme-switch-icon {
font-size: 14px;
line-height: 1;
opacity: 0.4;
transition: opacity 0.3s;
}
.theme-switch.dark .theme-switch-moon,
.theme-switch.light .theme-switch-sun {
opacity: 1;
}
.theme-switch-track {
position: relative;
width: 40px;
height: 22px;
border-radius: 11px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
transition: background 0.3s, border-color 0.3s;
flex-shrink: 0;
}
.theme-switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
color: var(--bg-primary);
border-color: var(--accent);
box-shadow: 0 0 8px rgba(200, 168, 81, 0.3);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s;
}
.theme-switch.dark .theme-switch-knob { transform: translateX(18px); }
.theme-switch.light .theme-switch-knob { transform: translateX(0); }
.theme-switch:hover .theme-switch-track { border-color: var(--accent); }
.theme-switch:hover .theme-switch-knob { box-shadow: 0 0 12px rgba(200, 168, 81, 0.5); }
/* === Kleine Schalter-Optik (fuer das Barrierefreiheits-Panel) === */
.toggle-switch {
position: relative;
display: inline-block;
width: 34px;
height: 18px;
border-radius: 9px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
transition: background 0.2s ease;
}
.toggle-switch::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-secondary);
transition: transform 0.2s ease, background 0.2s ease;
}
/* === Barrierefreiheits-Panel (a11y.js haengt sich in .header-right ein) === */
.a11y-center { position: relative; }
.a11y-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 6px 8px;
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease, background 0.2s ease;
width: 36px;
height: 36px;
}
.a11y-btn:hover { color: var(--accent); background: var(--bg-hover); }
.a11y-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 240px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
z-index: 200;
padding: 16px;
}
.a11y-panel-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.a11y-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
cursor: pointer;
font-size: 13px;
color: var(--text-primary);
user-select: none;
}
.a11y-option input[type="checkbox"] {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.a11y-option .toggle-switch { flex-shrink: 0; }
.a11y-option input:focus-visible + .toggle-switch {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.a11y-option input:checked + .toggle-switch { background: var(--accent); }
.a11y-option input:checked + .toggle-switch::after {
transform: translateX(16px);
background: var(--bg-primary);
}
/* === Barrierefreiheits-Modi === */
[data-a11y-contrast="true"] {
--text-muted: #B0BDD0;
--border: #3A4A66;
}
[data-a11y-contrast="true"] .btn-primary { color: #1A1A1A; }
[data-a11y-contrast="true"][data-theme="light"] {
--accent: #6B5714;
--accent-hover: #5A4A11;
--text-muted: #718096;
--border: #94A3B8;
}
[data-a11y-focus="true"] a:focus-visible,
[data-a11y-focus="true"] button:focus-visible,
[data-a11y-focus="true"] input:focus-visible,
[data-a11y-focus="true"] select:focus-visible,
[data-a11y-focus="true"] textarea:focus-visible,
[data-a11y-focus="true"] [tabindex]:focus-visible,
[data-a11y-focus="true"] [role="button"]:focus-visible {
outline: 3px solid var(--accent) !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 4px rgba(200, 168, 81, 0.3) !important;
}
[data-a11y-fontsize="true"] body { zoom: 1.15; }
[data-a11y-motion="true"] *,
[data-a11y-motion="true"] *::before,
[data-a11y-motion="true"] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/* === Icon-Ausrichtung in Knoepfen === */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; }
.btn svg { flex-shrink: 0; }
/* === Aufklappbarer Bereich (z.B. "Erweitert" im Verbrauchs-Formular) === */
details.adv-section { grid-column: 1 / -1; border-top: 1px solid var(--border); padding-top: 10px; }
details.adv-section summary {
cursor: pointer;
color: var(--text-secondary);
font-size: 13px;
user-select: none;
display: flex;
align-items: center;
gap: 6px;
}
details.adv-section summary:hover { color: var(--text-primary); }
details.adv-section[open] summary { margin-bottom: 10px; }
details.adv-section .adv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
details.adv-section .adv-hint { grid-column: 1 / -1; font-size: 12px; color: var(--text-muted); margin: 0; }
/* === Light-Theme: dunklere Text-Farben fuer Badges/Status ===
(Die Dark-Pastelltoene sind auf hellem Grund unlesbar. Automatisch
abgeleitete Gegenstuecke, je Original-Farbton eine dunkle Variante.) */
[data-theme="light"] .error-msg { color: #B91C1C; }
[data-theme="light"] .btn-danger { color: #B91C1C; }
[data-theme="light"] .btn-success { color: #047857; }
[data-theme="light"] .badge-active { color: #047857; }
[data-theme="light"] .badge-inactive { color: #B91C1C; }
[data-theme="light"] .badge-trial { color: #1D4ED8; }
[data-theme="light"] .badge-annual { color: #7E22CE; }
[data-theme="light"] .badge-permanent { color: #B45309; }
[data-theme="light"] .badge-expired { color: #B91C1C; }
[data-theme="light"] .badge-revoked { color: #475569; }
[data-theme="light"] .badge-none { color: #475569; }
[data-theme="light"] .badge-suggestion-add_source { color: #1D4ED8; }
[data-theme="light"] .badge-suggestion-deactivate_source { color: #B45309; }
[data-theme="light"] .badge-suggestion-remove_source { color: #B91C1C; }
[data-theme="light"] .badge-suggestion-fix_url { color: #7E22CE; }
[data-theme="light"] .badge-priority-high { color: #B91C1C; }
[data-theme="light"] .badge-priority-medium { color: #B45309; }
[data-theme="light"] .badge-priority-low { color: #475569; }
[data-theme="light"] .sources-stat-item { color: #475569; }
[data-theme="light"] .health-badge-unknown { color: #475569; }
[data-theme="light"] .audit-entry-meta { color: #475569; }
[data-theme="light"] .audit-entry-detail summary { color: #475569; }
[data-theme="light"] /* === Verwendungs-Sicht (Phase 6) === */
.activity-cell { color: #475569; }
/* === Verlinkte Dashboard-Kacheln und -Karten === */
.stat-card-link { cursor: pointer; transition: border-color 0.15s ease, transform 0.15s ease; }
.stat-card-link:hover, .stat-card-link:focus-visible {
border-color: var(--accent);
transform: translateY(-1px);
}
.card-link { cursor: pointer; transition: border-color 0.15s ease; }
.card-link:hover, .card-link:focus-visible { border-color: var(--accent); }
.card-link .card-link-chevron { color: var(--text-muted); transition: color 0.15s ease, transform 0.15s ease; }
.card-link:hover .card-link-chevron { color: var(--accent); transform: translateX(2px); }
/* === Org-Detail als durchgehende Seite (statt Unterreitern) === */
.detail-panel.active { display: flex; flex-direction: column; }
.org-block { margin-top: 26px; scroll-margin-top: 120px; }
#sub-tokens { order: 2; }
.org-block-title {
font-size: 16px;
margin-bottom: 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
}
/* === Kontingent-Spalte in der Org-Tabelle === */
.org-row { cursor: pointer; }
.mini-bar {
width: 90px;
height: 5px;
border-radius: 3px;
background: var(--bg-tertiary);
overflow: hidden;
margin-top: 3px;
}
.mini-bar-fill { height: 100%; background: var(--success); border-radius: 3px; }
.mini-bar-fill.warning { background: var(--warning); }
.mini-bar-fill.critical { background: var(--danger); }
/* === Klickbare Eintraege in der Ablauf-Liste === */
.expiring-item-link { cursor: pointer; border-radius: var(--radius); transition: background 0.15s ease; }
.expiring-item-link:hover, .expiring-item-link:focus-visible { background: var(--bg-hover); }
/* === Prominenter Org-Status-Umschalter in der Kopfzeile === */
.org-status-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.ost-btn {
padding: 8px 18px;
font-size: 14px;
font-weight: 600;
font-family: var(--font-body);
background: var(--bg-secondary);
color: var(--text-muted);
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 7px;
transition: background 0.15s ease, color 0.15s ease;
}
.ost-btn + .ost-btn { border-left: 1px solid var(--border); }
.ost-btn:hover { color: var(--text-primary); background: var(--bg-hover); }
.ost-btn.on.ost-aktiv {
background: rgba(16, 185, 129, 0.16);
color: var(--success);
}
.ost-btn.on.ost-inaktiv {
background: rgba(239, 68, 68, 0.16);
color: var(--danger);
}
/* === Verbrauchs-Korrektur direkt an der Kachel === */
.stat-korrektur { display: flex; gap: 6px; margin-top: 10px; }
.stat-korrektur input { font-size: 12px; padding: 6px 8px; }
/* === Zahlenfelder ohne Hochzaehl-Pfeile (portalweit) === */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
input[type="number"] { -moz-appearance: textfield; appearance: textfield; }

Datei anzeigen

@@ -6,42 +6,69 @@
<title>AegisSight Monitor-Verwaltung</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="apple-touch-icon" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css?v=20260509d">
<link rel="stylesheet" href="/static/css/style.css?v=20260725o">
<script>(function(){var t=localStorage.getItem('portal_theme');if(t==='light')document.documentElement.setAttribute('data-theme','light');try{var a=JSON.parse(localStorage.getItem('osint_a11y')||'{}');Object.keys(a).forEach(function(k){if(a[k])document.documentElement.setAttribute('data-a11y-'+k,'true');});}catch(e){}})()</script>
<style>
.source-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:12px; font-weight:600; }
.badge-monitor { background:#e3f2fd; color:#1565c0; }
.badge-globe { background:#e8f5e9; color:#2e7d32; }
.badge-monitor { background:rgba(59,130,246,0.15); color:#93c5fd; }
.badge-globe { background:rgba(16,185,129,0.15); color:#86efac; }
[data-theme="light"] .badge-monitor { background:#e3f2fd; color:#1565c0; }
[data-theme="light"] .badge-globe { background:#e8f5e9; color:#2e7d32; }
</style>
</head>
<body>
<!-- Header -->
<header class="app-header">
<div class="logo">AegisSight <span>Monitor-Verwaltung</span></div>
<div class="logo">
<span class="logo-plaque"><img src="/static/favicon.svg" alt="AegisSight Logo"></span>
AegisSight <span>Monitor-Verwaltung</span>
</div>
<div class="header-right">
<div class="theme-switch dark" id="theme-toggle" onclick="ThemeManager.toggle()" role="switch" aria-checked="true" aria-label="Dark Mode" title="Theme wechseln">
<span class="theme-switch-icon theme-switch-sun">&#9728;&#65038;</span>
<div class="theme-switch-track">
<div class="theme-switch-knob"></div>
</div>
<span class="theme-switch-icon theme-switch-moon">&#9789;</span>
</div>
<span class="header-user" id="headerUser"></span>
<button class="btn btn-secondary btn-small" id="logoutBtn">Abmelden</button>
<button class="btn btn-secondary btn-small" id="logoutBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></svg>
Abmelden
</button>
</div>
</header>
<main class="app-content">
<!-- Navigation -->
<!-- Navigation (Lucide-Icons, Strichstaerke 2 bei 15px) -->
<nav class="nav-tabs">
<button class="nav-tab active" data-section="dashboard">Dashboard</button>
<button class="nav-tab" data-section="orgs">Organisationen</button>
<button class="nav-tab" data-section="licenses">Lizenzen</button>
<button class="nav-tab" data-section="sources">Quellen</button>
<button class="nav-tab" data-section="audit">Audit-Log</button>
<button class="nav-tab active" data-section="dashboard">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
Dashboard
</button>
<button class="nav-tab" data-section="orgs">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></svg>
Organisationen
</button>
<button class="nav-tab" data-section="sources">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>
Quellen
</button>
<button class="nav-tab" data-section="audit">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>
Audit-Log
</button>
</nav>
<!-- Dashboard Section -->
<div class="section active" id="sec-dashboard">
<div class="stats-grid" id="statsGrid"></div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div class="card">
<div class="card 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>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">
<ul class="expiring-list" id="expiringList">
@@ -49,42 +76,6 @@
</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-header">
<h2>Artikel-Übersetzung</h2>
</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>
</div>
</div>
</div>
<!-- Organizations Section -->
@@ -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,20 +110,77 @@
<button class="detail-back" id="orgBackBtn">&larr; Zurück</button>
<div id="orgDetailHeader"></div>
<div class="nav-tabs mt-16" id="orgDetailTabs">
<button class="nav-tab active" data-subtab="users">Nutzer</button>
<button class="nav-tab" data-subtab="org-licenses">Lizenzen</button>
<button class="nav-tab" data-subtab="settings">Einstellungen</button>
<button class="nav-tab" data-subtab="tokens">Token-Nutzung</button>
<!-- 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 (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">API-Kosten Monat (Einkauf)</div>
<div class="token-stat-value" id="tokenCostUsd">-</div>
</div>
</div>
<div class="token-budget-bar-section">
<div class="token-budget-label">
<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>
<!-- 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>
<!-- An zweiter Stelle: Lizenz, Nutzer und Kontingent in EINER Karte -->
<h3 style="margin-top:24px;">Lizenz, Nutzer &amp; Credits-Kontingent bearbeiten</h3>
<div class="card" style="margin-top:12px;">
<div class="card-header">
<span class="text-secondary" style="font-size:13px;">Aktive und frühere Lizenzen dieser Organisation</span>
<button class="btn btn-primary btn-small" id="newLicenseBtn"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Neue Lizenz</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Typ</th>
<th>Max Nutzer</th>
<th>Gültig ab</th>
<th>Gültig bis</th>
<th>Status</th>
<th>Pipeline-Sprache</th>
<th>KI-Verarbeitung</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="card">
<div class="table-wrap">
<table>
<thead>
@@ -148,97 +196,40 @@
<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 class="card-body" style="border-top:1px solid var(--border);">
<form id="tokenBudgetForm" style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div style="grid-column:1/-1; padding:8px 12px; background:var(--bg-tertiary); border-radius:6px; border:1px solid var(--border);">
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; margin:0;">
<input type="checkbox" id="editUnlimitedBudget" onchange="onUnlimitedToggle()">
<span><strong>Unbegrenztes Budget</strong> &mdash; Verbrauch wird trotzdem getrackt, aber kein Limit/Hard-Stop</span>
</label>
</div>
<div class="form-group">
<label>Status</label>
<select id="editOrgActive">
<option value="true">Aktiv</option>
<option value="false">Deaktiviert</option>
<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="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>
<label for="editWarnPercent">Warnschwelle (%, 0 = aus)</label>
<input type="number" id="editWarnPercent" min="0" max="100" placeholder="80">
</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 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>
</div>
<div class="section" id="sub-tokens">
<div class="token-overview">
<div class="token-stats-row" id="tokenStatsRow">
<div class="token-stat-card">
<div class="token-stat-label">Verbrauchte Credits</div>
<div class="token-stat-value" id="tokenCreditsUsed">-</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-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 id="tokenBudgetPercent">0%</span>
</div>
<div class="token-budget-bar-container">
<div class="token-budget-bar" id="tokenBudgetBar"></div>
</div>
</div>
<h3 style="margin-top:24px;">Monatlicher Verlauf</h3>
<table class="data-table">
<thead>
@@ -254,127 +245,53 @@
</thead>
<tbody id="tokenMonthlyTable"></tbody>
</table>
<h3 style="margin-top:24px;">Budget bearbeiten</h3>
<div class="card" style="margin-top:12px;">
<div class="card-body">
<form id="tokenBudgetForm" style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div style="grid-column:1/-1; padding:8px 12px; background:var(--bg-tertiary,#0f172a); border-radius:6px; border:1px solid var(--border);">
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; margin:0;">
<input type="checkbox" id="editUnlimitedBudget" onchange="onUnlimitedToggle()">
<span><strong>Unbegrenztes Budget</strong> &mdash; Verbrauch wird trotzdem getrackt, aber kein Limit/Hard-Stop</span>
</label>
</div>
<div class="form-group">
<label for="editCreditsTotal">Credits-Kontingent</label>
<input type="number" id="editCreditsTotal" placeholder="z.B. 600000">
</div>
<div class="form-group">
<label for="editCostPerCredit">Kosten pro Credit (USD)</label>
<input type="number" id="editCostPerCredit" step="0.0001" placeholder="z.B. 0.0033">
</div>
<div class="form-group">
<label for="editBudgetUsd">Budget-Limit (USD)</label>
<input type="number" id="editBudgetUsd" step="0.01" placeholder="z.B. 2000">
</div>
<div class="form-group">
<label for="editCreditsUsed">Verbrauchte Credits</label>
<input type="number" id="editCreditsUsed" step="0.01" placeholder="Aktueller Verbrauch">
</div>
<div style="grid-column:1/-1; display:flex; gap:8px; align-items:center;">
<button type="submit" class="btn btn-primary">Speichern</button>
<span id="tokenBudgetMsg" style="font-size:13px; color:var(--text-secondary);"></span>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Licenses Section (global overview) -->
<div class="section" id="sec-licenses">
<div class="card">
<div class="card-header">
<h2>Bald ablaufende Lizenzen</h2>
<select id="expiringDays" style="background: var(--bg-primary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-primary); padding: 4px 8px; font-size: 13px;">
<option value="14">14 Tage</option>
<option value="30" selected>30 Tage</option>
<option value="90">90 Tage</option>
</select>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Organisation</th>
<th>Typ</th>
<th>Max Nutzer</th>
<th>Läuft ab</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="expiringTable"></tbody>
</table>
</div>
</div>
</div>
<!-- Sources Section -->
<div class="section" id="sec-sources">
<div class="nav-tabs" id="sourceSubTabs">
<button class="nav-tab active" data-subtab="global-sources">Grundquellen</button>
<button class="nav-tab" data-subtab="tenant-sources">Kundenquellen</button>
<button class="nav-tab" data-subtab="source-health">Quellen-Health</button>
<button class="nav-tab" data-subtab="classification-review">Klassifikation <span class="sources-tab-badge" id="classificationPendingBadge">0</span></button>
<button class="nav-tab 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>
@@ -383,90 +300,74 @@
</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="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>
<!-- Recherche-Zugänge (Sub-Tab): X-Konten + Telegram-Session-Status -->
<div class="section" id="sub-x-scraper">
<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 class="table-wrap">
<table>
<thead>
<tr>
<th>Benutzername</th>
<th>E-Mail</th>
<th>Status</th>
<th>Anfragen</th>
<th>Letzte Nutzung</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="xScraperTable"></tbody>
</table>
</div>
</div>
<div class="card">
<div class="review-list" id="classificationReviewList">
<div class="text-muted" style="padding:24px;text-align:center;">Lade Review-Queue…</div>
<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>
@@ -477,16 +378,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>
@@ -497,6 +389,7 @@
<button class="btn btn-secondary btn-small" id="auditNextBtn" disabled>Weiter &rarr;</button>
</div>
</div>
<div class="filter-bar" id="auditFilterBar"></div>
<div class="card">
<div class="table-wrap">
<table>
@@ -530,10 +423,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">
@@ -542,6 +432,62 @@
</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">
<label for="newOrgAiBackend">KI-Verarbeitung</label>
<select id="newOrgAiBackend">
<option value="" selected>Servervorgabe</option>
<option value="cli">Anthropic (heutiger Weg)</option>
<option value="bedrock">EU (Frankfurt, DSGVO-Fassung)</option>
</select>
<small class="text-secondary">Gilt für alle Lagen dieser Organisation, solange eine Lage nichts Eigenes einstellt. EU verarbeitet über AWS Bedrock in Frankfurt und recherchiert über den europäischen Suchindex staan.</small>
</div>
<div class="form-group" style="background:var(--bg-tertiary,#0f172a); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="checkbox" id="newOrgWithLicense" checked onchange="onNewOrgLicenseToggle()">
<span><strong>Direkt eine Lizenz mit Credits-Kontingent anlegen</strong></span>
</label>
</div>
<div id="newOrgLicenseFields">
<div class="form-group">
<label for="newOrgLicType">Lizenztyp</label>
<select id="newOrgLicType" onchange="onNewOrgLicTypeChange()">
<option value="trial" selected>Trial</option>
<option value="annual">Jahreslizenz</option>
<option value="permanent">Permanent</option>
</select>
</div>
<div class="form-group">
<label for="newOrgLicMaxUsers">Maximale Nutzer</label>
<input type="number" id="newOrgLicMaxUsers" value="5" min="1" max="1000">
</div>
<div class="form-group" id="newOrgLicDurationGroup">
<label for="newOrgLicDuration">Laufzeit (Tage)</label>
<input type="number" id="newOrgLicDuration" value="14" min="1" max="3650">
<div class="text-muted mt-8" style="font-size: 12px;">Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage</div>
</div>
<div class="form-group" style="background:var(--bg-tertiary,#0f172a); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="checkbox" id="newOrgLicUnlimited" onchange="onNewOrgLicUnlimitedToggle()">
<span><strong>Unbegrenztes Budget</strong> &mdash; getrackt, aber kein Hard-Stop</span>
</label>
</div>
<div class="form-group">
<label for="newOrgLicCreditsTotal">Credits-Kontingent</label>
<input type="number" id="newOrgLicCreditsTotal" value="10000" placeholder="z.B. 10000">
</div>
<div class="form-group">
<label for="newOrgLicCreditsPeriod">Abrechnungszeitraum</label>
<select id="newOrgLicCreditsPeriod">
<option value="monthly" selected>Monatlich (Kontingent füllt sich zum Monatswechsel neu)</option>
<option value="total">Gesamtlaufzeit</option>
</select>
</div>
<div class="form-group">
<label for="newOrgLicWarnPercent">Warnschwelle (%, 0 = aus)</label>
<input type="number" id="newOrgLicWarnPercent" value="80" min="0" max="100">
</div>
</div>
<div id="newOrgError" class="error-msg" style="display:none"></div>
</div>
<div class="modal-footer">
@@ -617,15 +563,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>
@@ -638,6 +587,53 @@
</div>
<!-- Modal: Organisation umbenennen -->
<div class="modal-overlay" id="modalRenameOrg">
<div class="modal">
<div class="modal-header">
<h3>Organisation umbenennen</h3>
<button class="modal-close" onclick="closeModal('modalRenameOrg')">&times;</button>
</div>
<form id="renameOrgForm">
<div class="modal-body">
<div class="form-group">
<label for="renameOrgName">Name</label>
<input type="text" id="renameOrgName" required maxlength="200">
</div>
<div id="renameOrgError" class="error-msg" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modalRenameOrg')">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<!-- Modal: Lizenz verlängern (ersetzt das alte Browser-prompt) -->
<div class="modal-overlay" id="modalExtendLicense">
<div class="modal">
<div class="modal-header">
<h3>Lizenz verlängern</h3>
<button class="modal-close" onclick="closeModal('modalExtendLicense')">&times;</button>
</div>
<form id="extendLicenseForm">
<div class="modal-body">
<div class="form-group">
<label for="extendDays">Um wie viele Tage verlängern?</label>
<input type="number" id="extendDays" value="365" min="1" max="3650" required>
<div class="text-muted mt-8" style="font-size:12px;">Die Verlängerung setzt am aktuellen Ablaufdatum an und aktiviert die Lizenz, falls sie abgelaufen war.</div>
</div>
<div id="extendLicenseError" class="error-msg" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modalExtendLicense')">Abbrechen</button>
<button type="submit" class="btn btn-primary">Verlängern</button>
</div>
</form>
</div>
</div>
<!-- Modal: Source (Create/Edit) -->
<div class="modal-overlay" id="modalSource">
<div class="modal">
@@ -662,38 +658,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;">
@@ -798,23 +767,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>
@@ -852,13 +804,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>
@@ -933,15 +879,85 @@
</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>
<script src="/static/js/app.js?v=20260522a"></script>
<script src="/static/js/sources.js?v=20260509d"></script>
<script src="/static/js/source-health.js?v=20260509l"></script>
<script src="/static/js/audit.js?v=20260509d"></script>
<!-- Modal: X-Recherche-Konto hinzufügen -->
<div class="modal-overlay" id="modalXScraperAdd">
<div class="modal">
<div class="modal-header">
<h3>X-Recherche-Konto hinzufügen</h3>
<button class="modal-close" onclick="closeModal('modalXScraperAdd')">&times;</button>
</div>
<form id="xScraperAddForm">
<div class="modal-body">
<div class="form-group">
<label for="xsUsername">X-Benutzername</label>
<input type="text" id="xsUsername" required placeholder="Login-Handle des Kontos, ohne @">
</div>
<div class="form-group">
<label for="xsPassword">X-Passwort</label>
<input type="password" id="xsPassword" placeholder="optional">
</div>
<div class="form-group">
<label for="xsEmail">E-Mail</label>
<input type="text" id="xsEmail" placeholder="optional, z.B. konto@protonmail.com">
</div>
<div class="form-group">
<label for="xsEmailPassword">E-Mail-Passwort</label>
<input type="password" id="xsEmailPassword" placeholder="optional">
</div>
<div class="form-group">
<label for="xsCookies">Cookies</label>
<textarea id="xsCookies" rows="3" required placeholder="auth_token=...; ct0=..."></textarea>
<small class="text-secondary">Aus dem eingeloggten X-Browser exportiert, mindestens auth_token und ct0.</small>
</div>
<div id="xScraperAddError" class="error-msg" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modalXScraperAdd')">Abbrechen</button>
<button type="submit" class="btn btn-primary">Konto anlegen</button>
</div>
</form>
</div>
</div>
<!-- Modal: X-Recherche-Konto Cookies erneuern -->
<div class="modal-overlay" id="modalXScraperCookies">
<div class="modal">
<div class="modal-header">
<h3>Cookies erneuern</h3>
<button class="modal-close" onclick="closeModal('modalXScraperCookies')">&times;</button>
</div>
<form id="xScraperCookiesForm">
<div class="modal-body">
<div class="form-group">
<label for="xsCookiesUsername">Konto</label>
<input type="text" id="xsCookiesUsername" readonly>
</div>
<div class="form-group">
<label for="xsCookiesValue">Neue Cookies</label>
<textarea id="xsCookiesValue" rows="3" required placeholder="auth_token=...; ct0=..."></textarea>
<small class="text-secondary">Frisch aus dem eingeloggten X-Browser exportieren.</small>
</div>
<div id="xScraperCookiesError" class="error-msg" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modalXScraperCookies')">Abbrechen</button>
<button type="submit" class="btn btn-primary">Cookies setzen</button>
</div>
</form>
</div>
</div>
<script src="/static/js/a11y.js?v=20260725a"></script>
<script src="/static/js/app.js?v=20260802a"></script>
<script src="/static/js/sources.js?v=20260725h"></script>
<script src="/static/js/aufgaben.js?v=20260725d"></script>
<script src="/static/js/x-scraper.js?v=20260725c"></script>
<script src="/static/js/audit.js?v=20260725a"></script>
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
</body>
</html>

Datei anzeigen

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

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

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

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

Datei anzeigen

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

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

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

570
src/static/js/rechner.js Normale Datei
Datei anzeigen

@@ -0,0 +1,570 @@
"use strict";
// Verbrauchsrechner (Posten-Modell) fuer das Verwaltungsportal.
// Eigenstaendiges Modul, injiziert Tab, Bereich und Styles selbst,
// damit dashboard.html unveraendert bleiben kann.
// Saetze sind ueber Regler frei einstellbar, mit Empfehlung aus den echten
// API-Kosten der letzten 30 Tage (GET /api/pricing/cost-basis).
(function () {
var USD = 0.20;
var MINIMUM_TAKT_MINUTEN = 30;
// Frei einstellbare Saetze (Credits je Aktion).
var saetze = { live: 45, research: 40, neben: 1 };
function rechercheNeu() { return saetze.research * 3; }
var EINHEITEN = [
{ wert: 1, text: "Minuten" },
{ wert: 60, text: "Stunden" },
{ wert: 1440, text: "Tage" }
];
var BLOCK_NAMEN = { live: "Live-Monitoring", recherche: "Recherche", neben: "Nebenkosten" };
var BLOCK_REIHENFOLGE = ["live", "recherche", "neben"];
var bloecke = {
live: [{ anzahl: 1, nurManuell: false, taktWert: 1, taktEinheit: 1440, tage: 30, manuell: 0 }],
recherche: [{ anzahl: 1, vertiefungen: 1 }],
neben: [{ auto: true, aufrufe: 50 }]
};
function fmt(n) { return Math.round(n).toLocaleString("de-DE"); }
function fmtUsd(n) { return n.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " $"; }
function neuerPosten(typ) {
if (typ === "recherche") return { anzahl: 1, vertiefungen: 1 };
if (typ === "neben") return { auto: true, aufrufe: 50 };
return { anzahl: 1, nurManuell: false, taktWert: 1, taktEinheit: 1440, tage: 30, manuell: 0 };
}
function autoAufrufe() {
var summe = 0;
bloecke.live.forEach(function (p) { summe += p.anzahl; });
bloecke.recherche.forEach(function (p) { summe += p.anzahl; });
return Math.round(summe * 1.5);
}
function postenCredits(typ, p) {
if (typ === "live") {
var laeufe = 1;
if (!p.nurManuell && p.taktWert > 0) {
var intervallMin = Math.max(MINIMUM_TAKT_MINUTEN, p.taktWert * p.taktEinheit);
laeufe = Math.max(1, Math.floor(p.tage * 1440 / intervallMin));
}
return (p.anzahl * laeufe + p.manuell) * saetze.live;
}
if (typ === "recherche") {
return p.anzahl * rechercheNeu() + p.vertiefungen * saetze.research;
}
return (p.auto ? autoAufrufe() : p.aufrufe) * saetze.neben;
}
// ---------- Posten-Felder ----------
function feldHtml(typ, i, name, labelText, hinweis, wert, min, max) {
var id = "vr-" + typ + "-" + i + "-" + name;
return '<div class="vr-field"><label for="' + id + '">' + labelText +
(hinweis ? " <small>" + hinweis + "</small>" : "") + "</label>" +
'<input type="number" id="' + id + '" data-feld="' + name + '" value="' + wert + '" min="' + min + '" max="' + max + '"></div>';
}
function taktHtml(i, p) {
var idW = "vr-live-" + i + "-taktwert";
var idE = "vr-live-" + i + "-takteinheit";
var intervallMin = p.taktWert * p.taktEinheit;
var chips = [
{ min: 60, text: "stündlich" },
{ min: 720, text: "halbtäglich" },
{ min: 1440, text: "täglich" }
].map(function (c) {
return '<button type="button" data-takt="' + c.min + '"' + (intervallMin === c.min ? ' class="aktiv"' : "") + ">" + c.text + "</button>";
}).join("");
return '<div class="vr-field"><label for="' + idW + '">Takt <small>wie oft jede Lage automatisch läuft, Minimum 30 Minuten</small></label>' +
'<span class="vr-takt">' +
'<input type="number" id="' + idW + '" data-feld="taktWert" value="' + p.taktWert + '" min="0" max="10000">' +
'<select id="' + idE + '" data-feld="taktEinheit" aria-label="Einheit des Takts">' +
EINHEITEN.map(function (o) {
return '<option value="' + o.wert + '"' + (o.wert === p.taktEinheit ? " selected" : "") + ">" + o.text + "</option>";
}).join("") + "</select></span></div>" +
'<div class="vr-schnell">' + chips + "</div>";
}
function postenFelder(typ, i, p) {
if (typ === "live") {
var idManuell = "vr-live-" + i + "-nurmanuell";
var html = feldHtml(typ, i, "anzahl", "Anzahl Lagen", "", p.anzahl, 1, 200) +
'<div class="vr-field"><label for="' + idManuell + '">Nur manuell <small>keine automatischen Läufe</small></label>' +
'<input type="checkbox" id="' + idManuell + '" data-feld="nurManuell"' + (p.nurManuell ? " checked" : "") + "></div>";
if (!p.nurManuell) {
html += taktHtml(i, p) +
feldHtml(typ, i, "tage", "Laufzeit in Tagen", "wie lange aktualisiert wird", p.tage, 1, 365);
}
html += feldHtml(typ, i, "manuell", "Aktualisierungen von Hand", "gesamt für diesen Posten", p.manuell, 0, 1000);
return html;
}
if (typ === "recherche") {
return feldHtml(typ, i, "anzahl", "Anzahl Recherchen", "", p.anzahl, 1, 200) +
feldHtml(typ, i, "vertiefungen", "Aktualisierungen von Hand", "gesamt für diesen Posten", p.vertiefungen, 0, 1000);
}
var idAuto = "vr-neben-" + i + "-auto";
var idAnz = "vr-neben-" + i + "-aufrufe";
return '<div class="vr-field"><label for="' + idAuto + '">Automatisch berechnen <small>Anzahl Lagen und Recherchen mal 1,5</small></label>' +
'<input type="checkbox" id="' + idAuto + '" data-feld="auto"' + (p.auto ? " checked" : "") + "></div>" +
'<div class="vr-field"><label for="' + idAnz + '">Anzahl Aufrufe <small>Chat oder Beschreibungs-Assistent, je 1 Credit</small></label>' +
'<input type="number" id="' + idAnz + '" data-feld="aufrufe" value="' + (p.auto ? autoAufrufe() : p.aufrufe) + '" min="0" max="100000"' + (p.auto ? " disabled" : "") + "></div>";
}
function renderBlock(typ) {
var el = document.getElementById("vr-liste-" + typ);
if (!el) return;
el.innerHTML = bloecke[typ].map(function (p, i) {
return '<div class="vr-posten" data-typ="' + typ + '" data-i="' + i + '">' +
'<div class="vr-head">' +
'<span class="vr-title">Posten ' + (i + 1) + "</span>" +
'<button type="button" class="vr-remove" data-remove aria-label="' + BLOCK_NAMEN[typ] + ', Posten ' + (i + 1) + ' entfernen">&times;</button>' +
"</div>" +
postenFelder(typ, i, p) +
'<div class="vr-sum">= <b data-sum>' + fmt(postenCredits(typ, p)) + "</b> Credits</div>" +
"</div>";
}).join("");
}
// ---------- Regler (frei einstellbare Saetze) ----------
function reglerHtml(satz, kicker, label, min, max) {
return '<div class="vr-regler">' +
'<div class="vr-regler-kicker">' + kicker + "</div>" +
'<div class="vr-regler-label">' + label + "</div>" +
'<div class="vr-regler-wert"><b id="vr-wert-' + satz + '">' + saetze[satz] + "</b> <small>Credits</small></div>" +
'<input type="range" id="vr-slider-' + satz + '" data-satz="' + satz + '" min="' + min + '" max="' + max + '" value="' + saetze[satz] + '">' +
(satz === "live" || satz === "research" ? '<div class="vr-note" id="vr-note-' + satz + '"></div>' : "") +
'<div class="vr-empf" id="vr-empf-' + satz + '">' +
(satz === "neben" ? "Nebenverbrauch real 0,02 bis 0,10 $ je Aufruf. 1 Credit ist die kleinste sinnvolle Einheit." : "Empfehlung wird geladen…") +
"</div>" +
"</div>";
}
function updateWerte() {
var wL = document.getElementById("vr-wert-live"); if (wL) wL.textContent = saetze.live;
var wR = document.getElementById("vr-wert-research"); if (wR) wR.textContent = saetze.research;
var wN = document.getElementById("vr-wert-neben"); if (wN) wN.textContent = saetze.neben;
var noteL = document.getElementById("vr-note-live");
if (noteL) noteL.textContent = "Gilt für Anlegen und jede Aktualisierung, gleicher Satz.";
var noteR = document.getElementById("vr-note-research");
if (noteR) noteR.textContent = "Anlegen einer Recherche = 3 × " + saetze.research + " = " + rechercheNeu() + " Credits.";
}
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) {
var el = document.getElementById("vr-empf-" + satz);
if (!el) return;
var t = (empfDaten.typen || {})[satz];
if (!t || t.empfohlen == null || !t.median_usd) {
el.textContent = "Keine Kostendaten der letzten " + empfDaten.days + " Tage.";
return;
}
el.innerHTML = "Empfohlen <b>" + t.empfohlen + "</b> · Median " + fmtUsd(t.median_usd) +
" aus " + fmt(t.n) + " Läufen (" + empfDaten.days + " Tage) " +
'<button type="button" class="vr-uebernehmen" data-uebernehmen="' + satz + '" data-wert="' + t.empfohlen + '">übernehmen</button>';
});
}
function ladeEmpfehlung() {
var token = null;
try { token = localStorage.getItem("token"); } catch (e) {}
fetch("/api/pricing/cost-basis?days=30", {
headers: token ? { "Authorization": "Bearer " + token } : {}
}).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
}).then(function (data) {
empfDaten = data;
renderEmpfehlung();
}).catch(function () {
["live", "research"].forEach(function (satz) {
var el = document.getElementById("vr-empf-" + satz);
if (el) el.textContent = "Empfehlung derzeit nicht verfügbar.";
});
});
}
// ---------- Gesamtrechnung ----------
function calc() {
var total = 0;
var zeilen = [];
BLOCK_REIHENFOLGE.forEach(function (typ) {
bloecke[typ].forEach(function (p, i) {
var c = postenCredits(typ, p);
total += c;
zeilen.push('<div class="vr-row"><span>' + BLOCK_NAMEN[typ] + " " + (i + 1) + "</span><span>" + fmt(c) + "</span></div>");
});
});
var posten = document.getElementById("vr-posten-zeilen");
if (posten) posten.innerHTML = zeilen.length ? zeilen.join("") : '<div class="vr-row"><span>Noch keine Posten</span><span>0</span></div>';
var totalEl = document.getElementById("vr-total");
if (totalEl) totalEl.innerHTML = fmt(total) + "<small>Credits insgesamt</small>";
var usdEl = document.getElementById("vr-usd");
if (usdEl) usdEl.textContent = fmtUsd(total * USD);
syncNebenAnzeige();
}
function syncNebenAnzeige() {
bloecke.neben.forEach(function (p, i) {
if (!p.auto) return;
var wrap = document.querySelector('#vr-liste-neben .vr-posten[data-i="' + i + '"]');
if (!wrap) return;
var input = wrap.querySelector('input[data-feld="aufrufe"]');
if (input) input.value = autoAufrufe();
var sumEl = wrap.querySelector("[data-sum]");
if (sumEl) sumEl.textContent = fmt(postenCredits("neben", p));
});
}
function alleSummenNeu() {
BLOCK_REIHENFOLGE.forEach(function (typ) {
bloecke[typ].forEach(function (p, i) {
var wrap = document.querySelector("#vr-liste-" + typ + ' .vr-posten[data-i="' + i + '"]');
if (!wrap) return;
var sumEl = wrap.querySelector("[data-sum]");
if (sumEl) sumEl.textContent = fmt(postenCredits(typ, p));
});
});
}
function satzGesetzt() {
updateWerte();
alleSummenNeu();
calc();
}
function wertUebernehmen(e) {
var wrap = e.target.closest(".vr-posten");
var name = e.target.getAttribute("data-feld");
if (!wrap || !name) return;
var typ = wrap.getAttribute("data-typ");
var i = parseInt(wrap.getAttribute("data-i"), 10);
if (name === "auto") {
if (!e.target.checked) bloecke.neben[i].aufrufe = autoAufrufe();
bloecke.neben[i].auto = e.target.checked;
renderBlock("neben");
calc();
return;
}
if (name === "nurManuell") {
bloecke.live[i].nurManuell = e.target.checked;
renderBlock("live");
calc();
return;
}
bloecke[typ][i][name] = parseFloat(e.target.value) || 0;
var sumEl = wrap.querySelector("[data-sum]");
if (sumEl) sumEl.textContent = fmt(postenCredits(typ, bloecke[typ][i]));
calc();
}
function normalisiereTakt(el) {
var wrap = el.closest(".vr-posten");
if (!wrap) return;
var i = parseInt(wrap.getAttribute("data-i"), 10);
var p = bloecke.live[i];
if (!p || p.nurManuell || p.taktWert <= 0) return;
var intervallMin = p.taktWert * p.taktEinheit;
if (intervallMin >= MINIMUM_TAKT_MINUTEN) return;
p.taktWert = Math.ceil((MINIMUM_TAKT_MINUTEN / p.taktEinheit) * 100) / 100;
var input = wrap.querySelector('input[data-feld="taktWert"]');
if (input) input.value = p.taktWert;
var sumEl = wrap.querySelector("[data-sum]");
if (sumEl) sumEl.textContent = fmt(postenCredits("live", p));
calc();
}
// ---------- Styles ----------
var STYLES =
".vr-kopf { margin-bottom: 16px; }" +
".vr-kopf h2 { margin: 0 0 4px; font-size: 20px; }" +
".vr-kopf p { margin: 0; font-size: 13px; color: var(--text-secondary); }" +
".vr-regler-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; margin-bottom: 10px; }" +
".vr-regler { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }" +
".vr-regler-kicker { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 6px; }" +
".vr-regler-label { font-size: 14px; font-weight: 600; margin-bottom: 6px; }" +
".vr-regler-wert { font-size: 30px; font-weight: 700; color: var(--accent); line-height: 1.1; margin-bottom: 8px; }" +
".vr-regler-wert small { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-left: 4px; }" +
".vr-regler input[type=range] { width: 100%; accent-color: var(--accent); cursor: pointer; }" +
".vr-note { font-size: 12px; color: var(--text-secondary); margin-top: 6px; }" +
".vr-empf { font-size: 12px; color: var(--text-muted); margin-top: 8px; line-height: 1.5; }" +
".vr-empf b { color: var(--accent); }" +
".vr-uebernehmen { margin-left: 4px; padding: 2px 9px; font-family: inherit; font-size: 11px; background: var(--bg-tertiary); color: var(--accent); border: 1px solid var(--accent); border-radius: 999px; cursor: pointer; }" +
".vr-uebernehmen:hover { background: var(--accent); color: var(--bg-primary); }" +
".vr-basis { font-size: 13px; color: var(--text-secondary); margin: 0 0 20px; }" +
".vr-calc { display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start; }" +
"@media (max-width: 900px) { .vr-calc { grid-template-columns: 1fr; } }" +
".vr-block { margin-bottom: 24px; }" +
".vr-block-title { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin: 0 0 10px; }" +
".vr-posten { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-bottom: 10px; }" +
".vr-head { display: flex; align-items: center; margin-bottom: 10px; }" +
".vr-title { font-weight: 600; font-size: 13px; }" +
".vr-remove { margin-left: auto; width: 26px; height: 26px; background: transparent; border: 1px solid var(--border); border-radius: 6px; color: var(--text-secondary); font-size: 15px; line-height: 1; cursor: pointer; }" +
".vr-remove:hover { border-color: var(--danger); color: var(--danger); }" +
".vr-field { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }" +
".vr-field label { font-size: 13px; color: var(--text-primary); }" +
".vr-field label small { display: block; font-size: 11px; color: var(--text-muted); }" +
".vr-field input[type=number], .vr-field select { width: 110px; flex: none; padding: 6px 8px; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; }" +
".vr-field input[type=number]::-webkit-outer-spin-button, .vr-field input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }" +
".vr-field input[type=number] { -moz-appearance: textfield; appearance: textfield; }" +
".vr-field input[type=checkbox] { width: 16px; height: 16px; flex: none; accent-color: var(--accent); cursor: pointer; }" +
".vr-field input:disabled { opacity: 0.55; }" +
".vr-field input:focus, .vr-field select:focus { outline: none; border-color: var(--accent); }" +
".vr-takt { display: flex; gap: 6px; flex: none; }" +
".vr-takt input { width: 58px; }" +
".vr-takt select { width: 96px; }" +
".vr-schnell { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: wrap; margin: -2px 0 10px; }" +
".vr-schnell button { padding: 2px 10px; font-family: inherit; font-size: 11px; background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border); border-radius: 999px; cursor: pointer; }" +
".vr-schnell button:hover, .vr-schnell button.aktiv { border-color: var(--accent); color: var(--accent); }" +
".vr-sum { border-top: 1px solid var(--bg-tertiary); margin-top: 10px; padding-top: 8px; text-align: right; font-size: 13px; color: var(--text-secondary); }" +
".vr-sum b { color: var(--accent); font-size: 14px; }" +
".vr-add { width: 100%; padding: 9px; background: transparent; border: 1px dashed var(--accent); border-radius: 8px; color: var(--accent); font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }" +
".vr-add:hover { background: rgba(240, 180, 41, 0.08); }" +
".vr-result { background: var(--bg-secondary); border: 1px solid var(--accent); border-radius: 8px; padding: 16px; position: sticky; top: 16px; }" +
".vr-total { font-size: 30px; font-weight: 700; color: var(--accent); line-height: 1.1; }" +
".vr-total small { display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-top: 4px; }" +
".vr-row { display: flex; justify-content: space-between; font-size: 13px; padding: 5px 0; border-bottom: 1px solid var(--bg-tertiary); }" +
".vr-row:last-child { border-bottom: none; }" +
".vr-row span:last-child { font-weight: 600; font-variant-numeric: tabular-nums; }" +
".vr-sep { margin: 12px 0; border: none; border-top: 1px solid var(--border); }";
// ---------- Aufbau ----------
function sektionHtml() {
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>" +
'<div class="vr-block"><h3 class="vr-block-title">Live-Monitoring</h3><div id="vr-liste-live"></div>' +
'<button type="button" class="vr-add" data-add="live">+ Live-Posten hinzufügen</button></div>' +
'<div class="vr-block"><h3 class="vr-block-title">Recherche</h3><div id="vr-liste-recherche"></div>' +
'<button type="button" class="vr-add" data-add="recherche">+ Recherche-Posten hinzufügen</button></div>' +
'<div class="vr-block"><h3 class="vr-block-title">Nebenkosten</h3><div id="vr-liste-neben"></div>' +
'<button type="button" class="vr-add" data-add="neben">+ Nebenkosten-Posten hinzufügen</button></div>' +
"</div>" +
'<div class="vr-result">' +
'<div class="vr-total" id="vr-total">0<small>Credits insgesamt</small></div>' +
'<hr class="vr-sep">' +
'<div id="vr-posten-zeilen"></div>' +
'<hr class="vr-sep">' +
'<div class="vr-row"><span>Selbstkosten</span><span id="vr-usd">0 $</span></div>' +
"</div>" +
"</div>";
}
function init() {
var nav = document.querySelector("nav.nav-tabs");
var content = document.querySelector(".app-content");
if (!nav || !content || document.getElementById("sec-rechner")) return;
var styleEl = document.createElement("style");
styleEl.textContent = STYLES;
document.head.appendChild(styleEl);
var sektion = document.createElement("div");
sektion.className = "section";
sektion.id = "sec-rechner";
sektion.innerHTML = sektionHtml();
content.appendChild(sektion);
var btn = document.createElement("button");
btn.className = "nav-tab";
btn.setAttribute("data-section", "rechner");
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 () {
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"); });
sektion.classList.add("active");
var detail = document.getElementById("orgDetail");
if (detail) detail.classList.remove("active");
});
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");
});
// Regler-Slider live in die Saetze uebernehmen.
sektion.addEventListener("input", function (e) {
if (e.target.type === "range") {
var satz = e.target.getAttribute("data-satz");
saetze[satz] = parseInt(e.target.value, 10) || 1;
satzGesetzt();
return;
}
if (e.target.tagName === "SELECT") return;
wertUebernehmen(e);
});
sektion.addEventListener("change", function (e) {
var name = e.target.getAttribute("data-feld");
if (e.target.tagName === "SELECT") wertUebernehmen(e);
if (name === "taktWert" || name === "taktEinheit") normalisiereTakt(e.target);
});
sektion.addEventListener("click", function (e) {
var uebBtn = e.target.closest("[data-uebernehmen]");
if (uebBtn) {
var s = uebBtn.getAttribute("data-uebernehmen");
var w = parseInt(uebBtn.getAttribute("data-wert"), 10);
if (w > 0) {
saetze[s] = w;
var slider = document.getElementById("vr-slider-" + s);
if (slider) {
if (w > parseInt(slider.max, 10)) slider.max = w;
slider.value = w;
}
satzGesetzt();
}
return;
}
var taktBtn = e.target.closest("[data-takt]");
if (taktBtn) {
var wrapT = taktBtn.closest(".vr-posten");
var iT = parseInt(wrapT.getAttribute("data-i"), 10);
var minuten = parseInt(taktBtn.getAttribute("data-takt"), 10);
var pT = bloecke.live[iT];
if (minuten % 1440 === 0) { pT.taktWert = minuten / 1440; pT.taktEinheit = 1440; }
else if (minuten % 60 === 0) { pT.taktWert = minuten / 60; pT.taktEinheit = 60; }
else { pT.taktWert = minuten; pT.taktEinheit = 1; }
renderBlock("live");
calc();
return;
}
var addBtn = e.target.closest("[data-add]");
if (addBtn) {
var typNeu = addBtn.getAttribute("data-add");
bloecke[typNeu].push(neuerPosten(typNeu));
renderBlock(typNeu);
calc();
return;
}
var removeBtn = e.target.closest("[data-remove]");
if (removeBtn) {
var wrap = removeBtn.closest(".vr-posten");
var typ = wrap.getAttribute("data-typ");
bloecke[typ].splice(parseInt(wrap.getAttribute("data-i"), 10), 1);
renderBlock(typ);
calc();
}
});
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") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

Datei anzeigen

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

Datei anzeigen

@@ -1,27 +1,75 @@
/* Grundquellen & Kundenquellen Management */
/* Vereinte Quellenliste (Grund- + Kundenquellen) */
"use strict";
// Hält Grund- UND Kundenquellen (GET /api/sources?scope=all)
let globalSourcesCache = [];
let tenantSourcesCache = [];
// Phase 3c: Tenant-Tab State
let tenantFilters = { search: "", type: "", category: "", org: "", language: "" };
let tenantSort = { field: "org_name", asc: true };
// Auswahl für Bulk-Promote (nur bei Herkunfts-Filter Kundenquellen sichtbar)
let tenantSelected = new Set();
// Checkbox-Filter der Quellenliste. Leeres Set = keine Einschränkung.
const unifiedFilters = { origin: new Set(), types: new Set(), cats: new Set(), status: new Set(), langs: new Set() };
let unifiedLangOptions = [];
let lastShowSelect = false;
// Lazy-Cache für die Health-Ausklapp-Details je Quelle
const sourceHealthDetailCache = new Map();
let editingSourceId = null;
let globalSortField = "category";
let globalSortAsc = true;
const CHECK_TYPE_LABELS = {
reachability: "Erreichbarkeit",
feed_validity: "Feed-Validität",
stale: "Aktualität",
duplicate: "Duplikat",
};
// CATEGORY_LABELS jetzt global (aus app.js loadMeta)
// TYPE_LABELS jetzt global (aus app.js loadMeta)
// META muss geladen sein, bevor Dropdowns befüllt werden (loadMeta läuft async beim Start)
async function ensureMeta() {
if (!window.META || !window.META.categories || !window.META.categories.length) {
if (typeof loadMeta === "function") await loadMeta();
}
}
// Modal-Selects ohne Leer-Option aus META befüllen
function fillSelect(el, items, opts = {}) {
if (!el) return;
el.innerHTML = "";
(items || []).forEach((it) => {
const o = document.createElement("option");
o.value = it.key;
o.textContent = it.label + (opts.disabledKeys && opts.disabledKeys.includes(it.key) && opts.disabledHint ? " " + opts.disabledHint : "");
if (opts.disabledKeys && opts.disabledKeys.includes(it.key)) o.disabled = true;
el.appendChild(o);
});
if (opts.value !== undefined) el.value = opts.value;
}
function fillSourceModalSelects() {
fillSelect(document.getElementById("sourceType"), (window.META && window.META.types) || [],
{ disabledKeys: ["pdf_document"], disabledHint: "(nur Upload)" });
fillSelect(document.getElementById("sourceCategory"), (window.META && window.META.categories) || []);
}
// --- Init ---
document.addEventListener("DOMContentLoaded", () => {
setupSourceSubTabs();
setupSourceForms();
if (typeof enableDragScroll === "function") {
enableDragScroll(document.getElementById("unifiedTableWrap"));
}
if (typeof attachFloatingScrollbar === "function") {
attachFloatingScrollbar(document.getElementById("unifiedTableWrap"));
}
// Beim Tab-Wechsel auf "Quellen" laden
// Beim Tab-Wechsel auf "Quellen" laden (+ Aufgaben-Badge aktualisieren)
document.querySelectorAll('.nav-tab[data-section="sources"]').forEach((tab) => {
tab.addEventListener("click", () => loadGlobalSources());
tab.addEventListener("click", () => {
loadUnifiedSources();
if (typeof refreshTasksBadge === "function") refreshTasksBadge();
});
});
});
@@ -34,39 +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) {
@@ -77,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) {
@@ -127,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",
@@ -135,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; }
@@ -143,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>`);
}
@@ -155,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;
}
@@ -179,47 +308,65 @@ function renderGlobalSources(sources) {
order.forEach((cat) => {
const label = CATEGORY_LABELS[cat] || cat;
const count = grouped[cat].length;
html += `<tr class="cat-header-row"><td colspan="${cols}"><span class="cat-header-label">${esc(label)}</span><span class="cat-header-count">${count}</span></td></tr>`;
html += `<tr class="cat-header-row"><td colspan="${cols}"><span class="cat-header-sticky"><span class="cat-header-label">${esc(label)}</span><span class="cat-header-count">${count}</span></span></td></tr>`;
grouped[cat].forEach((s) => {
const isTenant = s.tenant_id != null;
const hasNotes = s.notes && s.notes.trim();
const infoBtn = hasNotes
? `<span class="src-info-toggle" onclick="toggleSourceInfo(${s.id})" title="Info einblenden">&#9432;</span>`
: '';
const notesRow = hasNotes
? `<tr class="src-notes-row" id="notes-${s.id}" style="display:none;"><td colspan="${cols}" class="src-notes-cell">${esc(s.notes)}</td></tr>`
// EINE gemeinsame Detailzeile je Quelle: Notizen + Health-Details.
const hasDetail = hasNotes || s.health_status;
const detailRow = hasDetail
? `<tr class="src-notes-row" id="detail-${s.id}" style="display:none;"><td colspan="${cols}" class="src-notes-cell">${hasNotes ? `<div class="src-notes-text">${esc(s.notes)}</div>` : ""}${s.health_status ? `<div class="health-detail" id="health-detail-${s.id}"></div>` : ""}</td></tr>`
: '';
const lastSeen = s.last_seen_at ? formatDate(s.last_seen_at) : "-";
const hs = s.health_status || "unknown";
const hsLabel = { error: "Fehler", warning: "Warnung", ok: "OK", unknown: "—" }[hs];
const hsClass = "health-badge-" + (hs === "unknown" ? "unknown" : hs);
const healthCell = s.health_status
? `<span class="health-badge ${hsClass} health-badge-btn" onclick="toggleHealthDetail(${s.id})" title="Health-Details anzeigen" role="button" tabindex="0">${hsLabel}</span>`
: `<span class="health-badge ${hsClass}">${hsLabel}</span>`;
const selectCell = isTenant
? `<td class="select-col"><input type="checkbox" class="tenant-select" data-id="${s.id}" ${tenantSelected.has(s.id) ? "checked" : ""} onchange="toggleTenantSelect(${s.id}, this.checked)"></td>`
: `<td class="select-col"></td>`;
// Kundenquellen sind bewusst nicht editierbar: Workflow ist erst
// "Übernehmen" (Promote zur Grundquelle), dann als Grundquelle bearbeiten.
const actions = isTenant
? `<button class="btn btn-primary btn-small" onclick="promoteSource(${s.id}, '${esc(s.name)}')">Übernehmen</button>
<button class="btn btn-secondary btn-small" onclick="showSourceAudit(${s.id}, '${esc(s.name)}')">Audit</button>`
: `<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button>
<button class="btn btn-secondary btn-small" onclick="showSourceAudit(${s.id}, '${esc(s.name)}')">Audit</button>
<button class="btn btn-danger btn-small" onclick="confirmDeleteGlobalSource(${s.id}, '${esc(s.name)}')">Löschen</button>`;
html += `<tr>
<td>${infoBtn} ${esc(s.name)}</td>
${selectCell}
<td class="sticky-name" title="${esc(s.name)}">${infoBtn} ${esc(s.name)}</td>
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.url || '')}">${esc(s.url || "-")}</td>
<td>${esc(s.domain || "-")}</td>
<td>${typeLabel(s.source_type)}</td>
<td class="text-right">${s.article_count || 0}</td>
<td class="${(s.articles_30d || 0) === 0 ? "activity-cell activity-zero" : "activity-cell"}" title="7 Tage / 30 Tage"><strong>${s.articles_7d || 0}</strong> / ${s.articles_30d || 0}</td>
<td class="text-right"><span class="${(s.tenant_excluded_count || 0) === 0 ? "exclude-badge exclude-zero" : "exclude-badge"}">${s.tenant_excluded_count || 0}</span></td>
<td class="text-secondary">${esc(s.language || "-")}</td>
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.bias || "")}">${esc(s.bias || "-")}</td>
<td class="text-secondary">${lastSeen}</td>
<td><span class="health-badge ${hsClass}">${hsLabel}</span></td>
<td>${healthCell}</td>
<td><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td>
<td>
<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button>
<button class="btn btn-secondary btn-small" onclick="showSourceAudit(${s.id}, '${esc(s.name)}')">Audit</button>
<button class="btn btn-danger btn-small" onclick="confirmDeleteGlobalSource(${s.id}, '${esc(s.name)}')">Löschen</button>
</td>
</tr>${notesRow}`;
<td class="text-secondary">${isTenant ? esc(s.org_name || ("Org " + s.tenant_id)) : '<span class="text-muted">—</span>'}</td>
<td class="sticky-actions">${actions}</td>
</tr>${detailRow}`;
});
});
tbody.innerHTML = html;
document.getElementById("globalSourceCount").textContent = `${sources.length} Grundquellen`;
const o = unifiedFilters.origin;
const originLabel = (o.has("global") && !o.has("tenant")) ? "Grundquellen"
: (o.has("tenant") && !o.has("global")) ? "Kundenquellen" : "Quellen";
document.getElementById("globalSourceCount").textContent = `${sources.length} ${originLabel}`;
updateBulkButton();
// Sort-Icons aktualisieren
document.querySelectorAll("th.sortable .sort-icon").forEach(el => el.textContent = "");
const activeHeader = document.querySelector(`th.sortable[data-sort="${globalSortField}"] .sort-icon`);
document.querySelectorAll("#unifiedTable th.sortable .sort-icon").forEach(el => el.textContent = "");
const activeHeader = document.querySelector(`#unifiedTable th.sortable[data-sort="${globalSortField}"] .sort-icon`);
if (activeHeader) activeHeader.textContent = globalSortAsc ? " ▲" : " ▼";
}
@@ -227,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;
});
@@ -251,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 || "";
@@ -305,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");
}
@@ -329,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;
@@ -348,7 +528,6 @@ function setupSourceForms() {
if (cc) body.country_code = cc;
if (editingSourceId) {
body.state_affiliated = !!document.getElementById("sourceStateAffiliated")?.checked;
body.alignments = getAlignmentChips();
}
try {
@@ -358,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";
@@ -383,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);
@@ -482,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;
}
@@ -511,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",
@@ -562,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");
}
@@ -654,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");
@@ -665,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";
@@ -884,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");
@@ -937,7 +863,7 @@ function setupPdfUploadForm() {
if (typeof showToast === "function") {
showToast("PDF hochgeladen -- Verarbeitung laeuft im Hintergrund", "success");
}
loadGlobalSources();
loadUnifiedSources();
} catch (err) {
errEl.textContent = err.message || "Upload fehlgeschlagen";
errEl.style.display = "block";

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

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

199
src/static/js/x-scraper.js Normale Datei
Datei anzeigen

@@ -0,0 +1,199 @@
/* 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 {
xScraperCache = await API.get("/api/x-scraper/accounts");
renderXScraperAccounts(xScraperCache || []);
} catch (err) {
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">Fehler: ' + esc(err.message || "") + '</td></tr>';
}
}
// 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");
if (!list.length) {
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">Keine X-Recherche-Konten. Mit „+ Konto hinzufügen" anlegen.</td></tr>';
return;
}
tbody.innerHTML = list.map((a) => {
let status;
if (!a.active) status = '<span class="text-muted">Inaktiv</span>';
else if (a.locked) status = '<span style="color:var(--warning,#b8860b);">Gesperrt</span>';
else status = '<span style="color:var(--success,#2e7d32);">Aktiv</span>';
const lastUsed = a.last_used && typeof formatDateTime === "function"
? formatDateTime(a.last_used)
: (a.last_used || "—");
const errInfo = a.error_msg
? ' <span class="info-icon" title="' + esc(a.error_msg) + '">!</span>'
: "";
const u = esc(a.username);
const toggleLabel = a.active ? "Deaktivieren" : "Aktivieren";
return '<tr>'
+ '<td><strong>' + u + '</strong>' + errInfo + '</td>'
+ '<td>' + esc(a.email || "—") + '</td>'
+ '<td>' + status + '</td>'
+ '<td>' + (a.total_requests || 0) + '</td>'
+ '<td>' + esc(lastUsed) + '</td>'
+ '<td>'
+ '<button class="btn btn-secondary btn-small" onclick="openXScraperCookiesModal(\'' + u + '\')">Cookies erneuern</button> '
+ '<button class="btn btn-secondary btn-small" onclick="toggleXScraperActive(\'' + u + '\',' + (!a.active) + ')">' + toggleLabel + '</button> '
+ '<button class="btn btn-danger btn-small" onclick="confirmDeleteXScraper(\'' + u + '\')">Entfernen</button>'
+ '</td>'
+ '</tr>';
}).join("");
}
function openXScraperAddModal() {
document.getElementById("xScraperAddError").style.display = "none";
["xsUsername", "xsPassword", "xsEmail", "xsEmailPassword", "xsCookies"].forEach((id) => {
const el = document.getElementById(id);
if (el) el.value = "";
});
openModal("modalXScraperAdd");
}
function openXScraperCookiesModal(username) {
document.getElementById("xScraperCookiesError").style.display = "none";
document.getElementById("xsCookiesUsername").value = username;
document.getElementById("xsCookiesValue").value = "";
openModal("modalXScraperCookies");
}
async function toggleXScraperActive(username, active) {
try {
await API.post("/api/x-scraper/accounts/" + encodeURIComponent(username) + "/active", { active: active });
showToast("Status geändert.", "success");
loadXScraperAccounts();
} catch (err) {
showToast(err.message || "Status konnte nicht geändert werden", "error");
}
}
function confirmDeleteXScraper(username) {
showConfirm(
"Konto entfernen",
'Soll das X-Recherche-Konto "' + username + '" entfernt werden? Der Monitor nutzt es dann nicht mehr zum Scrapen.',
async () => {
try {
await API.del("/api/x-scraper/accounts/" + encodeURIComponent(username));
showToast("Konto entfernt.", "success");
loadXScraperAccounts();
} catch (err) {
showToast(err.message || "Konto konnte nicht entfernt werden", "error");
}
},
{ danger: true }
);
}
function resetXScraperLocks() {
showConfirm(
"Sperren zurücksetzen",
"Alle temporären Sperren der X-Recherche-Konten zurücksetzen?",
async () => {
try {
await API.post("/api/x-scraper/reset-locks", {});
showToast("Sperren zurückgesetzt.", "success");
loadXScraperAccounts();
} catch (err) {
showToast(err.message || "Sperren konnten nicht zurückgesetzt werden", "error");
}
}
);
}
function setupXScraperForms() {
const addForm = document.getElementById("xScraperAddForm");
if (addForm && !addForm.dataset.wired) {
addForm.dataset.wired = "1";
addForm.addEventListener("submit", async (e) => {
e.preventDefault();
const errEl = document.getElementById("xScraperAddError");
errEl.style.display = "none";
const body = {
username: document.getElementById("xsUsername").value.trim().replace(/^@/, ""),
password: document.getElementById("xsPassword").value,
email: document.getElementById("xsEmail").value.trim(),
email_password: document.getElementById("xsEmailPassword").value,
cookies: document.getElementById("xsCookies").value.trim(),
};
if (!body.username || !body.cookies) {
errEl.textContent = "Benutzername und Cookies sind erforderlich.";
errEl.style.display = "block";
return;
}
try {
await API.post("/api/x-scraper/accounts", body);
closeModal("modalXScraperAdd");
showToast("Konto angelegt.", "success");
loadXScraperAccounts();
} catch (err) {
errEl.textContent = err.message || "Anlegen fehlgeschlagen";
errEl.style.display = "block";
}
});
}
const ckForm = document.getElementById("xScraperCookiesForm");
if (ckForm && !ckForm.dataset.wired) {
ckForm.dataset.wired = "1";
ckForm.addEventListener("submit", async (e) => {
e.preventDefault();
const errEl = document.getElementById("xScraperCookiesError");
errEl.style.display = "none";
const username = document.getElementById("xsCookiesUsername").value;
const cookies = document.getElementById("xsCookiesValue").value.trim();
if (!cookies) {
errEl.textContent = "Cookies sind erforderlich.";
errEl.style.display = "block";
return;
}
try {
await API.post("/api/x-scraper/accounts/" + encodeURIComponent(username) + "/cookies", { cookies: cookies });
closeModal("modalXScraperCookies");
showToast("Cookies erneuert.", "success");
loadXScraperAccounts();
} catch (err) {
errEl.textContent = err.message || "Cookies konnten nicht erneuert werden";
errEl.style.display = "block";
}
});
}
}

Datei anzeigen

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

Datei anzeigen

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

50
tests/test_health_run.py Normale Datei
Datei anzeigen

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

Datei anzeigen

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

138
tests/test_org_ki_vorgabe.py Normale Datei
Datei anzeigen

@@ -0,0 +1,138 @@
"""Prueft die organisationsweite KI-Vorgabe ohne Datenbank und ohne Netz.
Der Monitor loest den KI-Weg als Lage vor Organisation vor Servervorgabe auf.
Diese Datei deckt das mittlere Glied ab, also das Org-Setting 'ai_backend'.
Geprueft werden die Modelle (welche Werte kommen durch), der Router (wird das
Setting geschrieben und gelesen) und die Oberflaeche (Spaltenzahl der Tabelle,
Behandlung des Leerstrings als gueltiger Wert).
"""
import os
import re
import pytest
from pydantic import ValidationError
from models import OrgCreate, OrgResponse, OrgUpdate
STAMM = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def lies(*teile):
with open(os.path.join(STAMM, *teile), encoding="utf-8") as f:
return f.read()
@pytest.fixture(scope="module")
def router():
return lies("src", "routers", "organizations.py")
@pytest.fixture(scope="module")
def html():
return lies("src", "static", "dashboard.html")
@pytest.fixture(scope="module")
def js():
return lies("src", "static", "js", "app.js")
# --- Modelle ---------------------------------------------------------------
@pytest.mark.parametrize("wert", ["", "cli", "bedrock"])
def test_orgcreate_nimmt_gueltige_werte(wert):
assert OrgCreate(name="Test", slug="test", ai_backend=wert).ai_backend == wert
@pytest.mark.parametrize("wert", ["gpt", "CLI", "bedrock ", "openai", "anthropic"])
def test_orgcreate_lehnt_ungueltige_werte_ab(wert):
with pytest.raises(ValidationError):
OrgCreate(name="Test", slug="test", ai_backend=wert)
def test_orgcreate_ohne_angabe_ist_leer():
assert OrgCreate(name="T", slug="t").ai_backend == ""
@pytest.mark.parametrize("wert", ["", "cli", "bedrock"])
def test_orgupdate_nimmt_gueltige_werte(wert):
assert OrgUpdate(ai_backend=wert).ai_backend == wert
def test_orgupdate_unterscheidet_leerstring_von_fehlender_angabe():
"""Der Leerstring bedeutet ausdruecklich "Servervorgabe" und muss den
Router erreichen. Eine fehlende Angabe darf dagegen nichts aendern."""
assert OrgUpdate(ai_backend="").ai_backend == ""
assert OrgUpdate(name="x").ai_backend is None
def test_orgresponse_liefert_das_feld():
felder = OrgResponse.model_fields
assert "ai_backend" in felder
assert felder["ai_backend"].default == ""
# --- Router ----------------------------------------------------------------
def test_router_setzt_beim_anlegen(router):
assert 'set_org_setting(db, org_id, "ai_backend", data.ai_backend or "")' in router
def test_router_setzt_beim_aendern(router):
assert 'set_org_setting(db, org_id, "ai_backend", data.ai_backend)' in router
def test_router_prueft_auf_none_statt_auf_wahrheit(router):
"""Eine Pruefung auf Wahrheit wuerde das Zuruecksetzen auf die
Servervorgabe verschlucken, denn der Leerstring ist falsy."""
assert "if data.ai_backend is not None:" in router
def test_router_liefert_das_setting_mit(router):
assert "key = 'ai_backend'" in router
assert 'org["ai_backend"] = (ai_row["value"] if ai_row else "") or ""' in router
# --- Oberflaeche -----------------------------------------------------------
def test_lizenztabelle_hat_acht_spalten(html):
bis = html.index('<tbody id="licenseTable">')
kopf = html[html.rindex("<thead>", 0, bis):bis]
assert len(re.findall(r"<th>", kopf)) == 8
assert "<th>KI-Verarbeitung</th>" in kopf
def test_jede_zeile_fuellt_acht_spalten(js):
"""Beide bedingten Zellen liefern in jedem Zweig genau ein td, im
else-Zweig steht es woertlich im Template. Zaehlen genuegt also."""
zeile = re.search(r"tbody\.innerHTML = licenses\.map.*?\.join\(\"\"\);", js, re.S).group(0)
assert len(re.findall(r"<td", zeile)) == 8
assert "spracheZelle" in zeile and "kiZelle" in zeile
def test_leere_tabelle_spannt_ueber_acht_spalten(js):
assert 'colspan="8"' in js
assert 'colspan="7"' not in js
def test_speicherfunktion_reicht_den_wert_unveraendert_durch(js):
assert "function setOrgAiBackend(wert)" in js
assert "_saveOrgSetting({ ai_backend: wert }" in js
assert 'onchange="setOrgAiBackend(this.value)"' in js
def test_auswahl_bietet_drei_werte(js):
auswahl = re.search(r"const kiZelle = `.*?`;", js, re.S).group(0)
assert re.findall(r'value="([^"]*)"', auswahl) == ["", "cli", "bedrock"]
assert auswahl.count("selected") == 3 # jeder Wert kann vorausgewaehlt sein
def test_anlege_dialog_hat_dasselbe_feld(html, js):
auswahl = re.search(r'<select id="newOrgAiBackend">.*?</select>', html, re.S)
assert auswahl is not None
assert re.findall(r'value="([^"]*)"', auswahl.group(0)) == ["", "cli", "bedrock"]
assert 'ai_backend: (document.getElementById("newOrgAiBackend") || {}).value || ""' in js
def test_cache_buster_wurde_erhoeht(html):
assert re.findall(r"/static/js/app\.js\?v=([0-9a-z]+)", html) == ["20260802a"]

Datei anzeigen

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

93
tests/test_statistik.py Normale Datei
Datei anzeigen

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