Skip to content

Instantly share code, notes, and snippets.

@mbaersch
Last active April 28, 2026 10:20
Show Gist options
  • Select an option

  • Save mbaersch/b1920a6f8ddc00bad37a64d220d8a102 to your computer and use it in GitHub Desktop.

Select an option

Save mbaersch/b1920a6f8ddc00bad37a64d220d8a102 to your computer and use it in GitHub Desktop.
Logalytrix — Browser-basierter Logfile-Analyzer (Single-File)

Logalytrix

Browser-basierter Logfile-Analyzer für Apache Combined Log und JSON-Logformate. Läuft vollständig lokal, ohne Server oder externe Abhängigkeiten.

Hinweis: Die Motivation zu diesem Tool, Hintergrund und Details inkl. Abbildungen im (https://www.markus-baersch.de/blog/logalytrix-logfile-analyse-showcase-tool-fuer-webserver-logs/)[begleitenden Bloglost].

Besonderheiten: Warum es dieses Tool gibt

Neben den üblichen Funktionen zum Import von Rohdaten in eine große Tabelle mit einer Menge Filtern und Suchfunktionen, Infos zu Browsern, Seiten, Pfaden, Statuscodes & Co. (die gibt es auch) hat diese Logfile Analyse ein paar Besonderheiten:

  • Importfilter: Da die Größe dessen, was in einen Browser inkl. Auswertungsfunktionen etc. passt, ohne die Performance bei Änderungen der Ansicht zu sehr zu beeinträchtigen, kann schon beim Import von Logdateien - je nach Ziel der Analyse - gezielt begrenzt werden, was überhaupt eingelesen werden soll. Dabei können auch individuelle Filter auf Pfadregel angewendet werden, statt diese nachher nur temporär auszublenden oder nachträglich aus der DB zu werfen (was beides auch möglich ist).

  • Attribution: Über die Definition von bis zu drei benannten Zielen, die anhand von Pfaden erkannt werden, können Conversions zu Referrern, UTM-Kampagnen, Quellen, die per Klick-ID erkannt werden, Gerätekategorien etc. zugeordnet werden. Durch die Bildung von Dimensionen wie "User" (aus IP + User Agent) und "Session" (endet 30 Minuten nach dem letzten Hit eines "Users") wird aus reinem Zählen von Hits ein Werkzeug zur gezielten Erfolgsauswertung - in den Grenzen, die ein Log mit sich bringt.

  • Bots: Über - zugegeben einfache - Mustererkennung werden sowohl Bots von Browsern getrennt, als auch in einer dritten Kategorie die zahlreichen Hits auf nicht existente typische "Angriffsziele" (WP Logins, cgi-scripts und andere) als Angriffe gruppiert. Beim Import ausgelassen oder durch Filter entfernt - oder gezielt betrachtet -, kann hierüber das Rauschen vom Signal getrennt werden; je nach Perspektive der Analyseaufgabe. Da auch Bots nicht alle gleich sind, werden diese zudem in Bot-Kategorien wie Suchmaschinen, KI-Crawler, SEO Tools u. a. gruppiert. Für jeden Bot gibt es Details wie Top-Seiten, Kilobytes und eine Heatmap mit Besuchsstatistik, aktivstem Tag und Stunde.

Wichtiger Hinweis: Alles, was hier anhand von einfachen Mustern erkannt und klassifiziert wird, ist eher rudimentär. Es geht nicht um Perfektion. Wer eigene Regeln bauen will, kann komplexere Lösungen einsetzen oder den Code anpassen. Dieses Tool hier soll die Lücke zwischen den Statistiken beim Hoster bzw. über einfache Logfile Analyzer und dem ELK Stack und ähnlich komplexen Ansätzen schließen.

Starten

logalytrix.html im Browser öffnen (file://-Protokoll genügt). Die gesamte Anwendung steckt in dieser einen Datei.

Logdateien laden

Über den Button Logdateien laden eine oder mehrere Dateien auswählen (.log, .txt, .json, .gz). Unterstützte Formate:

  • Apache Combined Log — das Standardformat von Apache und vielen Webservern:
    127.0.0.1 - - [29/Dec/2025:00:12:34 +0100] "GET /seite HTTP/1.1" 200 1234 "https://referrer.example" "Mozilla/5.0 ..."
    
    Varianten mit Host-Feld, Response Time am Zeilenende oder im Hetzner-Format (Domain + %D vor der IP) werden automatisch erkannt.
  • JSON — ein JSON-Objekt pro Zeile mit Feldern wie ip, timestamp, method, path, status, bytes, referrer, userAgent (Feldnamen sind flexibel, gängige Varianten werden erkannt).
  • Gzip-komprimierte Dateien (.gz) — werden automatisch entpackt.

Vor dem Import öffnet sich ein Import-Umfang-Dialog, in dem festgelegt wird, welche Kategorien importiert werden sollen:

  • Browser — reguläre Besucher-Requests (Standard: an)
  • Bots — Bot- und Crawler-Traffic (Standard: an)
  • Angriffe — erkannte Angriffsversuche (Standard: an)
  • Statuscodes — 3xx-Redirects, 4xx-Clientfehler, 5xx-Serverfehler einzeln abwählbar
  • Ressourcentypen — Bilder, CSS, JavaScript, Schriften einzeln abwählbar
  • Bereinigungsregeln anwenden — wenn Bereinigungsmuster definiert sind, können diese direkt beim Import angewandt werden. Treffer werden nicht importiert und zählen nicht gegen das Zeilenlimit.

Durch Abwählen nicht benötigter Kategorien lässt sich mehr relevanter Traffic in das Importlimit importieren. Wird das Limit während des Imports erreicht, erscheint dauerhaft eine Warnung im Header ("Import unvollständig"), die bei einem neuen vollständigen Import automatisch verschwindet.

Geladene Daten werden im Browser gespeichert (IndexedDB) und beim nächsten Öffnen automatisch wiederhergestellt.

Ansichten

Rohdaten

Alle Log-Einträge als Tabelle. Spaltenköpfe sortieren bei Klick (erneuter Klick kehrt die Richtung um). Suchfeld oberhalb der Tabelle filtert nach Pfadnamen. Angriffs-Requests und Bot-Requests werden farblich hervorgehoben (Legende über der Tabelle). Gesamtanzahl der Einträge wird über der Tabelle angezeigt.

Dashboard

Überblick mit Kennzahlen und Diagrammen:

  • Hits gesamt — Anzahl aller HTTP-Requests
  • Sessions — zusammenhängende Aktivitätsphasen (siehe Abschnitt Session-Modell)
  • Nutzer — eindeutige Kombinationen aus IP-Adresse und User-Agent
  • Angriffe — Anzahl erkannter Angriffs-Requests (SQL-Injection, Path-Traversal etc.)
  • Ø Seiten/Session — durchschnittliche Hits pro Session
  • Ø Seiten/Nutzer — durchschnittliche Hits pro Nutzer
  • Täglicher Traffic — Balkendiagramm, schaltet bei großen Zeiträumen automatisch auf Wochen- oder Monatsansicht um. Klick auf einen Balken filtert auf den Zeitraum — bei ≤3 Tagen wechselt die Ansicht automatisch auf stündliche Auflösung
  • HTTP-Methoden — Verteilung der HTTP-Methoden (GET, POST, HEAD etc.) als Segmentbalken
  • Antwortzeiten — Doppelbalken-Diagramm mit Median und Durchschnitt pro Tag/Woche/Monat (nur wenn Response Time im Log vorhanden, z.B. Apache %D)
  • HTTP-Status-Verteilung — Anteil der 2xx/3xx/4xx/5xx-Antworten als Balken mit Legende
  • Ressourcentypen — Verteilung nach Dateityp (Seite, Bild, CSS, JavaScript etc.)
  • Top 5 Seiten — meistaufgerufene Pfade

Top-Seiten

Alle aufgerufenen Pfade mit Hits, Bot-Hits, User-Hits und Bytes — sortierbar und paginiert.

Verzeichnisse

Aggregation der Seitenaufrufe nach Verzeichnistiefe. Die Tiefe ist per Dropdown wählbar (Ebene 1–4):

  • Ebene 1: /blog, /produkte, /docs
  • Ebene 2: /blog/2024, /produkte/schuhe
  • usw.

Spalten: Hits, Bot-Hits, User-Hits, Bytes — ermöglicht auf einen Blick, welche Verzeichnisse wie viel Bot- vs. echten Traffic haben.

Nur Ressourcentyp "Seite" wird berücksichtigt (Assets wie CSS, JS, Bilder werden ausgefiltert). Clean URLs (/produkte/nike-air-max) und URLs mit Dateiendung (/blog/post.html) funktionieren gleich — der Pfad wird einfach bei / gesplittet.

Einstiegsseiten

Erste Seite, die ein Besucher in einer Session aufruft (Landingpages). Nur Seiten-Aufrufe, Reloads werden dedupliziert.

Ausstiegsseiten

Letzte Seite einer Session (wo Besucher die Website verlassen). Gleiche Filterung wie bei Einstiegsseiten.

Pfadübergänge

Häufigste Seitenwechsel (von Seite A nach Seite B). Die Suche filtert über beide Spalten (Von und Nach).

Navigationsübersicht

Inspiriert von der Navigationsübersicht in Google Universal Analytics — auf Logfile-Basis und erweitert um Muster-Matching. Zeigt für eine wählbare Seite oder Seitengruppe in einem horizontalen 3-Spalten-Layout:

  • Vorherige Seiten — woher kamen die Nutzer? (Top 10, weitere nachladen)
  • Aktuelle Seite — Aufrufe, Einstiege und Ausstiege mit Prozentangaben
  • Nächste Seiten — wohin gingen die Nutzer danach? (Top 10, weitere nachladen)

Die Seitenauswahl nutzt das gleiche Muster-Matching wie die Suche (Enthält, Beginnt mit, Regex) und den NICHT-Toggle. Damit lassen sich ganze Seitenbereiche wie /blog* analysieren — quasi Content Groups ohne Vorarbeit. Die Analyse startet per Klick auf "Analysieren".

Ein Quell-Dropdown ermöglicht die Einschränkung auf Sessions einer bestimmten Herkunft (z.B. nur Google-Besucher).

Session-Analyse

Vollständige User-Journeys als filterbare Tabelle. KPI-Cards zeigen Sessions, Bounce-Rate, durchschnittliche Seiten pro Session, Median Pfadlänge und Median Session-Dauer. Ein Histogramm visualisiert die Verteilung der Session-Dauern.

  • Quell-Dropdown — Einschränkung auf Sessions einer bestimmten Herkunft (z.B. "Wie verhalten sich Google-Besucher vs. Facebook-Besucher?")
  • Journey-Tabelle — häufigste vollständige Pfade (z.B. /home → /produkt → /warenkorb → /kasse), sortierbar nach Häufigkeit, Schritten oder Dauer. Die Suche filtert auf Journeys, die eine bestimmte Seite enthalten — "zeig mir alle Wege durch /warenkorb". Zusätzliche Filter für Mindest-Schritte und Mindest-Sessions ermöglichen es, Rauschen auszublenden und typische Pfade zu identifizieren.
  • Bounce-Rate pro Einstiegsseite — zeigt für jede Landingpage, wie viele Sessions nur aus einem einzigen Seitenaufruf bestanden.

Quellen

Herkunft des Traffics in drei Bereichen:

  • Click-ID-Erkennung — KPI-Cards für bezahlten Traffic, erkannt anhand von URL-Parametern: Google Ads (gclid, gad_source, wbraid, gbraid), Google DV360 (dclid), Facebook/Meta (fbclid), Microsoft Ads (msclkid), TikTok (ttclid), LinkedIn (li_fat_id), Twitter/X (twclid), Snapchat (sclid), Reddit (rdt_cid), Pinterest (pclid). Nur Plattformen mit Treffern werden angezeigt.
  • Top-Referrer — externe Domains, die Traffic bringen (sortierbar, paginiert)
  • UTM-Kampagnen — Auswertung von utm_source/medium/campaign (auch Matomo mtm_ und Piwik pk_ Varianten)

Der eigene Hostname wird automatisch erkannt (häufigster Referrer) und kann in der Quellen-View angepasst werden. Zugriffe vom eigenen Host erscheinen als "(intern/unbekannt)" und werden nicht für die Conversion-Attribution herangezogen.

Sind Conversion-Goals definiert (siehe Conversions), erscheinen zusätzliche Spalten mit den zugeordneten Conversions pro Referrer bzw. Kampagne.

Die Suche filtert Referrer und Kampagnen gleichzeitig.

Conversions (Report)

Zeigt pro definiertem Goal (siehe Tools → Conversions) eine KPI-Card und ein Zeitreihen-Balkendiagramm mit den täglichen Conversions. Das Diagramm schaltet bei großen Zeiträumen automatisch auf Wochen- bzw. Monatsansicht um. Im Menü unter Attribution.

Conversion-Pfade

Im Menü unter Attribution. Zeigt die häufigsten User-Journeys, die zu einer Conversion führen. Basiert auf den gleichen Daten wie die Session-Analyse, filtert aber auf Pfade, die mindestens einen Schritt enthalten, der einem definierten Conversion-Ziel entspricht.

  • Goal-Dropdown — alle Ziele oder ein einzelnes Goal auswählen
  • Quell-Dropdown — Einschränkung auf Sessions einer bestimmten Herkunft
  • KPIs — Conversion-Sessions, Conversion-Rate, Unique Pfade, Median Pfadlänge und Dauer
  • Dauer-Histogramm — nur für Sessions mit Conversions
  • Pfad-Tabelle — Conversion-Schritte werden farbig hervorgehoben, filterbar nach Mindest-Schritten und -Sessions

Kampagnenqualität

Im Menü unter Attribution. Erkennt verdächtige Muster im bezahlten Traffic durch /24-Subnetz-Clustering:

  • Cluster — mehrere IPs aus demselben Subnetz mit Click-ID-Hits (z.B. gclid, fbclid)
  • Ad-dominiert — IPs, deren Traffic zu >80% aus Ad-Klicks besteht
  • Wiederkehrend — IPs mit Click-ID-Hits an 3 oder mehr verschiedenen Tagen

Klick auf ein Subnetz zeigt die einzelnen IPs mit Click-ID-Details und zugehörigen Kampagnen. Der Nachweis-Export erzeugt eine JSON-Datei mit allen Details — geeignet als Grundlage für Gutschrift-Anfragen bei Ad-Plattformen.

Statuscodes

Im Menü unter Technik. HTTP-Statuscodes mit Anzahl der Hits, sortierbar.

Ressourcentypen

Im Menü unter Technik. Verteilung der Requests nach Dateityp (Seite, Bild, CSS, JavaScript, Schrift, Feed/Daten, Dokument, Sonstiges) als sortierbare Tabelle.

Browser & Systeme

Im Menü unter Technik. Zwei Tabellen: erkannte Browser (Chrome, Firefox, Safari etc.) und Betriebssysteme (Windows, macOS, Linux, Android, iOS etc.). Nur Nicht-Bot-Einträge werden berücksichtigt.

IP-Auffälligkeiten

Im Menü unter Technik. Identifiziert IPs mit ungewöhnlichem Verhalten anhand statistischer Schwellenwerte (Median + 2σ):

  • Rate — ungewöhnlich hohe Request-Frequenz (gemessen als Spitze pro Stunde)
  • Fehler — überdurchschnittlich hoher Anteil an 4xx/5xx-Antworten
  • Scan — auffällig viele verschiedene Pfade aufgerufen (Scanning-Verhalten)

Klick auf eine IP zeigt Zeitraum, Status-Verteilung und ggf. den erkannten Bot-Namen. Der IP-Listen-Export erzeugt eine Plain-Text-Datei (eine IP pro Zeile) — direkt einsetzbar für Firewall-Regeln oder Rate-Limiting.

Bots & Crawler

Erkennung und Kategorisierung von Bot-Traffic:

  • Zusammenfassung: Bot-Hits, Browser-Hits, Bot-Anteil, Bot-Traffic (Bytes)
  • Kategorie-Vergleich — Tabelle mit 429-Rate, 408-Rate, Fehlerrate und Response-Time-Perzentilen pro Bot-Kategorie (zeigt z.B., ob KI-Crawler durch Rate Limiting oder langsame Antworten benachteiligt werden)
  • Bot-Liste gruppiert nach Kategorie (Suchmaschinen, KI-Crawler, SEO-Tools, Social Media, Feed-Reader, Monitoring, Headless/Automation, Sonstige)
  • Klick auf einen Bot zeigt Detailansicht mit Top-10-Seiten, IPs, Traffic, Statuscode-Verteilung und Response-Time-Metriken

Ca. 70 bekannte Bots werden namentlich erkannt (Googlebot, GPTBot, AhrefsBot etc.). Unbekannte Bots werden anhand generischer Muster identifiziert. Zusätzlich können über die heuristische Bot-Erkennung (siehe Tools → Bot-Erkennung) Besucher mit verdächtigem Verhalten als Bots der Kategorie Heuristisch reklassifiziert werden.

Bot-Zeitverhalten

7×24-Stunden-Heatmap der Bot-Aktivität (Wochentag × Uhrzeit). Per Dropdown auf einzelne Bots filterbar.

Tools

Im Menü unter Tools finden sich Hilfsfunktionen:

  • Bot-Erkennung — Heuristische Erkennung von Bots, die sich anhand ihres User-Agents nicht als solche zu erkennen geben. Fünf konfigurierbare Heuristiken:
    • Unbekannter Browser — User-Agent kann keinem bekannten Browser zugeordnet werden
    • Hammering — ein Besucher ruft denselben Pfad >N× pro Tag ab (Schwellwert einstellbar, Standard: 20)
    • Speed-Bot — Session mit >N Seitenaufrufen in <M Sekunden (Standards: 5 PV / 10 Sek.)
    • Safari Prefetch — Safari iOS mit Direct-Channel und nur einem Seitenaufruf (Bounce)
    • Honeypot — Besuch eines definierten Honeypot-Pfads disqualifiziert den gesamten Besucher (Teilstring-Match)
    • Erkannte Besucher werden der Bot-Kategorie Heuristisch zugeordnet und erscheinen in allen Bot-Auswertungen. Die Erkennung ist reversibel (Zurücksetzen-Button). Die Analyse läuft automatisch nach dem Import, wenn Regeln aktiv sind, und kann jederzeit manuell gestartet werden.
  • Bereinigung — Verwaltung von Filtermustern, um unerwünschte Einträge (z.B. Health-Checks, Monitoring) auszuschließen. Muster können per Substring, Prefix oder Regex auf Pfade matchen. Zwei Anwendungsarten:
    • Filtern — aktive Muster werden wie ein Filter angewandt (reversibel, erscheint als Chip in der Filterleiste)
    • Datenbank bereinigen — matchende Einträge werden unwiderruflich gelöscht
    • Beim Import neuer Logdateien kann im Import-Umfang-Dialog die Anwendung aktiver Muster aktiviert werden (Treffer werden nicht importiert)
    • Live-Vorschau zeigt die Anzahl betroffener Einträge
  • Conversions — Definition von bis zu 3 Conversion-Goals. Pro Goal werden Name, Pfad-Muster und Zählmodus (alle Aufrufe oder einmalig pro Nutzer) festgelegt. Das Attribution-Modell bestimmt, welchem Marketing-Channel eine Conversion zugeordnet wird:
    • Last-Touch (Standard) — letzter Kontaktpunkt vor der Conversion
    • First-Touch — erster Kontaktpunkt
    • Die Ergebnisse erscheinen als zusätzliche Spalten in der Quellen-View (Referrer- und Kampagnen-Tabelle) sowie als eigener Conversions-Report mit Zeitreihen-Diagramm
    • Live-Vorschau zeigt die Anzahl gefundener Conversions
  • Parameter — Alle Query-Parameter aus den URLs, sortiert nach Häufigkeit. Klick auf einen Parameter zeigt die häufigsten Werte und die Top-Pfade, auf denen er vorkommt.
  • Crawl-Budget — Analyse des Bot-Ressourcenverbrauchs aus SEO/GEO-Perspektive:
    • KPI-Cards: Bot-Hits, Bot-Bandwidth, Crawl-Waste (Bot-Hits auf 4xx/5xx), Nicht gecrawlt (Seiten mit User-Traffic aber ohne Bot-Hits)
    • Bandwidth-Verteilung nach Quelle (Bot/Browser/Angriff) und nach Ressourcentyp als Segmentbalken mit Legende
    • Crawl-Waste-Tabelle mit Pfad, Bot-Hits, Statuscodes und Bot-Namen (Top 3, aufklappbar)
    • Nicht-gecrawlt-Tabelle zeigt nur Seiten mit 2xx-Responses (filtert Angriffs-URLs automatisch aus)
    • Per Bot-Kategorie-Filter auf Suchmaschinen oder KI-Crawler eingrenzbar
  • Hotlinking — Erkennung externer Domains, die Bilder, Schriften oder andere statische Ressourcen direkt einbinden. Zeigt Domain, Hits und Bytes mit aufklappbarer Pfad-Detailansicht pro Domain.

Suche

Alle Listen-Views bieten ein Suchfeld mit drei Modi:

Modus Beschreibung
Enthält Substring-Suche (Standard)
Beginnt mit Sucht ab Anfang des Werts
Regex Regulärer Ausdruck

Der NICHT-Toggle kehrt das Suchergebnis um (Ausschlussfilter). Der Suchmodus gilt global für alle Views.

Filter

Über den Filter-Button im Header einblendbar. Verfügbare Kriterien:

Filter Optionen
Datum von / bis Zeitraum eingrenzen
Status 2xx, 3xx, 4xx, 5xx
Typ Seite, Bild, CSS, JavaScript, Schrift, Feed/Daten, Dokument, Sonstiges
Methode GET, POST, HEAD, PUT, DELETE, OPTIONS
Gerät Desktop, Smartphone, Tablet
Browser Chrome, Safari, Firefox, Edge, Opera, Samsung Internet, Vivaldi, Brave, UC Browser, Andere
OS Windows, macOS, Linux, Android, iOS, Chrome OS, Andere
Bots Nur Browser, Nur Bots, Nur Angriffe
Bot-Kategorie Suchmaschinen, KI-Crawler (↳ Training, ↳ Grounding), SEO-Tools, Social Media, Feed-Reader, Monitoring, Automation, Sonstige, Heuristisch (↳ Unbekannter Browser, ↳ Hammering, ↳ Speed-Bot, ↳ Safari Prefetch, ↳ Honeypot)

Filter wirken auf alle Ansichten gleichzeitig. Aktive Filter werden als Tags unterhalb der Kopfzeile angezeigt und können einzeln entfernt werden. Aktive Bereinigungsmuster erscheinen ebenfalls als Filter-Tag.

Export

Über den Export-Button. Exportiert die aktuell gefilterten Daten:

  • JSON — Zusammenfassung mit Metadaten, 100 Beispieleinträgen und allen Aggregationen
  • TSV — alle gefilterten Rohdaten als Tab-getrennte Tabelle (importierbar in Excel, LibreOffice etc.)

Session-Modell

Eine Session ist eine zusammenhängende Aktivitätsphase eines Besuchers. Regeln:

  • Zuordnung über IP-Adresse + User-Agent (kein Cookie-Tracking)
  • Eine neue Session beginnt nach 30 Minuten Inaktivität
  • Ein Nutzer mit mehreren Browsern oder IP-Adressen wird als mehrere Nutzer gezählt

Dark Mode

Toggle in der Sidebar unten. Standardmäßig wird die Systemeinstellung übernommen. Die Wahl wird im Browser gespeichert.

Technisches

  • Einzelne HTML-Datei, kein Framework, kein Backend
  • Reine Browser-App (Vanilla JS + CSS)
  • Daten bleiben lokal im Browser (IndexedDB + localStorage)
  • Keine externen Netzwerkaufrufe
  • Gzip-Dateien werden nativ via DecompressionStream entpackt
  • Fortschrittsanzeige beim Laden und Verarbeiten großer Dateien (zentral im Viewport, mit Backdrop)
  • Importlimit dynamisch basierend auf verfügbarem Browser-Speicher (Minimum: 2.000.000 Zeilen), Zeilen > 10.000 Zeichen werden übersprungen
  • Responsive Layout mit mobiler Sidebar (Hamburger-Menü)

Entwicklung

Die Quelldateien liegen in src/. Details zum Build-Prozess und zur Projektstruktur stehen in der Entwickler-Dokumentation.

<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex,nofollow"><link rel="shortcut icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect x='2' y='1' width='12' height='14' rx='1' fill='%230d6fd9'/><rect x='4' y='3' width='8' height='3' fill='%23fff'/><rect x='4' y='8' width='2' height='5' fill='%23fff' opacity='0.8'/><rect x='7' y='10' width='2' height='3' fill='%23fff' opacity='0.8'/><rect x='10' y='7' width='2' height='6' fill='%23fff' opacity='0.8'/></svg>"><title>Logalytrix</title><style>
:root {--bg-sidebar: #1a1d21;--text-sidebar: #d1d2d3;--text-sidebar-hover: #ffffff;--bg-main: #f4f4f4;--card-bg: #ffffff;--primary: #d9430d;--primary-hover: #ff652a;--border: #e0e0e0;--text-main: #1d1c1d;--text-muted: #616061;--text-secondary: #666;--text-hint: #888;--shadow: 0 1px 3px rgba(0,0,0,0.12);--radius: 8px;--status-ok: #007a5a;--status-warn: #d9430d;--status-info: #1164A3;--border-light: #ddd;--border-input: #ccc;--bg-subtle: #f8f9fa;--bg-hover: #fafafa;--bg-hover-dark: #f0f0f0;--bg-btn: #eee;--bg-btn-hover: #ddd;--bg-secondary: #e4e4e4;--danger: #e74c3c;}:root.dark {--bg-main: #1a1d21;--card-bg: #2a2d31;--text-main: #e0e0e0;--text-muted: #999;--border: #444;--shadow: 0 1px 3px rgba(0,0,0,0.3);--text-secondary: #999;--text-hint: #999;--border-light: #444;--border-input: #555;--bg-subtle: #2a2d31;--bg-hover: #333;--bg-hover-dark: #333;--bg-btn: #444;--bg-btn-hover: #555;--bg-secondary: #333;--danger: #e74c3c;}* { box-sizing: border-box; }body {margin: 0;font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;background: var(--bg-main);color: var(--text-main);height: 100vh;overflow: hidden;display: flex;}#sidebar {width: 260px;background: var(--bg-sidebar);color: var(--text-sidebar);display: flex;flex-direction: column;flex-shrink: 0;overflow-y: auto;}#sidebar .logo-img {display: block;width: 72px;height: 72px;margin: 1em auto 1em;}#sidebar h1 {font-size: 1.2rem;font-weight: 700;padding: 20px;color: #fff;margin: 0;letter-spacing: 0.5px;border-top: 1px solid var(--text-secondary);}#sidebar h1 small {font-size: 0.75rem;text-transform: uppercase;letter-spacing: 2px;opacity: 0.7;}#sidebar .sidebar-bottom {padding: 12px 20px;border-top: 1px solid #444;font-size: 0.75rem;color: var(--text-sidebar);display: flex;align-items: center;justify-content: space-between;}#sidebar .sidebar-bottom .version {opacity: 0.5;}.nav-item {padding: 10px 20px;cursor: pointer;display: flex;align-items: center;gap: 12px;font-size: 0.95rem;transition: background 0.2s, color 0.2s;color: var(--text-sidebar);border: none;background: none;width: 100%;text-align: left;font-family: inherit;}.nav-item:hover {background: rgba(255,255,255,0.1);color: var(--text-sidebar-hover);}.nav-item.active {background: var(--status-info);color: #fff;}.nav-item svg {fill: currentColor;width: 18px;height: 18px;opacity: 0.8;flex-shrink: 0;}.nav-separator {height: 1px;background: #444;margin: 10px 20px;}.nav-group {margin: 0;}.nav-group-header {padding: 10px 20px;cursor: pointer;display: flex;align-items: center;gap: 8px;font-size: 0.75rem;text-transform: uppercase;letter-spacing: 0.5px;color: var(--text-sidebar);opacity: 0.7;font-weight: 700;user-select: none;transition: opacity 0.2s;}.nav-group-header:hover {opacity: 1;}.nav-group-header .collapse-arrow {font-size: 0.55rem;transition: transform 0.15s ease;display: inline-block;}.nav-group-header .collapse-arrow.open {transform: rotate(90deg);}.nav-group-items.collapsed {display: none;}.nav-sub-item {padding-left: 36px !important;font-size: 0.88rem;}.nav-group.has-active-child > .nav-group-header {opacity: 1;}#main {flex-grow: 1;overflow-y: auto;padding: 20px 40px;position: relative;}.section-header {display: flex;justify-content: space-between;align-items: center;flex-wrap: wrap;gap: 10px;margin-bottom: 20px;}.section-header h2 {font-size: 1.5rem;font-weight: 600;margin: 0;}.section-header .header-actions {display: flex;gap: 8px;align-items: center;}.date-range-preset {padding: 6px 8px;border-radius: 4px;border: 1px solid var(--border);background: var(--bg);color: var(--text);font-size: 13px;cursor: pointer;}.settings-grid {display: grid;grid-template-columns: 1fr 1fr;gap: 16px;}@media (max-width: 900px) {.settings-grid { grid-template-columns: 1fr; }}.settings-table {width: 100%;border-collapse: collapse;}.settings-table td {padding: 4px 8px 4px 0;border-bottom: 1px solid var(--border);font-size: 13px;}.settings-table td:first-child {color: var(--text-muted);white-space: nowrap;}.cron-url-block {margin-top: 8px;display: flex;flex-direction: column;gap: 6px;}.cron-url-block label {font-size: 12px;color: var(--text-muted);}.cron-url {display: block;padding: 6px 8px;background: var(--bg-alt, var(--bg));border: 1px solid var(--border);border-radius: 4px;font-size: 12px;word-break: break-all;cursor: pointer;}.summary-badge {font-size: 0.85rem;color: var(--text-muted);margin-bottom: 15px;}.truncation-warning {color: #e67e22;font-weight: 600;}.view {display: none;}.view.active {display: block;animation: fadeIn 0.2s ease-out;}#view-bot-time {overflow-x: hidden;}@keyframes fadeIn {from { opacity: 0; transform: translateY(5px); }to { opacity: 1; transform: translateY(0); }}.card {background: var(--card-bg);border-radius: var(--radius);box-shadow: var(--shadow);padding: 20px;margin-bottom: 15px;border: 1px solid var(--border);}button {border: none;background: var(--bg-btn);color: var(--text-main);padding: 8px 12px;border-radius: 4px;cursor: pointer;font-weight: 600;font-size: 0.85rem;transition: all 0.2s;display: inline-flex;align-items: center;gap: 6px;font-family: inherit;}button:hover {background: var(--bg-btn-hover);}button.primary {background: var(--primary);color: #fff;}button.primary:hover {background: var(--primary-hover);}button.secondary {background: var(--bg-secondary);color: var(--text-main);border: 1px solid var(--border-input);}button.secondary:hover {background: var(--bg-btn-hover);}input[type="text"],input[type="search"],input[type="date"],select,textarea {padding: 8px 12px;border: 1px solid var(--border-input);border-radius: 4px;font-size: 0.9rem;font-family: inherit;background: var(--card-bg);color: var(--text-main);}input[type="text"]:focus,input[type="search"]:focus,input[type="date"]:focus,select:focus,textarea:focus {border-color: var(--primary);outline: none;}.card input[type="text"],.card select {background: var(--bg-main);}:root.dark input[type="text"],:root.dark input[type="search"],:root.dark input[type="date"],:root.dark select,:root.dark textarea {background: var(--bg-hover);color: var(--text-main);border-color: var(--border-input);}.filter-bar {background: var(--card-bg);border-radius: var(--radius);box-shadow: var(--shadow);border: 1px solid var(--border);padding: 15px 20px;margin-bottom: 15px;display: flex;flex-wrap: wrap;gap: 15px;align-items: flex-end;}.filter-bar.collapsed {display: none;}.filter-group {display: flex;flex-direction: column;gap: 4px;}.filter-group label {font-size: 0.75rem;text-transform: uppercase;color: var(--text-hint);font-weight: 600;}.filter-group input[type="date"],.filter-group select {padding: 6px 10px;}.filter-actions {display: flex;gap: 8px;align-items: flex-end;}.table-container {overflow-x: auto;}table {width: 100%;border-collapse: collapse;font-size: 0.9rem;}th, td {text-align: left;padding: 12px;border-bottom: 1px solid var(--bg-btn);}th {background: var(--bg-subtle);cursor: pointer;color: var(--text-muted);font-size: 0.75rem;text-transform: uppercase;user-select: none;position: sticky;top: 0;z-index: 1;}th:hover {color: var(--primary);background: var(--bg-hover-dark);}tr:hover {background: var(--bg-hover);}.stats-grid {display: grid;grid-template-columns: repeat(3, 1fr);gap: 10px;margin-bottom: 15px;}.metric-card {border-radius: 4px;border: 1px solid var(--border);background: var(--bg-subtle);padding: 12px;text-align: center;}.metric-card h4 {margin: 0;font-size: 0.75rem;text-transform: uppercase;color: var(--text-hint);display: flex;align-items: center;justify-content: center;gap: 4px;}.metric-card .value {font-weight: bold;font-size: 1.5rem;color: var(--text-main);margin-top: 4px;}.hint-icon {display: inline-flex;align-items: center;justify-content: center;width: 14px;height: 14px;border-radius: 50%;border: 1px solid var(--border-input);font-size: 0.6rem;font-weight: 700;color: var(--text-hint);cursor: help;position: relative;text-transform: none;letter-spacing: 0;flex-shrink: 0;}.hint-icon:hover {border-color: var(--primary);color: var(--primary);}.hint-icon .hint-text {display: none;position: absolute;bottom: calc(100% + 6px);left: 50%;transform: translateX(-50%);background: #333;color: #fff;padding: 8px 12px;border-radius: 4px;font-size: 0.78rem;font-weight: 400;line-height: 1.4;white-space: normal;width: 220px;text-align: left;z-index: 100;box-shadow: 0 2px 8px rgba(0,0,0,0.2);pointer-events: none;}.hint-icon .hint-text::after {content: '';position: absolute;top: 100%;left: 50%;transform: translateX(-50%);border: 5px solid transparent;border-top-color: #333;}.hint-icon:hover .hint-text {display: block;}.pagination {margin-top: 10px;margin-bottom: 15px;display: flex;justify-content: flex-end;gap: 8px;align-items: center;font-size: 0.85rem;color: var(--text-muted);}.pagination button {padding: 6px 12px;font-size: 0.85rem;}.pagination button:disabled {opacity: 0.4;cursor: default;}#progress-backdrop {position: fixed;inset: 0;background: rgba(0,0,0,0.15);z-index: 1999;opacity: 0;pointer-events: none;transition: opacity 0.3s;}#progress-backdrop.visible {opacity: 1;pointer-events: auto;}:root.dark #progress-backdrop {background: rgba(0,0,0,0.4);}#toast {position: fixed;bottom: 30px;left: 50%;transform: translateX(-50%);background: #333;color: #fff;padding: 10px 20px;border-radius: 4px;font-size: 0.9rem;opacity: 0;pointer-events: none;transition: opacity 0.3s;z-index: 2000;}#toast.visible {opacity: 1;}#toast.progress {position: fixed;top: 50%;left: 50%;transform: translate(-50%, -50%);bottom: auto;font-size: 1.1rem;padding: 16px 32px;border-radius: 8px;box-shadow: 0 4px 12px rgba(0,0,0,0.3);min-width: 320px;text-align: center;}.progress-bar-track {height: 8px;background: rgba(255,255,255,0.2);border-radius: 4px;margin: 10px 0 6px;overflow: hidden;}.progress-bar-fill {height: 100%;background: var(--primary, #d9430d);border-radius: 4px;transition: width 0.2s ease-out;min-width: 0;}.progress-stats {font-size: 0.8rem;opacity: 0.7;}.progress-dots {display: inline-block;width: 1.5em;}.progress-dots::after {content: '\2026';animation: ellipsis 1.5s infinite;display: inline-block;width: 0;overflow: hidden;vertical-align: bottom;}@keyframes ellipsis {0% { width: 0; }50% { width: 1.2em; }100% { width: 0; }}.status-text {font-size: 0.8rem;color: var(--text-muted);}#data-status {font-size: 0.75rem;color: var(--text-sidebar);opacity: 0.8;}.empty-hint {padding: 20px;text-align: center;color: var(--text-muted);font-size: 0.9rem;}.hint {margin-top: 10px;font-size: 0.85rem;background: var(--bg-subtle);border-radius: var(--radius);border: 1px solid var(--border);padding: 12px;color: var(--text-muted);white-space: pre-wrap;font-family: 'Courier New', monospace;}.dark-mode-toggle {padding: 0;cursor: pointer;font-size: 1.1rem;color: var(--text-sidebar);background: none;border: none;line-height: 1;opacity: 0.7;transition: opacity 0.2s;}.dark-mode-toggle:hover {opacity: 1;}#menu-toggle {display: none;margin-bottom: 15px;font-size: 1.8rem;background: none;border: none;padding: 0;cursor: pointer;color: var(--text-main);}#mobile-menu-overlay {display: none;position: fixed;top: 0;left: 0;width: 100%;height: 100%;background: rgba(0,0,0,0.5);z-index: 1500;}#mobile-menu-overlay.visible {display: block;}@media (max-width: 768px) {#sidebar {position: fixed;top: 0;left: 0;height: 100%;z-index: 2000;transition: transform 0.3s ease;transform: translateX(-100%);box-shadow: 2px 0 5px rgba(0,0,0,0.2);}#sidebar.open {transform: translateX(0);}#menu-toggle {display: block;}#main {padding: 15px;}.stats-grid {grid-template-columns: 1fr 1fr;}.filter-bar {flex-direction: column;align-items: stretch;}.section-header {flex-direction: column;align-items: flex-start;}}@media (max-width: 480px) {.stats-grid {grid-template-columns: 1fr;}}th .sort-arrow {margin-left: 4px;font-size: 0.7rem;}button.filter-active {background: var(--primary);color: #fff;border-color: var(--primary);}button.filter-active:hover {background: var(--primary-hover);}.active-filters-info {font-size: 0.8rem;color: var(--text-muted);margin-bottom: 12px;display: flex;align-items: center;gap: 6px;flex-wrap: wrap;}.filter-tag {display: inline-flex;align-items: center;gap: 4px;background: var(--bg-subtle);border: 1px solid var(--border);border-radius: 3px;padding: 2px 8px;font-size: 0.75rem;color: var(--text-main);}.filter-tag .filter-tag-label {color: var(--text-hint);font-weight: 600;text-transform: uppercase;font-size: 0.65rem;}.filter-tag-remove {cursor: pointer;margin-left: 4px;font-size: 0.85rem;line-height: 1;color: var(--text-hint);font-weight: 700;transition: color 0.15s;}.filter-tag-remove:hover {color: var(--primary);}.dashboard-section {margin-bottom: 25px;}.dashboard-section h3 {font-size: 1rem;margin: 0 0 10px 0;color: var(--text-muted);font-weight: 600;}.chart-bar-container {display: flex;align-items: flex-end;gap: 2px;height: 120px;padding: 0 4px;}.chart-bar-wrapper {flex: 1;min-width: 0;display: flex;flex-direction: column;align-items: center;height: 100%;justify-content: flex-end;position: relative;cursor: default;}.chart-bar-wrapper::after {content: attr(data-tooltip);position: absolute;top: 0;left: 50%;transform: translateX(-50%);background: #333;color: #fff;padding: 4px 8px;border-radius: 4px;font-size: 0.75rem;white-space: nowrap;z-index: 10;opacity: 0;pointer-events: none;transition: opacity 0.2s;}.chart-bar-wrapper:hover::after {opacity: 1;}.chart-bar {width: 100%;background: linear-gradient(to top, var(--primary), var(--primary-hover));border-radius: 2px 2px 0 0;min-height: 0;transition: opacity 0.2s;}.chart-bar-secondary {background: var(--text-hint);border-radius: 2px 2px 0 0;}.chart-bar-wrapper:hover .chart-bar {opacity: 0.8;}tr.collapsed { display: none; }.chart-drilldown { cursor: pointer; }.chart-bar-daybreak { border-left: 1px solid var(--border); }.chart-bar-label {font-size: 0.6rem;color: var(--text-hint);margin-top: 4px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;max-width: 100%;text-align: center;}.status-bar-track {display: flex;border-radius: 4px;overflow: hidden;height: 24px;background: var(--bg-subtle);border: 1px solid var(--border);}.status-bar-segment {height: 100%;position: relative;cursor: default;transition: opacity 0.2s;display: flex;align-items: center;justify-content: center;font-size: 0.7rem;font-weight: 600;color: #fff;min-width: 0;overflow: hidden;}.status-bar-segment.seg-light {color: #1a1d21;}.status-bar-segment:hover {opacity: 0.85;}.status-bar-legend {display: flex;gap: 15px;flex-wrap: wrap;margin-top: 8px;font-size: 0.8rem;}.status-bar-legend-item {display: flex;align-items: center;gap: 5px;}.status-dot {width: 10px;height: 10px;border-radius: 2px;flex-shrink: 0;}.top-pages-mini {list-style: none;padding: 0;margin: 0;}.top-pages-mini li {display: flex;justify-content: space-between;align-items: center;padding: 8px 0;border-bottom: 1px solid var(--bg-btn);font-size: 0.9rem;}.top-pages-mini li:last-child {border-bottom: none;}.top-pages-mini .page-path {color: var(--text-main);overflow: hidden;text-overflow: ellipsis;white-space: nowrap;flex: 1;margin-right: 12px;}.top-pages-mini .page-count {font-weight: 600;color: var(--primary);flex-shrink: 0;}.bot-overview-grid {display: grid;grid-template-columns: 1fr 1fr;gap: 15px;margin-bottom: 15px;}.bot-list {list-style: none;padding: 0;margin: 0;}.bot-list li {display: flex;justify-content: space-between;align-items: center;padding: 8px 12px;border-bottom: 1px solid var(--bg-btn);font-size: 0.9rem;cursor: pointer;transition: background 0.15s;border-radius: 4px;}.bot-list li:hover {background: var(--bg-hover);}.bot-list li.selected {background: var(--status-info);color: #fff;}.bot-list li.selected .bot-hits {color: rgba(255,255,255,0.8);}.bot-name {font-weight: 500;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;flex: 1;margin-right: 8px;}.bot-hits {font-size: 0.8rem;color: var(--text-hint);flex-shrink: 0;}.ai-purpose-badge {font-size: 0.65rem;padding: 1px 5px;border-radius: 3px;font-weight: 400;vertical-align: middle;margin-left: 4px;}.ai-purpose-badge.training { background: #f0e8c0; color: #5c4b00; }.ai-purpose-badge.grounding { background: #d0d8e8; color: #1a3a6e; }.dark .ai-purpose-badge.training { background: #3d3510; color: #e8d44d; }.dark .ai-purpose-badge.grounding { background: #1a2540; color: #8eb4e0; }.bot-category-label {font-size: 0.7rem;text-transform: uppercase;letter-spacing: 0.5px;color: var(--text-hint);font-weight: 700;margin: 12px 0 6px 0;padding-bottom: 4px;border-bottom: 1px solid var(--border);cursor: pointer;user-select: none;display: flex;align-items: center;gap: 4px;}.bot-category-label:hover {color: var(--text-main);}.collapse-arrow {font-size: 0.55rem;transition: transform 0.15s ease;display: inline-block;}.collapse-arrow.open {transform: rotate(90deg);}.bot-list.collapsed {display: none;}.bot-category-label:first-child {margin-top: 0;}.bot-detail-section {margin-top: 15px;}.bot-detail-section h3 {font-size: 1rem;font-weight: 600;margin: 0 0 10px 0;}@media (max-width: 768px) {.bot-overview-grid {grid-template-columns: 1fr;}}.params-grid {display: grid;grid-template-columns: minmax(180px, 1fr) 2fr;gap: 15px;margin-bottom: 15px;}.params-grid > .card:first-child {max-height: 70vh;overflow-y: auto;}@media (max-width: 768px) {.params-grid {grid-template-columns: 1fr;}.params-grid > .card:first-child {max-height: 40vh;}}.search-bar {display: flex;align-items: center;margin-bottom: 10px;}.search-bar input[type="search"] {width: 100%;max-width: 400px;transition: border 0.2s;}.search-bar select {max-width: 400px;transition: border 0.2s;}.search-mode-select {padding: 8px 10px;font-size: 0.85rem;margin-right: 4px;}.search-negate-toggle {padding: 6px 8px;font-size: 0.7rem;font-weight: 700;letter-spacing: 0.5px;border: 1px solid var(--border-input);border-radius: 4px;background: var(--card-bg);color: var(--text-hint);cursor: pointer;margin-right: 8px;transition: all 0.15s;flex-shrink: 0;}.search-negate-toggle:hover {border-color: var(--primary);color: var(--primary);}.search-negate-toggle.active {background: var(--primary);color: #fff;border-color: var(--primary);}.result-count {font-size: 0.8rem;color: var(--text-muted);margin-bottom: 6px;text-align: right;}.directory-depth-select {padding: 8px 10px;font-size: 0.85rem;margin-left: auto;}.modal-overlay {position: fixed;top: 0;left: 0;width: 100%;height: 100%;background: rgba(0,0,0,0.5);z-index: 3000;display: none;justify-content: center;align-items: flex-start;padding-top: 100px;}.modal-overlay.visible {display: flex;}.modal {background: var(--card-bg);padding: 25px;border-radius: var(--radius);width: 480px;max-width: 90%;box-shadow: 0 10px 25px rgba(0,0,0,0.2);color: var(--text-main);}.modal h3 {margin: 0 0 20px 0;font-size: 1.2rem;}.form-group {margin-bottom: 15px;}.form-label {display: block;font-size: 0.85rem;color: var(--text-secondary);margin-bottom: 5px;}.form-group input[type="text"] {width: 100%;}.export-format-options {display: flex;flex-direction: column;gap: 8px;}.export-format-options label,.export-json-options label {display: flex;align-items: center;gap: 6px;font-size: 0.9rem;cursor: pointer;}.modal-actions {margin-top: 20px;display: flex;justify-content: flex-end;gap: 10px;}.heatmap-grid {display: grid;grid-template-columns: 40px repeat(24, 1fr);gap: 2px;margin-bottom: 12px;}.heatmap-label,.heatmap-row-label,.heatmap-col-label {display: flex;align-items: center;justify-content: center;font-size: 0.7rem;color: var(--text-hint);}.heatmap-row-label {justify-content: flex-end;padding-right: 6px;font-weight: 600;}.heatmap-col-label {font-size: 0.6rem;}.heatmap-cell {aspect-ratio: 1;min-height: 18px;border-radius: 3px;position: relative;cursor: default;border: 1px solid var(--border);}.heatmap-cell:hover {outline: 2px solid var(--primary);z-index: 1;}.heatmap-cell::after {content: attr(data-tooltip);position: absolute;bottom: calc(100% + 6px);left: 50%;transform: translateX(-50%);background: #333;color: #fff;padding: 4px 8px;border-radius: 4px;font-size: 0.72rem;white-space: nowrap;z-index: 10;opacity: 0;pointer-events: none;transition: opacity 0.2s;}.heatmap-cell:hover::after {opacity: 1;}.heatmap-legend {display: flex;align-items: center;gap: 4px;font-size: 0.75rem;color: var(--text-hint);justify-content: flex-end;margin-top: 4px;}.heatmap-legend-block {width: 16px;height: 16px;border-radius: 3px;border: 1px solid var(--border);}@media (max-width: 768px) {.heatmap-grid {grid-template-columns: 30px repeat(24, 1fr);gap: 1px;}.heatmap-cell {min-height: 14px;}}tr.attack-row td { background-color: rgba(192, 57, 43, 0.07); }:root.dark tr.attack-row td { background-color: rgba(192, 57, 43, 0.15); }tr.bot-row td { background-color: rgba(52, 152, 219, 0.07); }:root.dark tr.bot-row td { background-color: rgba(52, 152, 219, 0.15); }.table-legend {display: flex;gap: 12px;font-size: 0.75rem;color: var(--text-hint);margin-bottom: 6px;}.legend-swatch {display: inline-block;width: 12px;height: 12px;border-radius: 2px;vertical-align: middle;margin-right: 4px;}.legend-swatch.attack { background: rgba(192, 57, 43, 0.25); }.legend-swatch.bot { background: rgba(52, 152, 219, 0.25); }.table-legend-count {margin-left: auto;color: var(--text-muted);font-size: 0.8rem;}.cleanup-header {display: flex;justify-content: space-between;align-items: center;margin-bottom: 1rem;flex-wrap: wrap;gap: 0.5rem;}.cleanup-add-form {display: flex;gap: 0.5rem;margin: 1rem 0;flex-wrap: wrap;}.cleanup-add-form input {flex: 1;min-width: 200px;}.cleanup-actions { margin-top: 1rem; display: flex; gap: 0.5rem; align-items: center; }.cleanup-preview {margin-top: 0.75rem;font-size: 0.9rem;color: var(--text-secondary);}button.danger { color: var(--danger, #e74c3c); border-color: var(--danger, #e74c3c); }button.danger:hover { background: var(--danger, #e74c3c); color: #fff; }.toggle-label {display: flex;align-items: center;gap: 0.5rem;cursor: pointer;white-space: nowrap;}.view-description {color: var(--text-secondary);margin: 0 0 0.5rem;font-size: 0.9rem;}.conversions-settings {margin-bottom: 1rem;}.goal-slot {border: 1px solid var(--border);border-radius: 8px;padding: 1rem;margin-bottom: 1rem;}.goal-slot-header {display: flex;justify-content: space-between;align-items: center;margin-bottom: 0.75rem;}.goal-slot-header h4 { margin: 0; }.goal-form {display: grid;grid-template-columns: 1fr 1fr auto;gap: 0.5rem;align-items: end;}.goal-form label { font-size: 0.85rem; color: var(--text-secondary); }.goal-form .form-field { display: flex; flex-direction: column; gap: 0.25rem; }.goal-form input[type="text"],.goal-form select {transition: border 0.2s;}.goal-count-mode {display: flex;gap: 1rem;margin-top: 0.5rem;font-size: 0.9rem;}.goal-preview {margin-top: 0.5rem;font-size: 0.85rem;color: var(--text-secondary);}button.small { padding: 0.25rem 0.5rem; font-size: 0.85rem; }.import-scope-hint {font-size: 0.85rem;color: var(--text-secondary);margin: 0 0 1rem;line-height: 1.4;}.import-scope-options {display: flex;flex-direction: column;gap: 0.5rem;margin-bottom: 0.5rem;}.import-scope-group {display: flex;flex-direction: column;gap: 0.4rem;}.import-scope-group-label {font-size: 0.75rem;font-weight: 600;color: var(--text-secondary);text-transform: uppercase;letter-spacing: 0.05em;margin-top: 0.3rem;}.import-scope-option {display: flex;align-items: center;gap: 0.5rem;font-size: 0.9rem;cursor: pointer;}.import-scope-option input[type="checkbox"]:disabled {opacity: 0.4;cursor: default;}.import-scope-option.disabled {opacity: 0.5;cursor: default;}.flag-tag {display: inline-block;padding: 1px 6px;border-radius: 3px;font-size: 0.7rem;font-weight: 600;margin-right: 3px;}.flag-rate { background: #B8860B; color: #fff; }.flag-fehler { background: #C0392B; color: #fff; }.flag-scan { background: #505874; color: #fff; }.flag-cluster { background: #B8860B; color: #fff; }.flag-ad-dominiert { background: #C0392B; color: #fff; }.flag-wiederkehrend { background: #505874; color: #fff; }.histogram { margin: 0.5rem 0 1.5rem; }.histogram-row { display: flex; align-items: center; gap: 0.5rem; margin: 0.2rem 0; }.histogram-label { width: 5rem; text-align: right; font-size: 0.85rem; color: var(--text-muted); flex-shrink: 0; }.histogram-bar-container { flex: 1; height: 1.2rem; background: var(--border); border-radius: 3px; overflow: hidden; }.histogram-bar { height: 100%; background: #4a90d9; border-radius: 3px; min-width: 2px; }.histogram-value { width: 4rem; font-size: 0.85rem; color: var(--text-secondary); flex-shrink: 0; }.journey-cell { max-width: 40rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.conversion-step { color: var(--status-ok); font-weight: 600; }.journey-filters { display: flex; gap: 1rem; margin-bottom: 0.5rem; align-items: center; }.journey-filters label { font-size: 0.85rem; color: var(--text-muted); display: flex; align-items: center; gap: 0.3rem; }.journey-filters input[type="number"] { background: var(--card-bg); color: var(--text-main); border: 1px solid var(--border-input); border-radius: 4px; padding: 0.2rem 0.3rem; }.nav-overview-flow { display: flex; gap: 16px; align-items: flex-start; margin-top: 12px; }.nav-overview-col { flex: 1; min-width: 0; }.nav-overview-center { flex: 0 0 200px; display: flex; flex-direction: column; align-items: center; gap: 10px; }.nav-overview-col-title { font-size: 0.85rem; color: var(--text-hint); margin: 0 0 8px 0; text-transform: uppercase; letter-spacing: 0.5px; }.nav-overview-current { text-align: center; padding: 16px; border: 2px solid var(--primary); border-radius: 6px; background: var(--bg-subtle); width: 100%; box-sizing: border-box; }.nav-overview-pattern { font-weight: bold; font-size: 1rem; color: var(--primary); word-break: break-all; }.nav-overview-hits { color: var(--text-hint); font-size: 0.85rem; margin-top: 4px; }.nav-overview-kpis { display: flex; gap: 8px; width: 100%; }.nav-overview-kpi { flex: 1; text-align: center; padding: 8px; border-radius: 4px; }.nav-overview-kpi-entry { background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.3); }.nav-overview-kpi-exit { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.3); }.nav-overview-kpi-label { font-size: 0.7rem; text-transform: uppercase; color: var(--text-hint); }.nav-overview-kpi-value { font-size: 1.2rem; font-weight: bold; color: var(--text-main); }.nav-overview-kpi-pct { font-size: 0.75rem; color: var(--text-hint); }.nav-overview-list { display: flex; flex-direction: column; gap: 2px; }.nav-overview-item { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: 3px; font-size: 0.85rem; }.nav-overview-item:hover { background: var(--bg-subtle); }.nav-overview-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-main); }.nav-overview-count { color: var(--text-hint); font-size: 0.8rem; white-space: nowrap; }.nav-overview-pct { color: var(--primary); font-weight: bold; font-size: 0.8rem; min-width: 40px; text-align: right; }.nav-overview-remaining { padding: 6px 8px; font-size: 0.8rem; color: var(--text-hint); font-style: italic; }.nav-overview-more { display: block; width: 100%; padding: 6px; margin-top: 4px; background: var(--bg-subtle); border: 1px solid var(--border); border-radius: 4px; color: var(--primary); cursor: pointer; font-size: 0.8rem; text-align: center; }.nav-overview-more:hover { background: var(--border); }.nav-overview-empty { color: var(--text-hint); font-style: italic; padding: 8px; }.nav-overview-negate { display: inline-block; background: rgba(248, 113, 113, 0.2); border: 1px solid rgba(248, 113, 113, 0.5); color: #f87171; font-size: 0.7rem; font-weight: 700; padding: 1px 6px; border-radius: 3px; vertical-align: middle; letter-spacing: 0.5px; }.btn-analyze { padding: 6px 16px; margin-left: 8px; background: var(--primary); color: #fff; border: 1px solid var(--primary); border-radius: 4px; cursor: pointer; font-size: 0.85rem; white-space: nowrap; font-weight: 600; }.btn-analyze:hover { opacity: 0.85; }@media (max-width: 768px) { .nav-overview-flow { flex-direction: column; } .nav-overview-center { flex: none; width: 100%; } }.timeline-canvas { display: flex; flex-direction: column; gap: 12px; }.timeline-add-btn {display: flex; align-items: center; justify-content: center; gap: 6px;padding: 8px 16px; margin: 0 auto;background: rgba(99,102,241,0.12); border: 1px dashed rgba(99,102,241,0.3);border-radius: 6px; color: #818cf8; font-size: 0.85rem; cursor: pointer;transition: background 0.15s;}.timeline-add-btn:hover { background: rgba(99,102,241,0.22); }.timeline-card {border: 1px solid var(--border); border-radius: 8px;background: var(--card-bg); overflow: hidden;}.timeline-card-header {display: flex; align-items: center; justify-content: space-between;padding: 8px 12px; border-bottom: 1px solid var(--border);font-size: 0.85rem; font-weight: 500;}.timeline-card-header-left { display: flex; align-items: center; gap: 10px; }.timeline-card-header-right { display: flex; align-items: center; gap: 8px; }.timeline-card-body { padding: 12px; }.timeline-legend {display: flex; flex-wrap: wrap; gap: 10px;margin-bottom: 10px; font-size: 0.78rem;}.timeline-legend-item { display: flex; align-items: center; gap: 4px; }.timeline-legend-dot { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }.timeline-legend-remove {color: rgba(255,100,100,0.5); cursor: pointer; font-size: 0.7rem; margin-left: 2px;}.timeline-legend-remove:hover { color: rgba(255,100,100,0.9); }.timeline-chart-area {position: relative; height: 150px;border-left: 1px solid var(--border); border-bottom: 1px solid var(--border);margin-left: 40px;}.timeline-y-label {position: absolute; left: -40px; font-size: 0.65rem;color: var(--text-hint); text-align: right; width: 36px;}.timeline-x-labels {display: flex; justify-content: space-between;font-size: 0.65rem; color: var(--text-hint); margin-top: 4px; margin-left: 40px;}.timeline-btn-sm {font-size: 0.75rem; color: var(--text-hint); cursor: pointer;padding: 2px 6px; border-radius: 3px; border: none; background: none;}.timeline-btn-sm:hover { color: var(--text); }.timeline-btn-delete { color: rgba(255,100,100,0.6); }.timeline-btn-delete:hover { color: rgba(255,100,100,0.9); }.timeline-mode-toggle { display: flex; gap: 2px; font-size: 0.7rem; }.timeline-mode-toggle button {padding: 2px 8px; border-radius: 3px; border: 1px solid var(--border);background: rgba(255,255,255,0.05); color: var(--text-hint); cursor: pointer;}.timeline-mode-toggle button.active {background: rgba(99,102,241,0.2); border-color: rgba(99,102,241,0.4); color: #818cf8;}.timeline-config {display: flex; gap: 8px; align-items: end; flex-wrap: wrap;padding: 12px; border: 1px dashed var(--border); border-radius: 6px;background: rgba(255,255,255,0.02);}.timeline-config label {font-size: 0.7rem; text-transform: uppercase; color: var(--text-hint);display: block; margin-bottom: 3px;}.timeline-config select, .timeline-config input {background: var(--input-bg); border: 1px solid var(--border);border-radius: 4px; padding: 6px 10px; font-size: 0.85rem; color: var(--text);}.timeline-stacked-bar {display: flex; flex-direction: column; flex: 1; min-width: 0;justify-content: flex-end;}.timeline-stacked-segment { transition: opacity 0.15s; }.timeline-stacked-segment:hover { opacity: 0.85; }.timeline-empty { text-align: center; padding: 3rem 1rem; color: var(--text-hint); }.timeline-chart-area circle { transition: opacity 0.15s; }.timeline-chart-area circle:hover { opacity: 1 !important; }.timeline-card[draggable] { cursor: grab; }.timeline-card-dragging { opacity: 0.4; }.timeline-card-dragover { outline: 2px dashed #818cf8; outline-offset: -2px; }.timeline-add-between { padding: 2px 12px; font-size: 0.75rem; opacity: 0.5; }.timeline-add-between:hover { opacity: 1; }</style>
</head><body><div id="mobile-menu-overlay" onclick="toggleMobileMenu()"></div><div id="sidebar"><img class="logo-img" alt="Logalytrix" src="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect x='2' y='1' width='12' height='14' rx='1' fill='%230d6fd9'/><rect x='4' y='3' width='8' height='3' fill='%23fff'/><rect x='4' y='8' width='2' height='5' fill='%23fff' opacity='0.8'/><rect x='7' y='10' width='2' height='3' fill='%23fff' opacity='0.8'/><rect x='10' y='7' width='2' height='6' fill='%23fff' opacity='0.8'/></svg>"><h1><small>LogAlytrix</small><br>Logfile Analyzer</h1><!-- STANDALONE-ONLY --><input id="log-file-input" type="file" multiple accept=".log,.txt,.json,.gz" style="display:none;"><!-- /STANDALONE-ONLY --><div class="nav-item active" data-view="raw" onclick="switchView('raw'); toggleMobileMenu(false)"><svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>Rohdaten</div><div class="nav-item" data-view="overview" onclick="switchView('overview'); toggleMobileMenu(false)"><svg viewBox="0 0 24 24"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg>Dashboard</div><div class="nav-separator"></div><div class="nav-group" data-group="inhalte"><div class="nav-group-header" onclick="toggleNavGroup('inhalte')"><span class="collapse-arrow">&#9654;</span> Inhalte</div><div class="nav-group-items collapsed"><div class="nav-item nav-sub-item" data-view="top-pages" data-group="inhalte" onclick="switchView('top-pages'); toggleMobileMenu(false)">Top-Seiten</div><div class="nav-item nav-sub-item" data-view="directories" data-group="inhalte" onclick="switchView('directories'); toggleMobileMenu(false)">Verzeichnisse</div><div class="nav-item nav-sub-item" data-view="entry-pages" data-group="inhalte" onclick="switchView('entry-pages'); toggleMobileMenu(false)">Einstiegsseiten</div><div class="nav-item nav-sub-item" data-view="exit-pages" data-group="inhalte" onclick="switchView('exit-pages'); toggleMobileMenu(false)">Ausstiegsseiten</div><div class="nav-item nav-sub-item" data-view="transitions" data-group="inhalte" onclick="switchView('transitions'); toggleMobileMenu(false)">Pfadübergänge</div><div class="nav-item nav-sub-item" data-view="nav-overview" data-group="inhalte" onclick="switchView('nav-overview'); toggleMobileMenu(false)">Navigationsübersicht</div><div class="nav-item nav-sub-item" data-view="session-analyse" data-group="inhalte" onclick="switchView('session-analyse'); toggleMobileMenu(false)">Session-Analyse</div></div></div><div class="nav-group" data-group="attribution-group"><div class="nav-group-header" onclick="toggleNavGroup('attribution-group')"><span class="collapse-arrow">&#9654;</span> Attribution</div><div class="nav-group-items collapsed"><div class="nav-item nav-sub-item" data-view="sources" data-group="attribution-group" onclick="switchView('sources'); toggleMobileMenu(false)">Quellen</div><div class="nav-item nav-sub-item" data-view="conversions-report" data-group="attribution-group" onclick="switchView('conversions-report'); toggleMobileMenu(false)">Conversions</div><div class="nav-item nav-sub-item" data-view="conversion-paths" data-group="attribution-group" onclick="switchView('conversion-paths'); toggleMobileMenu(false)">Conversion-Pfade</div><div class="nav-item nav-sub-item" data-view="campaign-quality" data-group="attribution-group" onclick="switchView('campaign-quality'); toggleMobileMenu(false)">Kampagnenqualität</div></div></div><div class="nav-group" data-group="technik-group"><div class="nav-group-header" onclick="toggleNavGroup('technik-group')"><span class="collapse-arrow">&#9654;</span> Technik</div><div class="nav-group-items collapsed"><div class="nav-item nav-sub-item" data-view="status-codes" data-group="technik-group" onclick="switchView('status-codes'); toggleMobileMenu(false)">Statuscodes</div><div class="nav-item nav-sub-item" data-view="resource-types" data-group="technik-group" onclick="switchView('resource-types'); toggleMobileMenu(false)">Ressourcentypen</div><div class="nav-item nav-sub-item" data-view="browsers" data-group="technik-group" onclick="switchView('browsers'); toggleMobileMenu(false)">Browser &amp; Systeme</div><div class="nav-item nav-sub-item" data-view="ip-anomalies" data-group="technik-group" onclick="switchView('ip-anomalies'); toggleMobileMenu(false)">IP-Auffälligkeiten</div></div></div><div class="nav-group" data-group="bots-group"><div class="nav-group-header" onclick="toggleNavGroup('bots-group')"><span class="collapse-arrow">&#9654;</span> Bots &amp; Crawler</div><div class="nav-group-items collapsed"><div class="nav-item nav-sub-item" data-view="bots" data-group="bots-group" onclick="switchView('bots'); toggleMobileMenu(false)">Übersicht</div><div class="nav-item nav-sub-item" data-view="bot-time" data-group="bots-group" onclick="switchView('bot-time'); toggleMobileMenu(false)">Zeitverhalten</div></div></div><div class="nav-group" data-group="tools-group"><div class="nav-group-header" onclick="toggleNavGroup('tools-group')"><span class="collapse-arrow">&#9654;</span><svg viewBox="0 0 24 24" style="width:16px;height:16px;fill:currentColor;flex-shrink:0"><path d="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z"/></svg>Tools</div><div class="nav-group-items collapsed"><div class="nav-item nav-sub-item" data-view="bot-detection" data-group="tools-group" onclick="switchView('bot-detection'); toggleMobileMenu(false)">Bot-Erkennung</div><div class="nav-item nav-sub-item" data-view="cleanup" data-group="tools-group" onclick="switchView('cleanup'); toggleMobileMenu(false)">Bereinigung</div><div class="nav-item nav-sub-item" data-view="conversions" data-group="tools-group" onclick="switchView('conversions'); toggleMobileMenu(false)">Ziele definieren</div><div class="nav-item nav-sub-item" data-view="parameters" data-group="tools-group" onclick="switchView('parameters'); toggleMobileMenu(false)">Parameter</div><div class="nav-item nav-sub-item" data-view="crawl-budget" data-group="tools-group" onclick="switchView('crawl-budget'); toggleMobileMenu(false)">Crawl-Budget</div><div class="nav-item nav-sub-item" data-view="hotlinking" data-group="tools-group" onclick="switchView('hotlinking'); toggleMobileMenu(false)">Hotlinking</div><div class="nav-item nav-sub-item" data-view="timeline" data-group="tools-group" onclick="switchView('timeline'); toggleMobileMenu(false)">Zeitverlauf</div></div></div><div style="flex-grow:1"></div><div class="sidebar-bottom"><div id="data-status">Keine Daten geladen</div></div><div class="sidebar-bottom"><div class="version">v42.193</div><button class="dark-mode-toggle" id="dark-mode-btn" onclick="toggleDarkMode()"><span id="dark-mode-icon">🌙</span></button></div></div><div id="main"><button id="menu-toggle" onclick="toggleMobileMenu()"></button><div class="section-header"><h2 id="view-title">Rohdaten</h2><div class="header-actions"><!-- STANDALONE-ONLY --><button class="primary" id="load-logs-btn"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z"/></svg>Logdateien laden</button><!-- /STANDALONE-ONLY --><select id="date-range-preset" class="date-range-preset" onchange="applyDateRangePreset(this.value)"><option value="1">Gestern</option><option value="7" selected>Letzte 7 Tage</option><option value="14">Letzte 14 Tage</option><option value="28">Letzte 28 Tage</option><option value="60">Letzte 60 Tage</option><option value="90">Letzte 90 Tage</option><option value="custom">Benutzerdefiniert</option></select><button class="secondary" id="filter-toggle-btn" onclick="toggleFilterBar()"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg>Filter</button><button class="secondary" id="export-btn" onclick="openExportModal()"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>Export</button></div></div><div class="filter-bar collapsed" id="filter-bar"><div class="filter-group"><label>Datum von</label><input type="date" id="filter-date-from"></div><div class="filter-group"><label>Datum bis</label><input type="date" id="filter-date-to"></div><div class="filter-group"><label>Status</label><select id="filter-status"><option value="">Alle</option><option value="2xx">2xx</option><option value="3xx">3xx</option><option value="4xx">4xx</option><option value="5xx">5xx</option></select></div><div class="filter-group"><label>Typ</label><select id="filter-resource-type"><option value="">Alle</option><option value="Seite">Seiten</option><option value="Bild">Bilder</option><option value="CSS">CSS</option><option value="JavaScript">JavaScript</option><option value="Schrift">Schriften</option><option value="Feed/Daten">Feeds/Daten</option><option value="Dokument">Dokumente</option><option value="Sonstiges">Sonstiges</option></select></div><div class="filter-group"><label>Methode</label><select id="filter-method"><option value="">Alle</option><option value="GET">GET</option><option value="POST">POST</option><option value="HEAD">HEAD</option><option value="PUT">PUT</option><option value="DELETE">DELETE</option><option value="OPTIONS">OPTIONS</option></select></div><div class="filter-group"><label>Pfad</label><input type="search" id="filter-path" placeholder="z.B. /mbtools/koro"></div><div class="filter-group"><label>Gerät</label><select id="filter-device"><option value="">Alle</option><option value="Desktop">Desktop</option><option value="Smartphone">Smartphone</option><option value="Tablet">Tablet</option><option value="Unbekannt">Unbekannt</option></select></div><div class="filter-group"><label>Browser</label><select id="filter-browser"><option value="">Alle</option><option value="Chrome">Chrome</option><option value="Safari">Safari</option><option value="Firefox">Firefox</option><option value="Edge">Edge</option><option value="Opera">Opera</option><option value="Samsung Internet">Samsung Internet</option><option value="Vivaldi">Vivaldi</option><option value="Brave">Brave</option><option value="UC Browser">UC Browser</option><option value="Andere">Andere</option></select></div><div class="filter-group"><label>OS</label><select id="filter-os"><option value="">Alle</option><option value="Windows">Windows</option><option value="macOS">macOS</option><option value="Linux">Linux</option><option value="Android">Android</option><option value="iOS">iOS</option><option value="Chrome OS">Chrome OS</option><option value="Andere">Andere</option></select></div><div class="filter-group"><label>Bots</label><select id="filter-bots"><option value="">Alle</option><option value="only-human">Nur Browser</option><option value="only-bot">Nur Bots</option><option value="only-attack">Nur Angriffe</option></select></div><div class="filter-group"><label>Bot-Kategorie</label><select id="filter-bot-category"><option value="">Alle</option><option value="search">Suchmaschinen</option><option value="ai">KI-Crawler</option><option value="ai-training"> ↳ Training</option><option value="ai-grounding"> ↳ Grounding</option><option value="ai-unknown"> ↳ Unbekannt</option><option value="seo">SEO-Tools</option><option value="social">Social Media</option><option value="feed">Feed-Reader</option><option value="monitoring">Monitoring</option><option value="automation">Automation</option><option value="other">Sonstige</option><option value="heuristic">Heuristisch</option><option value="heuristic-unknown-browser"> ↳ Unbekannter Browser</option><option value="heuristic-hammering"> ↳ Hammering</option><option value="heuristic-speed-bot"> ↳ Speed-Bot</option><option value="heuristic-safari-prefetch"> ↳ Safari Prefetch</option><option value="heuristic-honeypot"> ↳ Honeypot</option></select></div><div class="filter-actions"><button class="primary" id="apply-filters-btn">Anwenden</button><button class="secondary" id="clear-filters-btn">Löschen</button></div></div><div id="active-filters-info" class="active-filters-info"></div><div id="summary-badge" class="summary-badge"></div><section id="view-raw" class="view active"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="raw-path-search" placeholder="Pfad filtern..." oninput="handleTableSearch('raw')"></div><div class="card"><div id="raw-table-container" class="table-container"></div></div><div id="raw-pagination" class="pagination"></div></section><section id="view-overview" class="view"><div id="overview-metrics"></div></section><section id="view-top-pages" class="view"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="top-pages-search" placeholder="Pfad filtern..." oninput="handleTableSearch('topPages')"></div><div class="card"><div id="top-pages-table" class="table-container"></div></div><div id="top-pages-pagination" class="pagination"></div></section><section id="view-directories" class="view"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="directories-search" placeholder="Verzeichnis filtern..." oninput="handleTableSearch('directories')"><select id="directory-depth-select" class="directory-depth-select" onchange="handleDirectoryDepth()"><option value="1">Ebene 1</option><option value="2">Ebene 2</option><option value="3">Ebene 3</option><option value="4">Ebene 4</option></select></div><div class="card"><div id="directories-table" class="table-container"></div></div><div id="directories-pagination" class="pagination"></div></section><section id="view-status-codes" class="view"><div class="card"><div id="status-codes-table" class="table-container"></div></div><div id="status-codes-footer" class="pagination"></div></section><section id="view-resource-types" class="view"><div class="card"><div id="resource-types-table" class="table-container"></div></div><div id="resource-types-footer" class="pagination"></div></section><section id="view-browsers" class="view"><div class="card"><h3>Gerätekategorie</h3><div id="device-table" class="table-container"></div></div><div id="device-footer" class="pagination"></div><div class="card" style="margin-top:1rem"><h3>Browser</h3><div id="browsers-table" class="table-container"></div></div><div id="browsers-footer" class="pagination"></div><div class="card" style="margin-top:1rem"><h3>Betriebssysteme</h3><div id="os-table" class="table-container"></div></div><div id="os-footer" class="pagination"></div></section><section id="view-conversions-report" class="view"><div id="conversions-report-content"></div></section><section id="view-conversion-paths" class="view"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="conversion-paths-search" placeholder="Seite filtern..." oninput="handleTableSearch('conversionPaths')"></div><div id="conversion-paths-content"></div></section><section id="view-bots" class="view"><div id="bots-summary-grid"></div><div class="bot-overview-grid"><div class="card"><div id="bots-list-container"></div></div><div class="card"><div id="bot-detail-container"><div class="empty-hint">Wähle einen Bot aus der Liste, um dessen Top-Seiten zu sehen.</div></div></div></div></section><section id="view-entry-pages" class="view"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="entry-pages-search" placeholder="Pfad filtern..." oninput="handleTableSearch('entryPages')"></div><div class="card"><div id="entry-pages-table" class="table-container"></div></div><div id="entry-pages-pagination" class="pagination"></div></section><section id="view-exit-pages" class="view"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="exit-pages-search" placeholder="Pfad filtern..." oninput="handleTableSearch('exitPages')"></div><div class="card"><div id="exit-pages-table" class="table-container"></div></div><div id="exit-pages-pagination" class="pagination"></div></section><section id="view-transitions" class="view"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="transitions-search" placeholder="Pfad filtern..." oninput="handleTableSearch('transitions')"></div><div class="card"><div id="transitions-table" class="table-container"></div></div><div id="transitions-pagination" class="pagination"></div></section><section id="view-nav-overview" class="view"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="nav-overview-search" placeholder="Seite oder Muster eingeben..." onkeydown="if(event.key==='Enter')analyzeNavOverview()"><button type="button" class="btn-analyze" onclick="analyzeNavOverview()">Analysieren</button></div><div id="nav-overview-content"></div></section><section id="view-session-analyse" class="view"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="session-analyse-search" placeholder="Seite filtern..." oninput="handleTableSearch('sessionAnalyse')"></div><div id="session-analyse-content"></div></section><section id="view-sources" class="view"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="sources-search" placeholder="Quelle filtern..." oninput="handleTableSearch('sources')"></div><div id="sources-content"></div></section><section id="view-bot-time" class="view"><div class="search-bar"><select id="bot-time-filter" onchange="handleBotTimeFilter()"><option value="">Alle Bots</option></select></div><div id="bot-time-content"></div></section><section id="view-parameters" class="view"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="parameters-search" placeholder="Parameter filtern..." oninput="handleTableSearch('parameters')"></div><div class="params-grid"><div class="card"><div id="parameters-list-container"></div></div><div class="card"><div id="parameter-detail-container"><div class="empty-hint">Wähle einen Parameter aus der Liste, um dessen Top-Werte zu sehen.</div></div></div></div></section><section id="view-bot-detection" class="view"><div class="card" id="heuristic-bot-detection"><p class="view-description">Logalytrix erkennt Bots anhand von <strong id="heuristic-ua-pattern-count"></strong> User-Agent-Mustern.Zusätzlich können Heuristiken aktiviert werden, die verdächtiges Verhalten erkennen &mdash;z.&nbsp;B. wenn ein Besucher dieselbe Seite ungewöhnlich oft abruft oder in unmenschlicherGeschwindigkeit navigiert. Erkannte Besucher werden der Bot-Kategorie <em>Heuristisch</em> zugeordnet.</p><div id="heuristic-rules-container"></div><div class="cleanup-actions" id="heuristic-actions" style="display:none"><button class="secondary" onclick="runHeuristics()">Analyse starten</button><button class="secondary" onclick="resetHeuristics()">Zurücksetzen</button><span id="heuristic-result-summary"></span></div></div></section><section id="view-cleanup" class="view"><div class="card"><div class="cleanup-header"><p class="view-description">Definiere Pfad-Muster, um irrelevante Einträge (z.B. Tracking-Pixel) aus allen Analysen zu entfernen.</p><label class="toggle-label"><input type="checkbox" id="cleanup-active-toggle" onchange="toggleCleanupActive()" checked>Bereinigung aktiv</label></div><div id="cleanup-patterns-table"></div><div class="cleanup-add-form"><input type="text" id="cleanup-pattern-input" placeholder="Pfad-Muster eingeben..."><select id="cleanup-mode-select"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button class="primary" onclick="addCleanupPattern()">Hinzufügen</button></div><div class="cleanup-actions"><button class="secondary danger" onclick="purgeDatabase()">Datenbank bereinigen</button></div></div></section><section id="view-hotlinking" class="view"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="hotlinking-search" placeholder="Domain filtern..." oninput="handleTableSearch('hotlinking')"></div><div id="hotlinking-content"></div></section><section id="view-crawl-budget" class="view"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="crawl-budget-search" placeholder="Pfad filtern..." oninput="handleTableSearch('crawlBudget')"></div><div id="crawl-budget-content"></div></section><section id="view-ip-anomalies" class="view"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="ip-anomalies-search" placeholder="IP, Subnetz oder Flag filtern..." oninput="handleTableSearch('ipAnomalies')"></div><div id="ip-anomalies-content"></div></section><section id="view-campaign-quality" class="view"><div class="search-bar"><select class="search-mode-select" onchange="setSearchMode(this.value)"><option value="contains">Enthält</option><option value="startsWith">Beginnt mit</option><option value="regex">Regex</option></select><button type="button" class="search-negate-toggle" onclick="setSearchNegate(!searchNegate)" title="Ergebnis umkehren (NOT)">NICHT</button><input type="search" id="campaign-quality-search" placeholder="Subnetz oder Signal filtern..." oninput="handleTableSearch('campaignQuality')"></div><div id="campaign-quality-content"></div></section><section id="view-conversions" class="view"><div class="card"><p class="view-description">Definiere bis zu 3 Conversion-Ziele. Treffer werden unter Attribution &rarr; Conversions und im Quellen-Tab den Akquisitionsquellen zugeordnet.</p><div class="conversions-settings"><label>Attribution:<select id="attribution-model-select" onchange="handleAttributionModelChange()"><option value="last">Last Touch</option><option value="first">First Touch</option></select></label></div><div id="conversion-goals-container"></div></div></section><section id="view-timeline" class="view"><div id="timeline-content"></div></section></div><div id="export-modal-overlay" class="modal-overlay" onclick="closeExportModal(event)"><div class="modal" onclick="event.stopPropagation()"><h3>Daten exportieren</h3><div class="form-group"><label class="form-label">Dateiname</label><input type="text" id="export-filename" value="logalytrix-export"></div><div class="form-group"><label class="form-label">Format</label><div class="export-format-options"><label><input type="radio" name="export-format" value="tsv" checked onchange="toggleExportOptions()"> TSV (gefilterte Rohdaten, tabellarisch)</label><label><input type="radio" name="export-format" value="json" onchange="toggleExportOptions()"> JSON (gefilterte Rohdaten, strukturiert)</label></div></div><div class="form-group export-json-options" id="export-json-options" style="display:none"><label><input type="checkbox" id="export-include-aggregates"> Reports einschließen (Dashboard, Top-Seiten, etc.)</label></div><div class="modal-actions"><button class="secondary" onclick="closeExportModal()">Abbrechen</button><button class="primary" onclick="executeExport()">Exportieren</button></div></div></div><!-- STANDALONE-ONLY --><div id="import-scope-overlay" class="modal-overlay" onclick="cancelImportScope(event)"><div class="modal" onclick="event.stopPropagation()"><h3>Import-Umfang</h3><p class="import-scope-hint" id="import-scope-hint">Nicht benötigte Kategorien abwählen, um mehr relevante Daten in das Limit zu bekommen.</p><div class="import-scope-options"><div class="import-scope-group" id="import-scope-attribution-only-group" style="display:none"><label class="import-scope-option" style="font-weight:600"><input type="checkbox" id="import-scope-attribution-only" onchange="toggleAttributionOnly()">Nur Attributionsdaten</label><div style="font-size:0.8rem;opacity:0.7;margin-left:1.6rem;margin-top:-0.25rem">Importiert nur Browser-Eintritte mit Referrer, UTMs oder Klick-IDs sowie Conversions. Alle Statuscodes.</div></div><div class="import-scope-group" id="import-scope-sources-group"><div class="import-scope-group-label">Quellen</div><label class="import-scope-option"><input type="checkbox" id="import-scope-browser" checked>Browser</label><label class="import-scope-option"><input type="checkbox" id="import-scope-bots" checked>Bots</label><label class="import-scope-option"><input type="checkbox" id="import-scope-attacks" checked>Angriffe</label></div><div class="import-scope-group" id="import-scope-status-group"><div class="import-scope-group-label">Statuscodes</div><label class="import-scope-option"><input type="checkbox" id="import-scope-3xx" checked>3xx Redirects</label><label class="import-scope-option"><input type="checkbox" id="import-scope-4xx" checked>4xx Client-Fehler</label><label class="import-scope-option"><input type="checkbox" id="import-scope-5xx" checked>5xx Server-Fehler</label></div><div class="import-scope-group" id="import-scope-resources-group"><div class="import-scope-group-label">Ressourcentypen</div><label class="import-scope-option"><input type="checkbox" id="import-scope-images" checked>Bilder</label><label class="import-scope-option"><input type="checkbox" id="import-scope-css" checked>CSS</label><label class="import-scope-option"><input type="checkbox" id="import-scope-js" checked>JavaScript</label><label class="import-scope-option"><input type="checkbox" id="import-scope-fonts" checked>Schriften</label></div><div class="import-scope-group" id="import-scope-cleanup-group"><label class="import-scope-option" id="import-scope-cleanup-label"><input type="checkbox" id="import-scope-cleanup" checked>Bereinigungsregeln anwenden <span id="import-scope-cleanup-count"></span></label></div></div><div class="modal-actions"><button class="secondary" onclick="cancelImportScope()">Abbrechen</button><button class="primary" onclick="confirmImportScope()">Importieren</button></div></div></div><!-- /STANDALONE-ONLY --><div id="progress-backdrop"></div><div id="toast"></div><!-- STANDALONE-ONLY --><script>
const Storage = (() => {
const DB_NAME = "log-analyzer-db";
const DB_VERSION = 1;
const STORE_NAME = "state";
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function saveAll(state) {
try {
const db = await openDB();
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const toStore = {
rawEntries: state.rawEntries.map(e => ({
...e,
timestamp: e.timestamp.toISOString()
})),
sessions: state.sessions.map(s => ({
...s,
start: s.start.toISOString(),
end: s.end.toISOString()
})),
aggregates: state.aggregates,
filters: state.filters,
patterns: state.patterns || [],
cleanupActive: state.cleanupActive !== false,
attributionModel: state.attributionModel || 'last',
ownHost: state.ownHost || '',
truncated: state.truncated || false,
timelineCards: state.timelineCards || [],
heuristicRules: state.heuristicRules || null,
heuristicBotVisitors: state.heuristicBotVisitors
? Array.from(state.heuristicBotVisitors.entries()) : []
};
store.put(toStore, "state");
return tx.complete;
} catch (e) {
console.warn("Speichern in IndexedDB fehlgeschlagen:", e);
}
}
async function loadAll() {
try {
const db = await openDB();
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const req = store.get("state");
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
});
} catch (e) {
console.warn("Laden aus IndexedDB fehlgeschlagen:", e);
return null;
}
}
return {
saveAll,
loadAll
};
})();
</script>
<!-- /STANDALONE-ONLY --><script>
const BOT_DEFINITIONS = {
categories: {
search: "Suchmaschinen",
ai: "KI-Crawler",
seo: "SEO-Tools",
social: "Social Media",
feed: "Feed-Reader",
monitoring: "Monitoring",
automation: "Headless / Automation",
other: "Sonstige",
heuristic: "Heuristisch"
},
bots: [
{ pattern: "googlebot-image", name: "Googlebot Image", category: "search" },
{ pattern: "googlebot-video", name: "Googlebot Video", category: "search" },
{ pattern: "googlebot-news", name: "Googlebot News", category: "search" },
{ pattern: "googleother-image", name: "GoogleOther Image", category: "search" },
{ pattern: "googleother-video", name: "GoogleOther Video", category: "search" },
{ pattern: "googleother", name: "GoogleOther", category: "search" },
{ pattern: "google-cloudvertexbot", name: "Google CloudVertexBot (Vertex AI)", category: "ai", aiPurpose: "grounding" },
{ pattern: "google-extended", name: "Google Extended (Gemini Training)", category: "ai", aiPurpose: "training" },
{ pattern: "google-inspectiontool", name: "Google InspectionTool", category: "search" },
{ pattern: "storebot-google", name: "Google StoreBot", category: "search" },
{ pattern: "adsbot-google-mobile", name: "Google AdsBot (Mobile)", category: "search" },
{ pattern: "adsbot-google", name: "Google AdsBot", category: "search" },
{ pattern: "mediapartners-google", name: "Google AdSense", category: "search" },
{ pattern: "google-safety", name: "Google Safety", category: "monitoring" },
{ pattern: "google favicon", name: "Google Favicon", category: "search" },
{ pattern: "duplexweb-google", name: "Google Duplex", category: "search" },
{ pattern: "googleweblight", name: "Google Web Light", category: "search" },
{ pattern: "feedfetcher-google", name: "Google Feedfetcher", category: "feed" },
{ pattern: "apis-google", name: "Google APIs", category: "search" },
{ pattern: "google-cws", name: "Google Chrome Web Store", category: "search" },
{ pattern: "googlemessages", name: "Google Messages", category: "social" },
{ pattern: "google-notebooklm", name: "Google NotebookLM", category: "ai", aiPurpose: "grounding" },
{ pattern: "google-pinpoint", name: "Google Pinpoint", category: "ai", aiPurpose: "grounding" },
{ pattern: "googleproducer", name: "Google Publisher Center", category: "feed" },
{ pattern: "google-read-aloud", name: "Google Read Aloud", category: "search" },
{ pattern: "google-speakr", name: "Google Read Aloud", category: "search" },
{ pattern: "google-agent", name: "Google Agent (Gemini/Mariner)", category: "ai", aiPurpose: "grounding" },
{ pattern: "gemini-deep-research", name: "Gemini Deep Research", category: "ai", aiPurpose: "grounding" },
{ pattern: "google-site-verification", name: "Google Site Verifier", category: "monitoring" },
{ pattern: "chrome-lighthouse", name: "Chrome Lighthouse", category: "monitoring" },
{ allOf: ["googlebot", "mobile"], name: "Googlebot (Mobile)", category: "search" },
{ pattern: "googlebot", name: "Googlebot (Desktop)", category: "search" },
{ pattern: "adidxbot", name: "AdIdxBot (Microsoft)", category: "search" },
{ pattern: "microsoftpreview", name: "Microsoft Preview", category: "search" },
{ pattern: "bingpreview", name: "Bing Preview", category: "search" },
{ pattern: "bingbot", name: "Bingbot", category: "search" },
{ pattern: "msnbot", name: "MSNBot", category: "search" },
{ pattern: "yandexbot", name: "YandexBot", category: "search" },
{ pattern: "yandex.com/bots", name: "YandexBot", category: "search" },
{ pattern: "baiduspider", name: "Baiduspider", category: "search" },
{ pattern: "duckduckbot", name: "DuckDuckBot", category: "search" },
{ pattern: "slurp", name: "Yahoo Slurp", category: "search" },
{ pattern: "sogou", name: "Sogou Spider", category: "search" },
{ pattern: "exabot", name: "Exabot", category: "search" },
{ pattern: "qwantify", name: "Qwant", category: "search" },
{ pattern: "seznam", name: "SeznamBot", category: "search" },
{ pattern: "naver", name: "Naver", category: "search" },
{ pattern: "applebot-extended", name: "Applebot Extended", category: "ai", aiPurpose: "training" },
{ pattern: "applebot", name: "Applebot", category: "search" },
{ pattern: "yisouspider", name: "YisouSpider", category: "search" },
{ pattern: "refindbot", name: "Refindbot", category: "search" },
{ pattern: "seekport", name: "SeekportBot", category: "search" },
{ pattern: "jobboersebot", name: "JobboerseBot", category: "search" },
{ pattern: "gptbot", name: "GPTBot (OpenAI)", category: "ai", aiPurpose: "training" },
{ pattern: "chatgpt-user", name: "ChatGPT User", category: "ai", aiPurpose: "grounding" },
{ pattern: "oai-searchbot", name: "OAI SearchBot", category: "ai", aiPurpose: "grounding" },
{ pattern: "oai-adsbot", name: "OAI AdsBot (OpenAI Ads)", category: "ai" },
{ pattern: "claudebot", name: "ClaudeBot (Anthropic)", category: "ai", aiPurpose: "training" },
{ pattern: "claude-searchbot", name: "Claude SearchBot", category: "ai", aiPurpose: "grounding" },
{ pattern: "claude-user", name: "Claude-User", category: "ai", aiPurpose: "grounding" },
{ pattern: "claude-web", name: "Claude-Web", category: "ai", aiPurpose: "grounding" },
{ pattern: "anthropic", name: "Anthropic", category: "ai", aiPurpose: "training" },
{ pattern: "mistralai-user", name: "MistralAI-User", category: "ai", aiPurpose: "grounding" },
{ pattern: "mistralbot", name: "MistralBot", category: "ai", aiPurpose: "training" },
{ pattern: "perplexitybot", name: "PerplexityBot", category: "ai", aiPurpose: "grounding" },
{ pattern: "perplexity-user", name: "Perplexity-User", category: "ai", aiPurpose: "grounding" },
{ pattern: "perplexity", name: "PerplexityBot", category: "ai", aiPurpose: "grounding" },
{ pattern: "cohere-ai", name: "Cohere AI", category: "ai", aiPurpose: "training" },
{ pattern: "meta-externalagent", name: "Meta AI", category: "ai", aiPurpose: "training" },
{ pattern: "meta-externalfetcher", name: "Meta Fetcher", category: "ai", aiPurpose: "grounding" },
{ pattern: "bytespider", name: "ByteSpider (ByteDance)", category: "ai", aiPurpose: "training" },
{ pattern: "ccbot", name: "CCBot (Common Crawl)", category: "ai", aiPurpose: "training" },
{ pattern: "diffbot", name: "Diffbot", category: "ai", aiPurpose: "training" },
{ pattern: "omgili", name: "Omgili", category: "ai", aiPurpose: "training" },
{ pattern: "youbot", name: "YouBot (You.com)", category: "ai", aiPurpose: "grounding" },
{ pattern: "friendly_crawler", name: "Friendly Crawler (AI)", category: "ai", aiPurpose: "training" },
{ pattern: "ia_archiver", name: "Internet Archive", category: "ai" },
{ pattern: "facebookexternalhit", name: "Facebook Crawler", category: "social" },
{ pattern: "twitterbot", name: "Twitterbot", category: "social" },
{ pattern: "linkedinbot", name: "LinkedInBot", category: "social" },
{ pattern: "telegrambot", name: "TelegramBot", category: "social" },
{ pattern: "whatsapp", name: "WhatsApp", category: "social" },
{ pattern: "slackbot", name: "Slackbot", category: "social" },
{ pattern: "discordbot", name: "Discordbot", category: "social" },
{ pattern: "semrushbot", name: "SemrushBot", category: "seo" },
{ pattern: "ahrefsbot", name: "AhrefsBot", category: "seo" },
{ pattern: "mj12bot", name: "MJ12Bot (Majestic)", category: "seo" },
{ pattern: "dotbot", name: "DotBot (Moz)", category: "seo" },
{ pattern: "rogerbot", name: "Rogerbot (Moz)", category: "seo" },
{ pattern: "screaming frog", name: "Screaming Frog", category: "seo" },
{ pattern: "petalbot", name: "PetalBot", category: "seo" },
{ pattern: "serpstatbot", name: "SerpstatBot", category: "seo" },
{ pattern: "blexbot", name: "BLEXBot", category: "seo" },
{ pattern: "dataforseo", name: "DataForSEO", category: "seo" },
{ pattern: "sistrix", name: "SISTRIX", category: "seo" },
{ pattern: "smtbot", name: "SMTBot", category: "seo" },
{ pattern: "rytebot", name: "RyteBot", category: "seo" },
{ pattern: "cocolyzebot", name: "Cocolyzebot", category: "seo" },
{ pattern: "brightedge", name: "BrightEdge", category: "seo" },
{ pattern: "hubspot", name: "HubSpot", category: "seo" },
{ pattern: "miniflux", name: "Miniflux", category: "feed" },
{ pattern: "feedly", name: "Feedly", category: "feed" },
{ pattern: "inoreader", name: "Inoreader", category: "feed" },
{ pattern: "theoldreader", name: "The Old Reader", category: "feed" },
{ pattern: "newsblur", name: "NewsBlur", category: "feed" },
{ pattern: "feedbin", name: "Feedbin", category: "feed" },
{ pattern: "uptimerobot", name: "UptimeRobot", category: "monitoring" },
{ pattern: "pingdom", name: "Pingdom", category: "monitoring" },
{ pattern: "statuscake", name: "StatusCake", category: "monitoring" },
{ pattern: "site24x7", name: "Site24x7", category: "monitoring" },
{ pattern: "jetmon", name: "Jetmon (Jetpack)", category: "monitoring" },
{ pattern: "cookiebot", name: "Cookiebot", category: "monitoring" },
{ pattern: "avalex", name: "Avalex", category: "monitoring" },
{ pattern: "bitsightbot", name: "BitSightBot", category: "monitoring" },
{ pattern: "awariosmartbot", name: "AwarioSmartBot", category: "monitoring" },
{ pattern: "awariorssbot", name: "AwarioRssBot", category: "monitoring" },
{ pattern: "headlesschrome", name: "HeadlessChrome", category: "automation" },
{ pattern: "phantomjs", name: "PhantomJS", category: "automation" },
{ pattern: "wkhtmlto", name: "wkhtmlto*", category: "automation" },
{ pattern: "site-shot", name: "Site-Shot", category: "automation" },
{ pattern: "pagepeeker", name: "PagePeeker", category: "automation" },
{ pattern: "screeenly", name: "Screeenly Bot", category: "automation" },
{ pattern: "msie 5.0", name: "Legacy IE (Bot-Verdacht)", category: "automation" },
{ pattern: "woobot", name: "Woobot", category: "other" },
{ pattern: "investment crawler", name: "Investment Crawler", category: "other" },
{ pattern: "veoozbot", name: "Veoozbot", category: "other" },
{ pattern: "elisabot", name: "Elisabot", category: "other" },
{ pattern: "ev-crawler", name: "EV-Crawler", category: "other" },
{ pattern: "cincraw", name: "Cincraw", category: "other" },
{ pattern: "headline.com", name: "Headline.com", category: "other" },
{ pattern: "pumoxbot", name: "Pumoxbot", category: "other" },
{ pattern: "intl-ui-bot", name: "INTL-UI-Bot", category: "other" },
{ pattern: "crawler", name: "Unbekannter Crawler", category: "other" },
{ pattern: "spider", name: "Unbekannter Spider", category: "other" },
{ pattern: "bot", notIf: ["cubot"], name: "Unbekannter Bot", category: "other" },
{ pattern: "scanner", name: "Scanner", category: "monitoring" },
{ pattern: "axios/", name: "Axios (HTTP-Client)", category: "automation" },
{ pattern: "python-requests", name: "Python Requests", category: "automation" },
{ pattern: "python-xmlrpc", name: "Python XML-RPC", category: "automation" },
{ pattern: "go-http-client", name: "Go HTTP Client", category: "automation" },
{ pattern: "okhttp", name: "OkHttp", category: "automation" },
{ pattern: "java/", name: "Java HTTP Client", category: "automation" },
{ pattern: "libwww", name: "libwww-perl", category: "automation" },
{ pattern: "rss", name: "RSS Reader", category: "feed" },
{ pattern: "feed", name: "Feed Reader", category: "feed" }
]
};
function matchesBotPattern(lowerUA, bot) {
var matched = bot.allOf
? bot.allOf.every(function(p) { return lowerUA.indexOf(p) !== -1; })
: lowerUA.indexOf(bot.pattern) !== -1;
if (!matched) return false;
if (bot.notIf) {
for (const ex of bot.notIf) {
if (lowerUA.indexOf(ex) !== -1) return false;
}
}
return true;
}
const ATTACK_PATTERNS = {
injectionStrings: [
'<script', 'javascript:', 'onerror=', 'onload=', 'onclick=', 'onmouseover=',
'onfocus=', '<svg', '<iframe', '<img src', '<body', 'alert(', 'confirm(',
'prompt(', 'document.cookie', 'document.domain',
'union select', "' or '", "' or 1", '1=1--', '1=1#', "' and '",
' and 1=1', ' or 1=1', '1=1 or ', '1=1 and ',
'sleep(', 'benchmark(', 'waitfor delay', 'drop table', 'insert into',
'information_schema', 'load_file(',
'../', '..\\', '%2e%2e%2f', '%2e%2e/', '..%2f', '..%5c',
';cat ', ';ls ', ';id', ';whoami', '|cat ', '|ls ', '$(', '`id`',
'eval(', 'base64_decode', 'system(', 'passthru(', 'shell_exec(',
'${jndi:', 'jndi:ldap', 'jndi:rmi',
'php://input', 'php://filter', 'data://text',
'<!entity', '<!doctype'
],
referrerBlacklist: [
'\\'
],
scannerAgents: [
'sqlmap', 'nikto', 'nuclei', 'gobuster', 'dirbuster', 'dirb ',
'wpscan', 'wfuzz', 'ffuf', 'hydra', 'burpsuite', 'nessus',
'acunetix', 'nmap', 'masscan', 'zgrab', 'openvas',
'metasploit', 'havij', 'w3af', 'skipfish', 'arachni',
'whatweb', 'joomscan', 'cmseek'
],
maliciousPaths: [
'/xleet.php', '/xleet-shell.php',
'alfacgiapi' // AlfaShell/AlfaCGIAPI Webshell — Substring-Match, egal wo im Pfad
],
probePaths: [
'/wp-admin', '/wp-login', '/wp-config', '/xmlrpc.php',
'/.env', '/.git/', '/.svn/', '/.htaccess', '/.htpasswd',
'/web.config', '/config.php', '/configuration.php',
'/database.yml', '/credentials',
'/phpinfo', '/phpmyadmin', '/pma/', '/adminer',
'/server-status', '/server-info', '/manager/html',
'/actuator', '/console',
'/administrator/', '/joomla/', '/drupal/', '/magento/', '/typo3/',
'/shell.php', '/cmd.php', '/c99.php', '/r57.php', '/webshell',
'/cgi-bin/', '/etc/passwd', '/etc/shadow', '/proc/self'
],
attackRules: [
{ field: 'pathLower', list: 'injectionStrings', when: 'always' },
{ field: 'referrer', list: 'referrerBlacklist', when: 'referrerPresent' },
{ field: 'referrer', list: 'injectionStrings', when: 'referrerPresent' },
{ field: 'uaLower', list: 'scannerAgents', when: 'always' },
{ field: 'pathOnly', list: 'maliciousPaths', when: 'always' },
{ field: 'pathOnly', list: 'probePaths', when: 'errorStatus' }
]
};
const RESOURCE_TYPES = {
png: "Bild", jpg: "Bild", jpeg: "Bild", gif: "Bild", svg: "Bild",
webp: "Bild", ico: "Bild", bmp: "Bild", avif: "Bild", tiff: "Bild",
css: "CSS",
js: "JavaScript",
woff: "Schrift", woff2: "Schrift", ttf: "Schrift", eot: "Schrift", otf: "Schrift",
xml: "Feed/Daten", rss: "Feed/Daten", atom: "Feed/Daten", json: "Feed/Daten",
pdf: "Dokument", doc: "Dokument", docx: "Dokument", xls: "Dokument",
xlsx: "Dokument", ppt: "Dokument", pptx: "Dokument", csv: "Dokument",
txt: "Dokument",
mp3: "Media", mp4: "Media", webm: "Media", ogg: "Media",
wav: "Media", avi: "Media", mov: "Media", flv: "Media",
zip: "Archiv", gz: "Archiv", tar: "Archiv", rar: "Archiv", "7z": "Archiv",
html: "Seite", htm: "Seite", php: "Seite", asp: "Seite", aspx: "Seite", jsp: "Seite",
map: "Source Map"
};
const BROWSER_PATTERNS = [
{ pattern: "edg/", name: "Edge" },
{ pattern: "opr/", name: "Opera" },
{ pattern: "opera", name: "Opera" },
{ pattern: "samsungbrowser", name: "Samsung Internet" },
{ pattern: "ucbrowser", name: "UC Browser" },
{ pattern: "vivaldi", name: "Vivaldi" },
{ pattern: "brave", name: "Brave" },
{ pattern: "firefox", name: "Firefox" },
{ pattern: "fxios", name: "Firefox" },
{ pattern: "chrome", name: "Chrome" },
{ pattern: "crios", name: "Chrome" },
{ pattern: "safari", name: "Safari" }
];
const OS_PATTERNS = [
{ pattern: "android", name: "Android" },
{ pattern: "iphone", name: "iOS" },
{ pattern: "ipad", name: "iOS" },
{ pattern: "ipod", name: "iOS" },
{ pattern: "cros", name: "Chrome OS" },
{ pattern: "windows", name: "Windows" },
{ pattern: "macintosh", name: "macOS" },
{ pattern: "mac os", name: "macOS" },
{ pattern: "linux", name: "Linux" }
];
const CLICK_ID_KEYS = ['gclid', 'gad_source', 'dclid', 'fbclid', 'wbraid', 'gbraid', 'msclkid', 'ttclid', 'li_fat_id', 'twclid', 'sclid', 'rdt_cid', 'pclid'];
const CLICK_ID_LABELS = {
gclid: 'Google Ads', gad_source: 'Google Ads (Consent)', dclid: 'Google DV360',
fbclid: 'Facebook/Meta', wbraid: 'Google (App/iOS)', gbraid: 'Google (App/Android)',
msclkid: 'Microsoft Ads', ttclid: 'TikTok Ads', li_fat_id: 'LinkedIn Ads',
twclid: 'Twitter/X Ads', sclid: 'Snapchat Ads', rdt_cid: 'Reddit Ads', pclid: 'Pinterest Ads'
};
const HOTLINK_RESOURCE_TYPES = ['Bild', 'Schrift', 'CSS', 'JavaScript', 'Media'];
const BOT_CATEGORY_LABELS = BOT_DEFINITIONS.categories;
const HEURISTIC_DEFAULTS = {
'unknown-browser': { label: 'Unbekannter Browser', active: false },
'hammering': { label: 'Hammering', active: false, threshold: 20 },
'speed-bot': { label: 'Speed-Bot', active: false, minPageviews: 5, maxSeconds: 10 },
'safari-prefetch': { label: 'Safari Prefetch', active: false },
'honeypot': { label: 'Honeypot', active: false, paths: [] }
};
const AI_PURPOSE_LABELS = { training: "Training", grounding: "Grounding", unknown: "Unbekannt" };
function getAiPurpose(botName) {
const entry = BOT_DEFINITIONS.bots.find(b => b.name === botName);
if (!entry || entry.category !== 'ai') return null;
return entry.aiPurpose || 'unknown';
}
const TIMELINE_COLORS = {
statusCode: { '2xx': '#7B9A6D', '3xx': '#00224E', '4xx': '#BDAC50', '5xx': '#EDD218' },
trafficType: { 'Browser': '#0E336F', 'Bots': '#9A9369', 'Angriffe': '#EDD218' },
resourceType: {
'Seite': '#0E336F', 'Bild': '#1E3A71', 'CSS': '#3A4B72', 'JavaScript': '#505874',
'Schrift': '#64697B', 'Feed/Daten': '#808079', 'Dokument': '#9A9369',
'Media': '#B8A953', 'Archiv': '#D2BB3B', 'Source Map': '#F0D414', 'Sonstiges': '#FDEA45'
},
device: { 'Desktop': '#0E336F', 'Smartphone': '#64697B', 'Tablet': '#B8A953', 'Unbekannt': '#FDEA45' },
method: { 'GET': '#0E336F', 'POST': '#3A4B72', 'HEAD': '#64697B', 'OPTIONS': '#808079', 'PUT': '#9A9369', 'DELETE': '#B8A953', 'PATCH': '#9A9369' },
botCategory: {
'Suchmaschinen': '#0E336F', 'KI-Crawler': '#1E3A71', 'SEO-Tools': '#3A4B72',
'Social Media': '#505874', 'Feed-Reader': '#64697B', 'Monitoring': '#808079',
'Bibliotheken': '#9A9369', 'Archivierung': '#B8A953', 'Sonstige': '#FDEA45',
'Heuristisch': '#D2BB3B'
},
browser: {
'Chrome': '#0E336F', 'Safari': '#1E3A71', 'Firefox': '#3A4B72', 'Edge': '#505874',
'Opera': '#64697B', 'Samsung Internet': '#808079', 'Andere': '#FDEA45'
},
os: {
'Windows': '#0E336F', 'macOS': '#1E3A71', 'iOS': '#3A4B72', 'Android': '#505874',
'Linux': '#64697B', 'Chrome OS': '#808079', 'Andere': '#FDEA45'
},
_fallback: '#FDEA45',
_sonstige: 'rgba(255,255,255,0.25)'
};
if (typeof window !== 'undefined') {
window.BOT_DEFINITIONS = BOT_DEFINITIONS;
window.HOTLINK_RESOURCE_TYPES = HOTLINK_RESOURCE_TYPES;
window.BOT_CATEGORY_LABELS = BOT_CATEGORY_LABELS;
window.HEURISTIC_DEFAULTS = HEURISTIC_DEFAULTS;
window.AI_PURPOSE_LABELS = AI_PURPOSE_LABELS;
window.TIMELINE_COLORS = TIMELINE_COLORS;
}
</script>
<!-- STANDALONE-ONLY --><script>
function parseApacheCombinedLog(line) {
if (!line || typeof line !== "string") return null;
const firstSpace = line.indexOf(' ');
if (firstSpace > 0) {
const firstField = line.substring(0, firstSpace);
if (/^[a-zA-Z]/.test(firstField) && firstField.indexOf('.') !== -1) {
const secondSpace = line.indexOf(' ', firstSpace + 1);
if (secondSpace > firstSpace) {
const rtField = line.substring(firstSpace + 1, secondSpace);
const rtValue = parseInt(rtField, 10);
if (!isNaN(rtValue) && String(rtValue) === rtField) {
const rest = line.substring(secondSpace + 1);
const inner = parseApacheCombinedLog(rest);
if (inner) {
inner.responseTime = rtValue;
return inner;
}
}
}
}
}
const patterns = [
/^(\S+)\s+\S+\s+\S+\s+\[([^\]]+)\]\s+"([^"]*)"\s+(\d{3}|-)\s+(\S+)\s+(\S+)\s+"([^"]*)"\s+"([^"]*)"/,
/^(\S+)\s+\S+\s+\S+\s+\[([^\]]+)\]\s+"([^"]*)"\s+(\d{3}|-)\s+(\S+)\s+"([^"]*)"\s+"([^"]*)"/
];
let match = null;
let withHost = false;
for (let i = 0; i < patterns.length; i++) {
match = line.match(patterns[i]);
if (match) { withHost = i === 0; break; }
}
if (!match) {
try {
const firstSpace = line.indexOf(" ");
if (firstSpace === -1) return null;
const ip = line.substring(0, firstSpace);
const dateStart = line.indexOf("[");
const dateEnd = line.indexOf("]");
if (dateStart === -1 || dateEnd === -1) return null;
const rawDate = line.substring(dateStart + 1, dateEnd);
const reqStart = line.indexOf("\"", dateEnd);
if (reqStart === -1) return null;
const reqEnd = line.indexOf("\"", reqStart + 1);
if (reqEnd === -1) return null;
const request = line.substring(reqStart + 1, reqEnd);
const reqParts = request.split(" ");
const method = reqParts[0] || "";
const path = reqParts[1] || "/";
const afterReq = line.substring(reqEnd + 1).trim();
const refStart = afterReq.indexOf("\"");
const refEnd = refStart === -1 ? -1 : afterReq.indexOf("\"", refStart + 1);
const referrer = refStart !== -1 && refEnd !== -1 ? afterReq.substring(refStart + 1, refEnd) : "";
const uaStart = refEnd === -1 ? -1 : afterReq.indexOf("\"", refEnd + 1);
const uaEnd = uaStart === -1 ? -1 : afterReq.indexOf("\"", uaStart + 1);
const userAgent = uaStart !== -1 && uaEnd !== -1 ? afterReq.substring(uaStart + 1, uaEnd) : "";
const beforeRef = refStart !== -1 ? afterReq.substring(0, refStart).trim() : afterReq;
const parts = beforeRef.split(" ").filter(p => p.length > 0);
const status = parts.length > 0 ? parseInt(parts[0], 10) : 0;
const bytesRaw = parts.length > 1 ? parts[1] : "-";
const bytes = bytesRaw === "-" ? 0 : parseInt(bytesRaw, 10);
const timestamp = parseApacheDate(rawDate);
if (!timestamp) return null;
return {
ip: ip,
timestamp: timestamp,
method: method,
path: path,
status: status,
bytes: bytes,
referrer: referrer === "-" ? "" : referrer,
userAgent: userAgent || "",
responseTime: null
};
} catch (e) {
return null;
}
}
const ip = match[1];
const rawDate = match[2];
const request = match[3] || "";
const statusRaw = match[4];
const bytesRaw = match[5];
let referrer = "";
let userAgent = "";
if (withHost) {
referrer = match[7] || "";
userAgent = match[8] || "";
} else {
referrer = match[6] || "";
userAgent = match[7] || "";
}
const reqParts = request.split(" ");
const method = reqParts[0] || "";
const path = reqParts[1] || "/";
const status = statusRaw === "-" ? 0 : parseInt(statusRaw, 10);
const bytes = bytesRaw === "-" ? 0 : parseInt(bytesRaw, 10);
const timestamp = parseApacheDate(rawDate);
if (!timestamp) return null;
let responseTime = null;
const remainder = line.substring(match[0].length).trim();
if (remainder.length > 0) {
const rtCandidate = parseInt(remainder, 10);
if (!isNaN(rtCandidate) && String(rtCandidate) === remainder) {
responseTime = rtCandidate;
}
}
return {
ip: ip,
timestamp: timestamp,
method: method,
path: path,
status: status,
bytes: bytes,
referrer: referrer === "-" ? "" : referrer,
userAgent: userAgent || "",
responseTime: responseTime
};
}
function parseApacheDate(str) {
const spaceIndex = str.indexOf(" ");
if (spaceIndex === -1) return null;
const datePart = str.substring(0, spaceIndex);
const timePart = str.substring(spaceIndex + 1);
const dmyParts = datePart.split("/");
if (dmyParts.length !== 3) return null;
const day = parseInt(dmyParts[0], 10);
const monStr = dmyParts[1];
const yearAndTime = dmyParts[2]; // "2025:00:00:12"
const yearParts = yearAndTime.split(":");
if (yearParts.length !== 4) return null;
const year = parseInt(yearParts[0], 10);
const hour = parseInt(yearParts[1], 10);
const min = parseInt(yearParts[2], 10);
const sec = parseInt(yearParts[3], 10);
const months = {
Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5,
Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11
};
const month = months[monStr];
if (month === undefined) return null;
return new Date(year, month, day, hour, min, sec);
}
function parseJsonLogLine(line) {
if (!line || typeof line !== 'string') return null;
line = line.trim();
if (line.length === 0) return null;
try {
const obj = JSON.parse(line);
return {
ip: obj.ip || obj.client_ip || obj.remote_addr || '',
timestamp: obj.timestamp ? new Date(obj.timestamp) : (obj.time ? new Date(obj.time) : null),
method: obj.method || obj.request_method || (obj.request && obj.request.method) || '',
path: obj.path || obj.url || (obj.request && obj.request.path) || '',
status: typeof obj.status === 'number' ? obj.status : (obj.status_code ? parseInt(obj.status_code, 10) : null),
bytes: obj.bytes || obj.size || obj.response_size || 0,
referrer: obj.referrer || obj.referer || (obj.request && obj.request.referrer) || '',
userAgent: obj.userAgent || obj.ua || obj.user_agent || (obj.request && obj.request.headers && obj.request.headers['User-Agent']) || '',
responseTime: null
};
} catch (e) {
return null;
}
}
function isBotUserAgent(ua) {
if (!ua) return true;
const lower = ua.toLowerCase();
for (const bot of BOT_DEFINITIONS.bots) {
if (matchesBotPattern(lower, bot)) return true;
}
return false;
}
function isAttackRequest(entry) {
let pathRaw = (entry.path || '').toLowerCase();
try { pathRaw = decodeURIComponent(pathRaw); } catch (_) {}
const pathLower = pathRaw.replace(/\+/g, ' ');
const uaLower = (entry.userAgent || '').toLowerCase();
const referrer = (entry.referrer || '').toLowerCase();
const qIdx = pathLower.indexOf('?');
const pathOnly = qIdx !== -1 ? pathLower.substring(0, qIdx) : pathLower;
const fields = { pathLower, pathOnly, uaLower, referrer };
const conditions = {
always: true,
errorStatus: entry.status >= 400 || entry.status === 0,
referrerPresent: !!(referrer && referrer !== '-')
};
for (const rule of ATTACK_PATTERNS.attackRules) {
if (!conditions[rule.when]) continue;
const haystack = fields[rule.field];
if (!haystack) continue;
for (const needle of ATTACK_PATTERNS[rule.list]) {
if (haystack.indexOf(needle) !== -1) return true;
}
}
return false;
}
</script>
<script>
function buildSessions(entries) {
var sorted = entries;
for (var i = 1; i < entries.length; i++) {
if (entries[i].timestamp < entries[i - 1].timestamp) {
sorted = [...entries].sort((a, b) => a.timestamp - b.timestamp);
break;
}
}
const sessionsByKey = new Map();
const timeoutMs = 30 * 60 * 1000;
for (const e of sorted) {
const key = e.ip + "|" + e.userAgent;
let sessions = sessionsByKey.get(key);
if (!sessions) {
sessions = [];
sessionsByKey.set(key, sessions);
}
const last = sessions[sessions.length - 1];
if (!last || e.timestamp - last.lastSeen > timeoutMs) {
sessions.push({
sessionId: key + "|" + e.timestamp.getTime(),
ip: e.ip,
userAgent: e.userAgent,
start: e.timestamp,
end: e.timestamp,
lastSeen: e.timestamp,
referrer: e.referrer || '',
pages: (e.status >= 300 && e.status < 400) ? [] : [e.path],
pageviews: 1
});
} else {
last.end = e.timestamp;
last.lastSeen = e.timestamp;
if (!(e.status >= 300 && e.status < 400)) last.pages.push(e.path);
last.pageviews += 1;
}
}
return Array.from(sessionsByKey.values()).flat().map(s => ({
sessionId: s.sessionId,
ip: s.ip,
userAgent: s.userAgent,
start: s.start,
end: s.end,
duration: s.end - s.start,
referrer: s.referrer,
pages: s.pages,
pageviews: s.pageviews
}));
}
</script>
<script>
function toLocalDateStr(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return y + '-' + m + '-' + day;
}
function normalizeReferrerHost(referrer) {
if (!referrer || referrer === '-') return '';
try { return new URL(referrer).hostname.replace(/^www\./, '').toLowerCase(); }
catch (_) { return referrer.toLowerCase(); }
}
function extractUtmParams(params) {
const src = params.get('utm_source') || params.get('mtm_source') || params.get('pk_source') || '';
const med = params.get('utm_medium') || params.get('mtm_medium') || params.get('pk_medium') || '';
const camp = params.get('utm_campaign') || params.get('mtm_campaign') || params.get('pk_campaign') || '';
return { source: src, medium: med, campaign: camp };
}
function getTimestampRange(entries) {
if (!entries.length) return null;
let minTs = Infinity, maxTs = -Infinity;
for (const e of entries) {
const t = e.timestamp.getTime();
if (t < minTs) minTs = t;
if (t > maxTs) maxTs = t;
}
return { min: new Date(minTs), max: new Date(maxTs) };
}
function buildUserTouches(entries, ownHost) {
ownHost = (ownHost || '').replace(/^www\./, '').toLowerCase();
const userEntries = new Map();
for (const e of entries) {
const key = e.ip + '|' + e.userAgent;
if (!userEntries.has(key)) userEntries.set(key, []);
userEntries.get(key).push(e);
}
const result = new Map();
for (const [userKey, events] of userEntries) {
events.sort((a, b) => a.timestamp - b.timestamp);
const touches = [];
for (const e of events) {
const ref = e.referrer || '';
let domain = '';
if (ref && ref !== '-') {
domain = normalizeReferrerHost(ref);
if (ownHost && domain === ownHost) domain = '';
}
let utmKey = '';
const clickIds = [];
const qIdx = (e.path || '').indexOf('?');
if (qIdx !== -1) {
try {
const params = new URLSearchParams(e.path.substring(qIdx + 1));
const utm = extractUtmParams(params);
if (utm.source || utm.medium || utm.campaign) {
utmKey = (utm.source || '(leer)') + '|' + (utm.medium || '(leer)') + '|' + (utm.campaign || '(leer)');
}
for (const cid of CLICK_ID_KEYS) {
if (params.has(cid)) clickIds.push(cid);
}
} catch (_) {}
}
if (domain || utmKey || clickIds.length > 0) {
touches.push({ timestamp: e.timestamp, referrer: domain, campaign: utmKey, clickIds });
}
}
result.set(userKey, { events, touches });
}
return result;
}
function findAttributedTouch(touches, timestamp, attributionModel) {
if (!touches || touches.length === 0) return null;
if (attributionModel === 'first') return touches[0];
for (let i = touches.length - 1; i >= 0; i--) {
if (touches[i].timestamp <= timestamp) return touches[i];
}
return null;
}
function buildAttributionSources(entries, sessions, ownHost, attributionModel) {
const touchData = buildUserTouches(entries, ownHost);
const sessionsByUser = new Map();
for (const s of sessions) {
const key = s.ip + '|' + s.userAgent;
if (!sessionsByUser.has(key)) sessionsByUser.set(key, []);
sessionsByUser.get(key).push(s);
}
function getSessionId(e) {
const us = sessionsByUser.get(e.ip + '|' + e.userAgent);
if (!us) return null;
for (const s of us) if (e.timestamp >= s.start && e.timestamp <= s.end) return s.sessionId;
return null;
}
const makeBucket = () => ({ hits: new Map(), sessions: new Map(), users: new Map() });
const campaigns = makeBucket();
const referrers = makeBucket();
const clickIds = makeBucket();
function inc(bucket, key, userKey, sessId) {
bucket.hits.set(key, (bucket.hits.get(key) || 0) + 1);
if (!bucket.users.has(key)) bucket.users.set(key, new Set());
bucket.users.get(key).add(userKey);
if (sessId) {
if (!bucket.sessions.has(key)) bucket.sessions.set(key, new Set());
bucket.sessions.get(key).add(sessId);
}
}
for (const [userKey, { events, touches }] of touchData) {
for (const e of events) {
const touch = findAttributedTouch(touches, e.timestamp, attributionModel);
const sessId = getSessionId(e);
if (touch && touch.campaign) {
inc(campaigns, touch.campaign, userKey, sessId);
}
let refKey;
if (!touch) refKey = '(direkt)';
else if (!touch.referrer) refKey = '(intern/unbekannt)';
else refKey = touch.referrer;
inc(referrers, refKey, userKey, sessId);
if (touch && touch.clickIds && touch.clickIds.length > 0) {
for (const cidParam of touch.clickIds) {
inc(clickIds, cidParam, userKey, sessId);
}
}
}
}
return { campaigns, referrers, clickIds };
}
function buildConversionData(entries, conversionPatterns, attributionModel, ownHost) {
if (!conversionPatterns || conversionPatterns.length === 0) return {};
ownHost = (ownHost || '').replace(/^www\./, '').toLowerCase();
const touchData = buildUserTouches(entries, ownHost);
const result = {};
for (const cp of conversionPatterns) {
if (!result[cp.goalIndex]) {
result[cp.goalIndex] = { name: cp.goalName, countMode: cp.countMode, byReferrer: new Map(), byCampaign: new Map(), byClickId: new Map(), byBrowser: new Map(), byOS: new Map(), byDevice: new Map(), total: 0, dailyMap: new Map() };
}
}
for (const [userKey, { events, touches }] of touchData) {
for (const cp of conversionPatterns) {
const goal = result[cp.goalIndex];
const counted = new Set();
for (const e of events) {
if (!matchPattern(e.path, cp.pattern, cp.matchMode)) continue;
const uaInfo = classifyUserAgent(e.userAgent);
const touch = findAttributedTouch(touches, e.timestamp, attributionModel);
const countConversion = () => {
goal.total++;
const convDay = toLocalDateStr(e.timestamp);
goal.dailyMap.set(convDay, (goal.dailyMap.get(convDay) || 0) + 1);
goal.byBrowser.set(uaInfo.browser, (goal.byBrowser.get(uaInfo.browser) || 0) + 1);
goal.byOS.set(uaInfo.os, (goal.byOS.get(uaInfo.os) || 0) + 1);
goal.byDevice.set(uaInfo.device, (goal.byDevice.get(uaInfo.device) || 0) + 1);
};
if (!touch) {
const dedupKey = userKey + '|unattr';
if (!(cp.countMode === 'unique' && counted.has(dedupKey))) {
counted.add(dedupKey);
goal.byReferrer.set('(intern/unbekannt)', (goal.byReferrer.get('(intern/unbekannt)') || 0) + 1);
goal.byCampaign.set('(Andere)', (goal.byCampaign.get('(Andere)') || 0) + 1);
countConversion();
}
continue;
}
let attributed = false;
if (touch.referrer) {
const dedupKey = userKey + '|ref|' + touch.referrer;
if (!(cp.countMode === 'unique' && counted.has(dedupKey))) {
counted.add(dedupKey);
goal.byReferrer.set(touch.referrer, (goal.byReferrer.get(touch.referrer) || 0) + 1);
countConversion();
attributed = true;
}
} else {
const dedupKey = userKey + '|ref|(intern/unbekannt)';
if (!(cp.countMode === 'unique' && counted.has(dedupKey))) {
counted.add(dedupKey);
goal.byReferrer.set('(intern/unbekannt)', (goal.byReferrer.get('(intern/unbekannt)') || 0) + 1);
}
}
if (touch.campaign) {
const dedupKeyCamp = userKey + '|camp|' + touch.campaign;
if (!(cp.countMode === 'unique' && counted.has(dedupKeyCamp))) {
counted.add(dedupKeyCamp);
goal.byCampaign.set(touch.campaign, (goal.byCampaign.get(touch.campaign) || 0) + 1);
if (!attributed) {
countConversion();
attributed = true;
}
}
} else {
const dedupKeyCamp = userKey + '|camp|(Andere)';
if (!(cp.countMode === 'unique' && counted.has(dedupKeyCamp))) {
counted.add(dedupKeyCamp);
goal.byCampaign.set('(Andere)', (goal.byCampaign.get('(Andere)') || 0) + 1);
}
}
if (touch.clickIds && touch.clickIds.length > 0) {
for (const cidParam of touch.clickIds) {
const cidLabel = CLICK_ID_LABELS[cidParam] || cidParam;
const dedupKeyCid = userKey + '|cid|' + cidLabel;
if (!(cp.countMode === 'unique' && counted.has(dedupKeyCid))) {
counted.add(dedupKeyCid);
goal.byClickId.set(cidLabel, (goal.byClickId.get(cidLabel) || 0) + 1);
}
}
}
}
}
}
const serializable = {};
for (const [idx, goal] of Object.entries(result)) {
serializable[idx] = {
name: goal.name,
countMode: goal.countMode,
total: goal.total,
byReferrer: Object.fromEntries(goal.byReferrer),
byCampaign: Object.fromEntries(goal.byCampaign),
byClickId: Object.fromEntries(goal.byClickId),
byBrowser: Object.fromEntries(goal.byBrowser),
byOS: Object.fromEntries(goal.byOS),
byDevice: Object.fromEntries(goal.byDevice),
daily: Array.from(goal.dailyMap.entries())
.map(([day, count]) => ({ day, count }))
.sort((a, b) => a.day.localeCompare(b.day))
};
}
return serializable;
}
function buildAggregatesCore(entries, sessions) {
const byDay = new Map();
const statusCounts = new Map();
const pageCounts = new Map();
const botCounts = new Map();
const resourceTypeCounts = new Map();
const browserCounts = new Map();
const osCounts = new Map();
const deviceCounts = new Map();
const botDetails = new Map();
const referrerCounts = new Map();
const utmCombos = new Map();
const clickIdCounts = {};
for (const k of CLICK_ID_KEYS) clickIdCounts[k] = 0;
const queryParams = new Map();
const browserUsers = new Map(), browserSessions = new Map();
const osUsers = new Map(), osSessions = new Map();
const deviceUsers = new Map(), deviceSessions = new Map();
const referrerUsers = new Map(), referrerSessions = new Map();
const campaignUsers = new Map(), campaignSessions = new Map();
const clickIdUserSets = new Map(), clickIdSessionSets = new Map();
const sessionsByUser = new Map();
for (const s of sessions) {
const key = s.ip + '|' + s.userAgent;
if (!sessionsByUser.has(key)) sessionsByUser.set(key, []);
sessionsByUser.get(key).push(s);
}
function getSessionId(e) {
const key = e.ip + '|' + e.userAgent;
const userSessions = sessionsByUser.get(key);
if (!userSessions) return null;
for (const s of userSessions) {
if (e.timestamp >= s.start && e.timestamp <= s.end) return s.sessionId;
}
return null;
}
let attackCount = 0;
let totalBotBytes = 0;
let totalBrowserBytes = 0;
let totalAttackBytes = 0;
const botHeatmapMatrix = Array.from({ length: 7 }, () => new Array(24).fill(0));
const botHeatmapPerBot = new Map(); // botName → 7×24 matrix
const dayResponseTimes = new Map();
const allResponseTimes = [];
const methodCounts = new Map();
const hotlinkMap = new Map();
const ipMap = new Map();
for (const e of entries) {
const userKey = e.ip + '|' + e.userAgent;
const sessId = getSessionId(e);
const day = toLocalDateStr(e.timestamp);
if (!byDay.has(day)) {
byDay.set(day, { pageviews: 0, sessions: 0, users: new Set() });
}
const dayObj = byDay.get(day);
dayObj.pageviews += 1;
if (e.responseTime !== null && e.responseTime !== undefined) {
if (!dayResponseTimes.has(day)) dayResponseTimes.set(day, []);
dayResponseTimes.get(day).push(e.responseTime);
allResponseTimes.push(e.responseTime);
}
methodCounts.set(e.method, (methodCounts.get(e.method) || 0) + 1);
const basePath = e.path.split('?')[0];
const resType = classifyResourceType(e.path);
const isBotEff = isEffectiveBot(e);
const botName = isBotEff ? getEffectiveBotName(e) : null;
if (!ipMap.has(e.ip)) {
ipMap.set(e.ip, {
hits: 0, bytes: 0,
statusGroups: { s2xx: 0, s3xx: 0, s4xx: 0, s5xx: 0 },
paths: new Set(),
clickIds: new Set(), clickIdHits: 0, clickIdDays: new Set(),
campaigns: new Set(),
isBot: false, isAttack: false, botNames: new Set(),
hourCounts: new Map(),
firstSeen: e.timestamp, lastSeen: e.timestamp
});
}
const ipData = ipMap.get(e.ip);
ipData.hits++;
ipData.bytes += (e.bytes || 0);
const st = e.status;
if (st >= 200 && st < 300) ipData.statusGroups.s2xx++;
else if (st >= 300 && st < 400) ipData.statusGroups.s3xx++;
else if (st >= 400 && st < 500) ipData.statusGroups.s4xx++;
else if (st >= 500 && st < 600) ipData.statusGroups.s5xx++;
ipData.paths.add(basePath);
const ipHourKey = day + ' ' + String(e.timestamp.getHours()).padStart(2, '0');
ipData.hourCounts.set(ipHourKey, (ipData.hourCounts.get(ipHourKey) || 0) + 1);
ipData.isBot = ipData.isBot || isBotEff;
if (botName && botName !== 'Unbekannt') ipData.botNames.add(botName);
ipData.isAttack = ipData.isAttack || e.isAttack;
if (e.timestamp < ipData.firstSeen) ipData.firstSeen = e.timestamp;
if (e.timestamp > ipData.lastSeen) ipData.lastSeen = e.timestamp;
if (e.referrer && e.referrer !== '-') {
try {
const refDomain = new URL(e.referrer).hostname.toLowerCase();
const ownLower = (typeof AppState !== 'undefined' && AppState.ownHost || '').toLowerCase();
if (refDomain && refDomain !== ownLower && refDomain !== 'www.' + ownLower && 'www.' + refDomain !== ownLower) {
if (HOTLINK_RESOURCE_TYPES.indexOf(resType) !== -1) {
if (!hotlinkMap.has(refDomain)) hotlinkMap.set(refDomain, { hits: 0, bytes: 0, byType: new Map(), byPath: new Map() });
const hl = hotlinkMap.get(refDomain);
hl.hits += 1;
hl.bytes += (e.bytes || 0);
if (!hl.byType.has(resType)) hl.byType.set(resType, { hits: 0, bytes: 0 });
const bt = hl.byType.get(resType);
bt.hits += 1;
bt.bytes += (e.bytes || 0);
if (!hl.byPath.has(e.path)) hl.byPath.set(e.path, { hits: 0, bytes: 0, type: resType });
const bp = hl.byPath.get(e.path);
bp.hits += 1;
bp.bytes += (e.bytes || 0);
}
}
} catch (ex) { /* ungueltige Referrer-URL ignorieren */ }
}
const statusKey = e.status.toString();
statusCounts.set(statusKey, (statusCounts.get(statusKey) || 0) + 1);
const pathKey = e.path || "/";
const pcData = pageCounts.get(pathKey);
if (pcData) { pcData.hits++; pcData.bytes += (e.bytes || 0); if (isBotEff) pcData.botHits++; else pcData.userHits++; }
else { pageCounts.set(pathKey, { hits: 1, botHits: isBotEff ? 1 : 0, userHits: isBotEff ? 0 : 1, bytes: e.bytes || 0 }); }
if (e.isAttack) attackCount++;
const botKey = isBotEff ? "bot" : "human";
botCounts.set(botKey, (botCounts.get(botKey) || 0) + 1);
const entryBytes = e.bytes || 0;
if (e.isAttack) { totalAttackBytes += entryBytes; }
else if (isBotEff) { totalBotBytes += entryBytes; }
else { totalBrowserBytes += entryBytes; }
const rtData = resourceTypeCounts.get(resType);
if (rtData) { rtData.hits++; rtData.bytes += (e.bytes || 0); }
else { resourceTypeCounts.set(resType, { hits: 1, bytes: e.bytes || 0 }); }
const ref = e.referrer || '';
if (ref && ref !== '-') {
let domain = normalizeReferrerHost(ref) || '(direkt)';
const ownHost = (typeof AppState !== 'undefined') ? (AppState.ownHost || '').replace(/^www\./, '').toLowerCase() : '';
if (ownHost && domain === ownHost) {
domain = '(intern/unbekannt)';
}
referrerCounts.set(domain, (referrerCounts.get(domain) || 0) + 1);
if (!referrerUsers.has(domain)) referrerUsers.set(domain, new Set());
referrerUsers.get(domain).add(userKey);
if (!referrerSessions.has(domain)) referrerSessions.set(domain, new Set());
if (sessId) referrerSessions.get(domain).add(sessId);
}
const qIdx = pathKey.indexOf('?');
if (qIdx !== -1) {
try {
const params = new URLSearchParams(pathKey.substring(qIdx + 1));
const utm = extractUtmParams(params);
if (utm.source || utm.medium || utm.campaign) {
const comboKey = (utm.source || '(leer)') + '|' + (utm.medium || '(leer)') + '|' + (utm.campaign || '(leer)');
utmCombos.set(comboKey, (utmCombos.get(comboKey) || 0) + 1);
ipData.campaigns.add(comboKey);
if (!campaignUsers.has(comboKey)) campaignUsers.set(comboKey, new Set());
campaignUsers.get(comboKey).add(userKey);
if (!campaignSessions.has(comboKey)) campaignSessions.set(comboKey, new Set());
if (sessId) campaignSessions.get(comboKey).add(sessId);
}
for (const cid of CLICK_ID_KEYS) {
if (params.has(cid)) {
clickIdCounts[cid]++;
ipData.clickIds.add(cid);
ipData.clickIdHits++;
ipData.clickIdDays.add(day);
if (!clickIdUserSets.has(cid)) clickIdUserSets.set(cid, new Set());
clickIdUserSets.get(cid).add(userKey);
if (!clickIdSessionSets.has(cid)) clickIdSessionSets.set(cid, new Set());
if (sessId) clickIdSessionSets.get(cid).add(sessId);
}
}
const basePath = pathKey.split('?')[0] || '/';
for (const [key, val] of params.entries()) {
if (!queryParams.has(key)) {
queryParams.set(key, { count: 0, values: new Map(), paths: new Map() });
}
const qp = queryParams.get(key);
qp.count++;
const normalizedVal = val || '(leer)';
qp.values.set(normalizedVal, (qp.values.get(normalizedVal) || 0) + 1);
qp.paths.set(basePath, (qp.paths.get(basePath) || 0) + 1);
}
} catch (_) {}
}
if (botKey === "bot") {
if (!botDetails.has(botName)) {
botDetails.set(botName, { hits: 0, ips: new Set(), bytes: 0, pages: new Map(), statusCounts: new Map(), responseTimes: [] });
}
const detail = botDetails.get(botName);
detail.hits += 1;
detail.ips.add(e.ip);
detail.bytes += (e.bytes || 0);
detail.pages.set(pathKey, (detail.pages.get(pathKey) || 0) + 1);
const st = e.status || 0;
detail.statusCounts.set(st, (detail.statusCounts.get(st) || 0) + 1);
if (e.responseTime !== null && e.responseTime !== undefined) {
detail.responseTimes.push(e.responseTime);
}
const jsDay = e.timestamp.getDay();
const isoDay = jsDay === 0 ? 6 : jsDay - 1;
const hour = e.timestamp.getHours();
botHeatmapMatrix[isoDay][hour]++;
if (!botHeatmapPerBot.has(botName)) {
botHeatmapPerBot.set(botName, Array.from({ length: 7 }, () => new Array(24).fill(0)));
}
botHeatmapPerBot.get(botName)[isoDay][hour]++;
}
if (!isBotEff) {
const uaInfo = classifyUserAgent(e.userAgent);
browserCounts.set(uaInfo.browser, (browserCounts.get(uaInfo.browser) || 0) + 1);
if (!browserUsers.has(uaInfo.browser)) browserUsers.set(uaInfo.browser, new Set());
browserUsers.get(uaInfo.browser).add(userKey);
if (!browserSessions.has(uaInfo.browser)) browserSessions.set(uaInfo.browser, new Set());
if (sessId) browserSessions.get(uaInfo.browser).add(sessId);
osCounts.set(uaInfo.os, (osCounts.get(uaInfo.os) || 0) + 1);
if (!osUsers.has(uaInfo.os)) osUsers.set(uaInfo.os, new Set());
osUsers.get(uaInfo.os).add(userKey);
if (!osSessions.has(uaInfo.os)) osSessions.set(uaInfo.os, new Set());
if (sessId) osSessions.get(uaInfo.os).add(sessId);
deviceCounts.set(uaInfo.device, (deviceCounts.get(uaInfo.device) || 0) + 1);
if (!deviceUsers.has(uaInfo.device)) deviceUsers.set(uaInfo.device, new Set());
deviceUsers.get(uaInfo.device).add(userKey);
if (!deviceSessions.has(uaInfo.device)) deviceSessions.set(uaInfo.device, new Set());
if (sessId) deviceSessions.get(uaInfo.device).add(sessId);
}
}
for (const s of sessions) {
const day = toLocalDateStr(s.start);
if (!byDay.has(day)) {
byDay.set(day, { pageviews: 0, sessions: 0, users: new Set() });
}
const dayObj = byDay.get(day);
dayObj.sessions += 1;
dayObj.users.add(s.ip + "|" + s.userAgent);
}
return {
byDay, statusCounts, pageCounts, botCounts, resourceTypeCounts, methodCounts, hotlinkMap, ipMap,
browserCounts, osCounts, deviceCounts, botDetails,
referrerCounts, utmCombos, clickIdCounts, queryParams,
browserUsers, browserSessions, osUsers, osSessions,
deviceUsers, deviceSessions, referrerUsers, referrerSessions,
campaignUsers, campaignSessions, clickIdUserSets, clickIdSessionSets,
attackCount, botHeatmapMatrix, botHeatmapPerBot,
dayResponseTimes, allResponseTimes,
totalBotBytes, totalBrowserBytes, totalAttackBytes
};
}
function computePercentile(sortedArr, p) {
if (sortedArr.length === 0) return null;
const idx = Math.ceil(p * sortedArr.length) - 1;
return sortedArr[Math.max(0, idx)];
}
function calcThreshold(values, absMin) {
if (values.length === 0) return absMin;
const sorted = values.slice().sort((a, b) => a - b);
const median = computePercentile(sorted, 0.5);
const mean = sorted.reduce((s, v) => s + v, 0) / sorted.length;
const variance = sorted.reduce((s, v) => s + (v - mean) ** 2, 0) / sorted.length;
const sigma = Math.sqrt(variance);
return Math.max(median + 2 * sigma, absMin);
}
function deduplicateSessionPages(session) {
var pageOnly = session.pages.filter(function(p) { return classifyResourceType(p) === 'Seite'; });
if (pageOnly.length === 0) return [];
var deduped = [pageOnly[0]];
for (var i = 1; i < pageOnly.length; i++) {
if (pageOnly[i] !== deduped[deduped.length - 1]) {
deduped.push(pageOnly[i]);
}
}
return deduped;
}
function buildSessionAnalysis(sessions) {
var journeyMap = new Map();
var entryMap = new Map();
var pathLengths = [];
var durations = [];
var bounceCount = 0;
var totalPages = 0;
var bucketLimits = [1000, 10000, 30000, 60000, 300000, 900000, 1800000];
var bucketLabels = ['0s', '1\u201310s', '10\u201330s', '30s\u20131m', '1\u20135m', '5\u201315m', '15\u201330m', '30m+'];
var histogram = bucketLabels.map(function(label) { return { label: label, count: 0 }; });
for (var i = 0; i < sessions.length; i++) {
var deduped = deduplicateSessionPages(sessions[i]);
if (deduped.length === 0) continue;
var len = deduped.length;
var dur = sessions[i].duration || 0;
pathLengths.push(len);
durations.push(dur);
totalPages += len;
if (len === 1) bounceCount++;
var key = deduped.join('\t');
var existing = journeyMap.get(key);
if (existing) {
existing.count++;
existing.totalDuration += dur;
} else {
journeyMap.set(key, { journey: deduped, count: 1, totalDuration: dur });
}
var entry = deduped[0];
var entryData = entryMap.get(entry);
if (entryData) {
entryData.sessions++;
if (len === 1) entryData.bounces++;
} else {
entryMap.set(entry, { sessions: 1, bounces: len === 1 ? 1 : 0 });
}
var bucketIdx = bucketLimits.length;
for (var b = 0; b < bucketLimits.length; b++) {
if (dur < bucketLimits[b]) { bucketIdx = b; break; }
}
histogram[bucketIdx].count++;
}
var total = pathLengths.length;
function median(arr) {
if (arr.length === 0) return 0;
var sorted = arr.slice().sort(function(a, b) { return a - b; });
var mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
var journeys = Array.from(journeyMap.values())
.map(function(j) {
return { journey: j.journey, count: j.count, avgDuration: j.count > 0 ? j.totalDuration / j.count : 0 };
})
.sort(function(a, b) { return b.count - a.count; });
var bounceByEntry = Array.from(entryMap.entries())
.map(function(pair) {
return {
path: pair[0],
sessions: pair[1].sessions,
bounces: pair[1].bounces,
bounceRate: pair[1].sessions > 0 ? Math.round(pair[1].bounces / pair[1].sessions * 1000) / 10 : 0
};
})
.sort(function(a, b) { return b.sessions - a.sessions; });
return {
kpis: {
totalSessions: total,
bounceRate: total > 0 ? Math.round(bounceCount / total * 1000) / 10 : 0,
avgPagesPerSession: total > 0 ? Math.round(totalPages / total * 100) / 100 : 0,
medianPathLength: median(pathLengths),
medianDuration: median(durations)
},
durationHistogram: histogram,
journeys: journeys,
bounceByEntry: bounceByEntry
};
}
function buildNavigationOverview(sessions, pattern, matchMode, negate) {
var previousCounts = new Map();
var nextCounts = new Map();
var totalHits = 0;
var entries = 0;
var exits = 0;
for (var i = 0; i < sessions.length; i++) {
var deduped = deduplicateSessionPages(sessions[i]);
if (deduped.length === 0) continue;
for (var j = 0; j < deduped.length; j++) {
var matched = matchPattern(deduped[j], pattern, matchMode);
if (negate) matched = !matched;
if (!matched) continue;
totalHits++;
if (j > 0) {
var prev = deduped[j - 1];
previousCounts.set(prev, (previousCounts.get(prev) || 0) + 1);
} else {
entries++;
}
if (j < deduped.length - 1) {
var next = deduped[j + 1];
nextCounts.set(next, (nextCounts.get(next) || 0) + 1);
} else {
exits++;
}
}
}
var totalPrev = 0;
previousCounts.forEach(function(c) { totalPrev += c; });
var totalNext = 0;
nextCounts.forEach(function(c) { totalNext += c; });
var previousPages = Array.from(previousCounts.entries())
.map(function(e) {
return { path: e[0], count: e[1], percent: totalPrev ? +(e[1] / totalPrev * 100).toFixed(1) : 0 };
})
.sort(function(a, b) { return b.count - a.count; });
var nextPages = Array.from(nextCounts.entries())
.map(function(e) {
return { path: e[0], count: e[1], percent: totalNext ? +(e[1] / totalNext * 100).toFixed(1) : 0 };
})
.sort(function(a, b) { return b.count - a.count; });
return {
pattern: pattern,
totalHits: totalHits,
entries: entries,
exits: exits,
entriesPercent: totalHits ? +(entries / totalHits * 100).toFixed(1) : 0,
exitsPercent: totalHits ? +(exits / totalHits * 100).toFixed(1) : 0,
previousPages: previousPages,
nextPages: nextPages
};
}
function finalizeAggregates(maps, entries, sessions) {
const {
byDay, statusCounts, pageCounts, botCounts, resourceTypeCounts, methodCounts, hotlinkMap, ipMap,
browserCounts, osCounts, deviceCounts, botDetails,
referrerCounts, utmCombos, clickIdCounts, queryParams,
browserUsers, browserSessions, osUsers, osSessions,
deviceUsers, deviceSessions, referrerUsers, referrerSessions,
campaignUsers, campaignSessions, clickIdUserSets, clickIdSessionSets,
attackCount, botHeatmapMatrix, botHeatmapPerBot,
dayResponseTimes, allResponseTimes,
totalBotBytes, totalBrowserBytes, totalAttackBytes
} = maps;
const daily = Array.from(byDay.entries()).map(([day, v]) => {
const rts = dayResponseTimes.get(day);
let rtMedian = null, rtAvg = null, rtP95 = null;
if (rts && rts.length > 0) {
const sorted = rts.slice().sort((a, b) => a - b);
rtMedian = computePercentile(sorted, 0.5);
rtP95 = computePercentile(sorted, 0.95);
rtAvg = Math.round(sorted.reduce((s, v) => s + v, 0) / sorted.length);
}
return {
day,
pageviews: v.pageviews,
sessions: v.sessions,
users: v.users.size,
responseTimeMedian: rtMedian,
responseTimeAvg: rtAvg,
responseTimeP95: rtP95
};
}).sort((a, b) => a.day.localeCompare(b.day));
const topPages = Array.from(pageCounts.entries())
.map(([path, d]) => ({ path, count: d.hits, botHits: d.botHits, userHits: d.userHits, bytes: d.bytes }))
.sort((a, b) => b.count - a.count);
const statusList = Array.from(statusCounts.entries())
.map(([status, count]) => ({ status: parseInt(status, 10), count }))
.sort((a, b) => a.status - b.status);
const methodList = Array.from(methodCounts.entries())
.map(([method, count]) => ({ method, count }))
.sort((a, b) => b.count - a.count);
const hotlinkList = Array.from(hotlinkMap.entries())
.map(([domain, d]) => ({
domain,
hits: d.hits,
bytes: d.bytes,
byType: Array.from(d.byType.entries())
.map(([type, t]) => ({ type, hits: t.hits, bytes: t.bytes }))
.sort((a, b) => b.bytes - a.bytes),
byPath: Array.from(d.byPath.entries())
.map(([path, p]) => ({ path, hits: p.hits, bytes: p.bytes, type: p.type }))
.sort((a, b) => b.hits - a.hits)
}))
.sort((a, b) => b.bytes - a.bytes);
const bots = Array.from(botCounts.entries())
.map(([type, count]) => ({ type, count }));
const botDetailList = Array.from(botDetails.entries())
.map(([name, d]) => {
const sortedRT = d.responseTimes.slice().sort((a, b) => a - b);
return {
name,
category: classifyBot(name),
aiPurpose: getAiPurpose(name),
hits: d.hits,
uniqueIPs: d.ips.size,
bytes: d.bytes,
uniquePaths: d.pages.size,
topPages: Array.from(d.pages.entries())
.map(([path, count]) => ({ path, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 25),
statusCounts: Object.fromEntries(d.statusCounts),
responseTimeMedian: computePercentile(sortedRT, 0.5),
responseTimeP95: computePercentile(sortedRT, 0.95)
};
})
.sort((a, b) => b.hits - a.hits);
const catAccum = new Map();
const aiPurposeAccum = new Map();
for (const bot of botDetailList) {
if (!catAccum.has(bot.category)) {
catAccum.set(bot.category, { hits: 0, botCount: 0, status429: 0, status408: 0, statusError: 0, responseTimes: [] });
}
const c = catAccum.get(bot.category);
c.hits += bot.hits;
c.botCount += 1;
c.status429 += (bot.statusCounts[429] || 0);
c.status408 += (bot.statusCounts[408] || 0);
for (const [code, count] of Object.entries(bot.statusCounts)) {
const s = parseInt(code, 10);
if (s >= 400 && s <= 599) c.statusError += count;
}
const detail = botDetails.get(bot.name);
if (detail && detail.responseTimes.length > 0) {
c.responseTimes.push(...detail.responseTimes);
}
if (bot.category === 'ai' && bot.aiPurpose) {
if (!aiPurposeAccum.has(bot.aiPurpose)) {
aiPurposeAccum.set(bot.aiPurpose, { hits: 0, botCount: 0, status429: 0, status408: 0, statusError: 0, responseTimes: [] });
}
const p = aiPurposeAccum.get(bot.aiPurpose);
p.hits += bot.hits;
p.botCount += 1;
p.status429 += (bot.statusCounts[429] || 0);
p.status408 += (bot.statusCounts[408] || 0);
for (const [code, count] of Object.entries(bot.statusCounts)) {
const s = parseInt(code, 10);
if (s >= 400 && s <= 599) p.statusError += count;
}
if (detail && detail.responseTimes.length > 0) {
p.responseTimes.push(...detail.responseTimes);
}
}
}
function buildCatStat(category, label, c, indent) {
const sortedRT = c.responseTimes.slice().sort((a, b) => a - b);
return {
category, label, indent: !!indent,
hits: c.hits, botCount: c.botCount,
rate429: c.hits > 0 ? (c.status429 / c.hits * 100) : 0,
rate408: c.hits > 0 ? (c.status408 / c.hits * 100) : 0,
rateError: c.hits > 0 ? (c.statusError / c.hits * 100) : 0,
responseTimeMedian: computePercentile(sortedRT, 0.5),
responseTimeP95: computePercentile(sortedRT, 0.95)
};
}
const botCategoryStats = Array.from(catAccum.entries())
.filter(([_, c]) => c.hits > 0)
.map(([category, c]) => buildCatStat(category, BOT_DEFINITIONS.categories[category] || category, c, false))
.sort((a, b) => b.hits - a.hits || a.label.localeCompare(b.label));
if (aiPurposeAccum.size > 0) {
const aiIdx = botCategoryStats.findIndex(s => s.category === 'ai');
if (aiIdx !== -1) {
const subRows = Array.from(aiPurposeAccum.entries())
.filter(([_, c]) => c.hits > 0)
.map(([purpose, c]) => buildCatStat('ai-' + purpose, AI_PURPOSE_LABELS[purpose] || purpose, c, true))
.sort((a, b) => b.hits - a.hits);
botCategoryStats.splice(aiIdx + 1, 0, ...subRows);
}
}
const resourceTypes = Array.from(resourceTypeCounts.entries())
.map(([type, d]) => ({ type, count: d.hits, bytes: d.bytes }))
.sort((a, b) => b.count - a.count);
const entryPageCounts = new Map();
const exitPageCounts = new Map();
const sequenceCounts = new Map();
for (const session of sessions) {
const deduped = deduplicateSessionPages(session);
if (deduped.length === 0) continue;
entryPageCounts.set(deduped[0], (entryPageCounts.get(deduped[0]) || 0) + 1);
exitPageCounts.set(deduped[deduped.length - 1], (exitPageCounts.get(deduped[deduped.length - 1]) || 0) + 1);
for (let i = 0; i < deduped.length - 1; i++) {
const key = deduped[i] + '\t' + deduped[i + 1];
sequenceCounts.set(key, (sequenceCounts.get(key) || 0) + 1);
}
}
const entryPages = Array.from(entryPageCounts.entries())
.map(([path, count]) => ({ path, count }))
.sort((a, b) => b.count - a.count);
const exitPages = Array.from(exitPageCounts.entries())
.map(([path, count]) => ({ path, count }))
.sort((a, b) => b.count - a.count);
const pathSequences = Array.from(sequenceCounts.entries())
.map(([key, count]) => {
const [from, to] = key.split('\t');
return { from, to, count };
})
.sort((a, b) => b.count - a.count);
if (typeof AppState !== 'undefined' && !AppState.ownHost && referrerCounts.size > 0) {
const rawTop = Array.from(referrerCounts.entries()).sort((a, b) => b[1] - a[1]);
const candidate = rawTop[0][0];
if (candidate && candidate !== '(direkt)') {
AppState.ownHost = candidate;
}
}
const conversionPatterns = (typeof AppState !== 'undefined' && AppState.patterns)
? AppState.patterns.filter(p => p.type === 'conversion')
: [];
const attributionModel = (typeof AppState !== 'undefined') ? (AppState.attributionModel || 'last') : 'last';
const ownHost = (typeof AppState !== 'undefined') ? (AppState.ownHost || '').replace(/^www\./, '').toLowerCase() : '';
const attrSources = buildAttributionSources(entries, sessions, ownHost, attributionModel);
const topReferrers = Array.from(attrSources.referrers.hits.entries())
.map(([domain, count]) => ({
domain, count,
sessions: attrSources.referrers.sessions.has(domain) ? attrSources.referrers.sessions.get(domain).size : 0,
users: attrSources.referrers.users.has(domain) ? attrSources.referrers.users.get(domain).size : 0
}))
.sort((a, b) => b.count - a.count);
const campaigns = Array.from(attrSources.campaigns.hits.entries())
.map(([key, count]) => {
const [source, medium, campaign] = key.split('|');
return {
source, medium, campaign, count,
sessions: attrSources.campaigns.sessions.has(key) ? attrSources.campaigns.sessions.get(key).size : 0,
users: attrSources.campaigns.users.has(key) ? attrSources.campaigns.users.get(key).size : 0
};
})
.sort((a, b) => b.count - a.count);
const clickIdAttr = {};
const clickIdSessionsAttr = {};
const clickIdUsersAttr = {};
for (const k of CLICK_ID_KEYS) clickIdAttr[k] = 0;
for (const [param, count] of attrSources.clickIds.hits) {
clickIdAttr[param] = count;
clickIdSessionsAttr[param] = attrSources.clickIds.sessions.has(param) ? attrSources.clickIds.sessions.get(param).size : 0;
clickIdUsersAttr[param] = attrSources.clickIds.users.has(param) ? attrSources.clickIds.users.get(param).size : 0;
}
const conversionData = buildConversionData(entries, conversionPatterns, attributionModel, ownHost);
const range = getTimestampRange(entries);
const dateRange = range ? { min: toLocalDateStr(range.min), max: toLocalDateStr(range.max) } : null;
let globalRTMedian = null, globalRTAvg = null, globalRTP95 = null;
if (allResponseTimes.length > 0) {
const sortedAll = allResponseTimes.slice().sort((a, b) => a - b);
globalRTMedian = computePercentile(sortedAll, 0.5);
globalRTP95 = computePercentile(sortedAll, 0.95);
globalRTAvg = Math.round(sortedAll.reduce((s, v) => s + v, 0) / sortedAll.length);
}
const ipEntries = Array.from(ipMap.entries())
.filter(([, d]) => d.hits >= 5)
.map(([ip, d]) => {
let maxHitsPerHour = 0;
for (const c of d.hourCounts.values()) {
if (c > maxHitsPerHour) maxHitsPerHour = c;
}
return {
ip,
subnet: getSubnet(ip),
hits: d.hits,
maxHitsPerHour,
bytes: d.bytes,
errorRate: d.hits > 0 ? (d.statusGroups.s4xx + d.statusGroups.s5xx) / d.hits * 100 : 0,
pathCount: d.paths.size,
statusGroups: d.statusGroups,
isBot: d.isBot,
botNames: Array.from(d.botNames),
isAttack: d.isAttack,
firstSeen: d.firstSeen,
lastSeen: d.lastSeen
};
});
const rateThreshold = calcThreshold(ipEntries.map(e => e.maxHitsPerHour), 10);
const errorThreshold = calcThreshold(ipEntries.map(e => e.errorRate), 20);
const pathThreshold = calcThreshold(ipEntries.map(e => e.pathCount), 10);
const ipAnomalyList = ipEntries
.map(e => {
const flags = [];
if (e.maxHitsPerHour >= rateThreshold) flags.push('Rate');
if (e.errorRate >= errorThreshold && e.hits >= 5) flags.push('Fehler');
if (e.pathCount >= pathThreshold) flags.push('Scan');
return { ...e, flags };
})
.filter(e => e.flags.length > 0)
.sort((a, b) => b.hits - a.hits);
const subnetMap = new Map();
for (const [ip, d] of ipMap.entries()) {
if (d.clickIdHits === 0) continue;
const subnet = getSubnet(ip);
if (!subnetMap.has(subnet)) subnetMap.set(subnet, []);
subnetMap.get(subnet).push({ ip, data: d });
}
const campaignQualityList = [];
for (const [subnet, ips] of subnetMap.entries()) {
const signals = [];
if (ips.length >= 2) signals.push('Cluster');
if (ips.some(({ data: d }) => d.clickIdHits / d.hits > 0.8)) signals.push('Ad-dominiert');
if (ips.some(({ data: d }) => d.clickIdDays.size >= 3)) signals.push('Wiederkehrend');
if (signals.length === 0) continue;
const allDays = new Set();
for (const { data: d } of ips) {
for (const day of d.clickIdDays) allDays.add(day);
}
campaignQualityList.push({
subnet,
signals,
totalHits: ips.reduce((s, { data: d }) => s + d.hits, 0),
totalClickIdHits: ips.reduce((s, { data: d }) => s + d.clickIdHits, 0),
totalClickIdDays: allDays.size,
ips: ips.map(({ ip, data: d }) => ({
ip,
hits: d.hits,
clickIdHits: d.clickIdHits,
clickIdDays: d.clickIdDays.size,
clickIds: Array.from(d.clickIds),
campaigns: Array.from(d.campaigns),
isBot: d.isBot,
botNames: Array.from(d.botNames)
})).sort((a, b) => b.clickIdHits - a.clickIdHits)
});
}
campaignQualityList.sort((a, b) => b.totalClickIdHits - a.totalClickIdHits);
return {
daily,
dateRange,
topPages,
statusList,
methodList,
hotlinkList,
bots,
botDetailList,
botCategoryStats,
resourceTypes,
browserList: Array.from(browserCounts.entries())
.map(([name, count]) => ({
name, count,
sessions: browserSessions.has(name) ? browserSessions.get(name).size : 0,
users: browserUsers.has(name) ? browserUsers.get(name).size : 0
}))
.sort((a, b) => b.count - a.count),
osList: Array.from(osCounts.entries())
.map(([name, count]) => ({
name, count,
sessions: osSessions.has(name) ? osSessions.get(name).size : 0,
users: osUsers.has(name) ? osUsers.get(name).size : 0
}))
.sort((a, b) => b.count - a.count),
deviceList: Array.from(deviceCounts.entries())
.map(([name, count]) => ({
name, count,
sessions: deviceSessions.has(name) ? deviceSessions.get(name).size : 0,
users: deviceUsers.has(name) ? deviceUsers.get(name).size : 0
}))
.sort((a, b) => b.count - a.count),
entryPages,
exitPages,
pathSequences,
topReferrers,
campaigns,
clickIds: clickIdAttr,
clickIdSessions: clickIdSessionsAttr,
clickIdUsers: clickIdUsersAttr,
attackCount,
responseTimeMedian: globalRTMedian,
responseTimeAvg: globalRTAvg,
responseTimeP95: globalRTP95,
botHeatmap: botHeatmapMatrix,
botHeatmapPerBot: Object.fromEntries(botHeatmapPerBot),
queryParamList: Array.from(queryParams.entries())
.map(([name, d]) => ({
name,
count: d.count,
topValues: Array.from(d.values.entries())
.map(([value, count]) => ({ value, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 20),
topPaths: Array.from(d.paths.entries())
.map(([path, count]) => ({ path, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10)
}))
.sort((a, b) => b.count - a.count),
conversionData,
ipAnomalyList,
campaignQualityList,
totalBotBytes,
totalBrowserBytes,
totalAttackBytes
};
}
function buildAggregates(entries, sessions) {
const maps = buildAggregatesCore(entries, sessions);
return finalizeAggregates(maps, entries, sessions);
}
function buildHourlyData(entries) {
if (!entries.length) return [];
const daySet = new Set();
const hourMap = new Map();
for (const e of entries) {
const dayStr = toLocalDateStr(e.timestamp);
daySet.add(dayStr);
const hour = e.timestamp.getHours();
const key = dayStr + ' ' + String(hour).padStart(2, '0');
if (!hourMap.has(key)) {
hourMap.set(key, { pageviews: 0, responseTimes: [] });
}
const h = hourMap.get(key);
h.pageviews += 1;
if (e.responseTime !== null && e.responseTime !== undefined) {
h.responseTimes.push(e.responseTime);
}
}
const days = Array.from(daySet).sort();
const result = [];
for (const day of days) {
const parts = day.split('-');
const ddmm = parts[2] + '.' + parts[1] + '.';
for (let h = 0; h < 24; h++) {
const hStr = String(h).padStart(2, '0');
const key = day + ' ' + hStr;
const data = hourMap.get(key) || { pageviews: 0, responseTimes: [] };
let rtMedian = null, rtAvg = null, rtP95 = null;
if (data.responseTimes.length > 0) {
const sorted = data.responseTimes.slice().sort((a, b) => a - b);
rtMedian = computePercentile(sorted, 0.5);
rtP95 = computePercentile(sorted, 0.95);
rtAvg = Math.round(sorted.reduce((s, v) => s + v, 0) / sorted.length);
}
result.push({
hour: key,
label: ddmm + ' ' + hStr + ':00',
pageviews: data.pageviews,
responseTimeMedian: rtMedian,
responseTimeAvg: rtAvg,
responseTimeP95: rtP95
});
}
}
return result;
}
function getSubnet(ip) {
if (ip.startsWith('::ffff:')) {
ip = ip.slice(7);
}
if (ip.indexOf('.') !== -1) {
return ip.split('.').slice(0, 3).join('.');
}
const halves = ip.split('::');
let blocks;
if (halves.length === 2) {
const left = halves[0] ? halves[0].split(':') : [];
const right = halves[1] ? halves[1].split(':') : [];
const missing = 8 - left.length - right.length;
blocks = left.concat(Array(missing).fill('0')).concat(right);
} else {
blocks = ip.split(':');
}
return blocks.slice(0, 3).join(':');
}
function buildTimelineData(entries, dimension, values, forceGranularity) {
if (!entries.length) return { buckets: [], granularity: forceGranularity || 'day' };
var minDate = entries[0].timestamp, maxDate = entries[0].timestamp;
for (var i = 1; i < entries.length; i++) {
if (entries[i].timestamp < minDate) minDate = entries[i].timestamp;
if (entries[i].timestamp > maxDate) maxDate = entries[i].timestamp;
}
var daySpan = Math.round((maxDate - minDate) / 86400000) + 1;
var granularity = forceGranularity;
if (!granularity) {
if (daySpan <= 3) granularity = 'hour';
else if (daySpan <= 60) granularity = 'day';
else if (daySpan <= 365) granularity = 'week';
else granularity = 'month';
}
var isStacked = !values || values.length === 0;
function getEntity(e) {
switch (dimension) {
case 'statusCode': {
var s = e.status;
var code = String(s);
var cls = Math.floor(s / 100) + 'xx';
if (isStacked) return cls;
for (var v = 0; v < values.length; v++) {
if (values[v] === code) return code;
if (values[v] === cls) return cls;
}
return null;
}
case 'trafficType':
if (e.isAttack) return 'Angriffe';
if (isEffectiveBot(e)) return 'Bots';
return 'Browser';
case 'botCategory': {
if (!isEffectiveBot(e)) return null;
var botName = getEffectiveBotName(e);
var cat = classifyBot(botName);
var label = (typeof BOT_CATEGORY_LABELS !== 'undefined' && BOT_CATEGORY_LABELS[cat]) || cat;
if (isStacked) return label;
for (var v2 = 0; v2 < values.length; v2++) {
if (values[v2] === label || values[v2] === cat) return label;
}
return null;
}
case 'device': {
if (isEffectiveBot(e)) return null;
var uaD = classifyUserAgent(e.userAgent);
if (isStacked) return uaD.device;
return values.indexOf(uaD.device) !== -1 ? uaD.device : null;
}
case 'resourceType': {
var rt = classifyResourceType(e.path);
if (isStacked) return rt;
return values.indexOf(rt) !== -1 ? rt : null;
}
case 'browser': {
if (isEffectiveBot(e)) return null;
var uaB = classifyUserAgent(e.userAgent);
if (isStacked) return uaB.browser;
return values.indexOf(uaB.browser) !== -1 ? uaB.browser : null;
}
case 'os': {
if (isEffectiveBot(e)) return null;
var uaO = classifyUserAgent(e.userAgent);
if (isStacked) return uaO.os;
return values.indexOf(uaO.os) !== -1 ? uaO.os : null;
}
case 'method':
if (isStacked) return e.method;
return values.indexOf(e.method) !== -1 ? e.method : null;
default:
return null;
}
}
function bucketKey(ts) {
var y = ts.getFullYear();
var m = String(ts.getMonth() + 1).padStart(2, '0');
var d = String(ts.getDate()).padStart(2, '0');
if (granularity === 'hour') {
return y + '-' + m + '-' + d + ' ' + String(ts.getHours()).padStart(2, '0') + ':00';
}
if (granularity === 'day') {
return y + '-' + m + '-' + d;
}
if (granularity === 'week') {
var dt = new Date(ts.getTime());
dt.setHours(0, 0, 0, 0);
dt.setDate(dt.getDate() + 3 - (dt.getDay() + 6) % 7);
var yearStart = new Date(dt.getFullYear(), 0, 4);
var weekNo = Math.round(((dt - yearStart) / 86400000 + 1) / 7);
return dt.getFullYear() + '-W' + String(weekNo).padStart(2, '0');
}
return y + '-' + m;
}
var bucketMap = new Map();
for (var i = 0; i < entries.length; i++) {
var entity = getEntity(entries[i]);
if (entity === null) continue;
var key = bucketKey(entries[i].timestamp);
if (!bucketMap.has(key)) bucketMap.set(key, {});
var bv = bucketMap.get(key);
bv[entity] = (bv[entity] || 0) + 1;
}
var sortedKeys = Array.from(bucketMap.keys()).sort();
if (granularity === 'day') {
var cur = new Date(minDate.getTime());
cur.setHours(0, 0, 0, 0);
var end = new Date(maxDate.getTime());
end.setHours(0, 0, 0, 0);
var allKeys = [];
while (cur <= end) {
var dk = cur.getFullYear() + '-' + String(cur.getMonth() + 1).padStart(2, '0') + '-' + String(cur.getDate()).padStart(2, '0');
allKeys.push(dk);
if (!bucketMap.has(dk)) bucketMap.set(dk, {});
cur.setDate(cur.getDate() + 1);
}
sortedKeys = allKeys;
} else if (granularity === 'hour') {
var curH = new Date(minDate.getTime());
curH.setMinutes(0, 0, 0);
var endH = new Date(maxDate.getTime());
endH.setMinutes(0, 0, 0);
var allHKeys = [];
while (curH <= endH) {
var hk = curH.getFullYear() + '-' + String(curH.getMonth() + 1).padStart(2, '0') + '-' + String(curH.getDate()).padStart(2, '0') + ' ' + String(curH.getHours()).padStart(2, '0') + ':00';
allHKeys.push(hk);
if (!bucketMap.has(hk)) bucketMap.set(hk, {});
curH.setTime(curH.getTime() + 3600000);
}
sortedKeys = allHKeys;
} else if (granularity === 'week') {
var curW = new Date(minDate.getTime());
curW.setHours(0, 0, 0, 0);
curW.setDate(curW.getDate() - (curW.getDay() + 6) % 7);
var endW = new Date(maxDate.getTime());
endW.setHours(0, 0, 0, 0);
var allWKeys = [];
while (curW <= endW) {
var wk = bucketKey(curW);
if (allWKeys.indexOf(wk) === -1) allWKeys.push(wk);
if (!bucketMap.has(wk)) bucketMap.set(wk, {});
curW.setDate(curW.getDate() + 7);
}
sortedKeys = allWKeys;
} else if (granularity === 'month') {
var curM = new Date(minDate.getFullYear(), minDate.getMonth(), 1);
var endM = new Date(maxDate.getFullYear(), maxDate.getMonth(), 1);
var allMKeys = [];
while (curM <= endM) {
var mk = curM.getFullYear() + '-' + String(curM.getMonth() + 1).padStart(2, '0');
allMKeys.push(mk);
if (!bucketMap.has(mk)) bucketMap.set(mk, {});
curM.setMonth(curM.getMonth() + 1);
}
sortedKeys = allMKeys;
}
if (isStacked) {
var entityTotals = {};
for (var sk = 0; sk < sortedKeys.length; sk++) {
var vals = bucketMap.get(sortedKeys[sk]);
for (var ek in vals) {
entityTotals[ek] = (entityTotals[ek] || 0) + vals[ek];
}
}
var sortedEntities = Object.keys(entityTotals).sort(function(a, b) { return entityTotals[b] - entityTotals[a]; });
if (sortedEntities.length > 8) {
var topSet = new Set(sortedEntities.slice(0, 8));
for (var sk2 = 0; sk2 < sortedKeys.length; sk2++) {
var vals2 = bucketMap.get(sortedKeys[sk2]);
var other = 0;
for (var ek2 in vals2) {
if (!topSet.has(ek2)) { other += vals2[ek2]; delete vals2[ek2]; }
}
if (other > 0) vals2['Sonstige'] = other;
}
}
}
var buckets = [];
for (var bi = 0; bi < sortedKeys.length; bi++) {
var bKey = sortedKeys[bi];
var label;
if (granularity === 'hour') label = bKey.slice(11);
else if (granularity === 'day') label = bKey.slice(5);
else label = bKey;
var bVals = bucketMap.get(bKey);
if (!isStacked) {
for (var vi = 0; vi < values.length; vi++) {
if (!(values[vi] in bVals)) bVals[values[vi]] = 0;
}
}
buckets.push({ key: bKey, label: label, dateFrom: bKey, dateTo: bKey, values: bVals });
}
return { buckets: buckets, granularity: granularity };
}
function identifyBot(ua) {
if (!ua) return "Unbekannt";
const lower = ua.toLowerCase();
for (const bot of BOT_DEFINITIONS.bots) {
if (matchesBotPattern(lower, bot)) return bot.name;
}
return "Sonstiger Bot";
}
function classifyBot(botName) {
if (typeof HEURISTIC_DEFAULTS !== 'undefined') {
for (var key in HEURISTIC_DEFAULTS) {
if (HEURISTIC_DEFAULTS[key].label === botName) return 'heuristic';
}
}
const entry = BOT_DEFINITIONS.bots.find(b => b.name === botName);
return entry ? entry.category : "other";
}
function isEffectiveBot(e) {
if (e.isBot) return true;
if (typeof AppState !== 'undefined' && AppState.heuristicBotVisitors && AppState.heuristicBotVisitors.size > 0) {
return AppState.heuristicBotVisitors.has(e.ip + '|' + e.userAgent);
}
return false;
}
function getEffectiveBotName(e) {
if (e.isBot) return identifyBot(e.userAgent);
if (typeof AppState !== 'undefined' && AppState.heuristicBotVisitors) {
var ruleId = AppState.heuristicBotVisitors.get(e.ip + '|' + e.userAgent);
if (ruleId && typeof HEURISTIC_DEFAULTS !== 'undefined' && HEURISTIC_DEFAULTS[ruleId]) {
return HEURISTIC_DEFAULTS[ruleId].label;
}
}
return null;
}
function classifyResourceType(path) {
if (!path) return "Seite";
const clean = path.split('?')[0].split('#')[0].toLowerCase();
const lastSlash = clean.lastIndexOf('/');
const filename = clean.substring(lastSlash + 1);
const dotPos = filename.lastIndexOf('.');
if (dotPos === -1 || dotPos === filename.length - 1) return "Seite";
const ext = filename.substring(dotPos + 1);
return RESOURCE_TYPES[ext] || "Sonstiges";
}
function classifyUserAgent(ua) {
if (!ua) return { browser: 'Andere', os: 'Andere', device: 'Unbekannt' };
const lower = ua.toLowerCase();
let browser = 'Andere';
for (const bp of BROWSER_PATTERNS) {
if (lower.indexOf(bp.pattern) !== -1) { browser = bp.name; break; }
}
let os = 'Andere';
for (const op of OS_PATTERNS) {
if (lower.indexOf(op.pattern) !== -1) { os = op.name; break; }
}
let device = 'Unbekannt';
if (os === 'iOS') {
device = lower.indexOf('ipad') !== -1 ? 'Tablet' : 'Smartphone';
} else if (os === 'Android') {
device = lower.indexOf('mobile') !== -1 ? 'Smartphone' : 'Tablet';
} else if (os === 'Windows' || os === 'macOS' || os === 'Linux' || os === 'Chrome OS') {
device = 'Desktop';
}
return { browser, os, device };
}
function hasUtmParams(path) {
const qIdx = (path || '').indexOf('?');
if (qIdx === -1) return false;
try {
const params = new URLSearchParams(path.substring(qIdx + 1));
const utm = extractUtmParams(params);
return !!(utm.source || utm.medium || utm.campaign);
} catch (_) { return false; }
}
function hasClickId(path) {
const qIdx = (path || '').indexOf('?');
if (qIdx === -1) return false;
try {
const params = new URLSearchParams(path.substring(qIdx + 1));
return CLICK_ID_KEYS.some(k => params.has(k));
} catch (_) { return false; }
}
function isExternalReferrer(referrer, ownHost) {
if (!referrer || referrer === '-') return false;
const domain = normalizeReferrerHost(referrer);
if (!domain) return false;
return !ownHost || domain !== ownHost.replace(/^www\./, '').toLowerCase();
}
function buildDirectoryAggregates(entries, depth) {
const dirMap = new Map();
for (const e of entries) {
if (classifyResourceType(e.path) !== 'Seite') continue;
const segments = e.path.split('?')[0].split('#')[0].split('/').filter(Boolean);
const dir = '/' + segments.slice(0, depth).join('/');
let d = dirMap.get(dir);
if (!d) {
d = { directory: dir, hits: 0, botHits: 0, userHits: 0, bytes: 0, botBytes: 0, userBytes: 0 };
dirMap.set(dir, d);
}
const b = e.bytes || 0;
d.hits++;
d.bytes += b;
if (isEffectiveBot(e)) { d.botHits++; d.botBytes += b; }
else { d.userHits++; d.userBytes += b; }
}
return Array.from(dirMap.values()).sort((a, b) => b.hits - a.hits);
}
function buildCrawlBudgetData(entries) {
const pathMap = new Map();
let totalBotHits = 0;
for (const e of entries) {
if (classifyResourceType(e.path) !== 'Seite') continue;
const path = e.path.split('?')[0].split('#')[0];
let d = pathMap.get(path);
if (!d) {
d = { path: path, botHits: 0, userHits: 0, userOkHits: 0, bytes: 0, botErrorHits: 0, statusCounts: {}, botNames: new Set() };
pathMap.set(path, d);
}
const b = e.bytes || 0;
if (isEffectiveBot(e)) {
d.botHits++;
totalBotHits++;
const botName = getEffectiveBotName(e);
if (botName) d.botNames.add(botName);
if (e.status >= 400) {
d.botErrorHits++;
d.statusCounts[e.status] = (d.statusCounts[e.status] || 0) + 1;
}
} else {
d.userHits++;
if (e.status >= 200 && e.status < 300) d.userOkHits++;
d.bytes += b;
}
}
const wastePaths = [];
const notCrawled = [];
for (const d of pathMap.values()) {
if (d.botErrorHits > 0) {
wastePaths.push({
path: d.path,
botHits: d.botErrorHits,
statusCounts: d.statusCounts,
botNames: Array.from(d.botNames)
});
}
if (d.botHits === 0 && d.userOkHits > 0) {
notCrawled.push({ path: d.path, userHits: d.userOkHits, bytes: d.bytes });
}
}
wastePaths.sort(function(a, b) { return b.botHits - a.botHits; });
notCrawled.sort(function(a, b) { return b.userHits - a.userHits; });
const wasteTotal = wastePaths.reduce(function(sum, p) { return sum + p.botHits; }, 0);
const wasteRate = totalBotHits > 0 ? Math.round(wasteTotal / totalBotHits * 1000) / 10 : 0;
return {
crawlWaste: { total: wasteTotal, rate: wasteRate, paths: wastePaths },
notCrawled: notCrawled
};
}
</script>
<!-- /STANDALONE-ONLY --><script>
if (typeof isEffectiveBot !== 'function') {
isEffectiveBot = function(e) { return !!e.isBot; };
}
if (typeof getEffectiveBotName !== 'function') {
getEffectiveBotName = function(e) { return e.botName || e.bot_name || null; };
}
function runHeuristicAnalysis(entries, rules) {
var result = new Map();
if (!rules || !entries || entries.length === 0) return result;
var humanEntries = entries.filter(function(e) { return !e.isBot; });
if (humanEntries.length === 0) return result;
if (rules['honeypot'] && rules['honeypot'].active && rules['honeypot'].paths && rules['honeypot'].paths.length > 0) {
var honeypotPaths = rules['honeypot'].paths.map(function(p) { return p.toLowerCase(); });
for (var i = 0; i < humanEntries.length; i++) {
var e = humanEntries[i];
var pathLower = e.path.toLowerCase();
for (var j = 0; j < honeypotPaths.length; j++) {
if (pathLower.indexOf(honeypotPaths[j]) !== -1) {
result.set(e.ip + '|' + e.userAgent, 'honeypot');
break;
}
}
}
}
if (rules['unknown-browser'] && rules['unknown-browser'].active) {
for (var i = 0; i < humanEntries.length; i++) {
var e = humanEntries[i];
var key = e.ip + '|' + e.userAgent;
if (result.has(key)) continue;
var classified = classifyUserAgent(e.userAgent);
if (classified.browser === 'Andere') {
result.set(key, 'unknown-browser');
}
}
}
if (rules['hammering'] && rules['hammering'].active) {
var threshold = rules['hammering'].threshold || 20;
var hammerMap = new Map();
for (var i = 0; i < humanEntries.length; i++) {
var e = humanEntries[i];
var vKey = e.ip + '|' + e.userAgent;
if (result.has(vKey)) continue;
var day = e.timestamp.getFullYear() + '-'
+ String(e.timestamp.getMonth() + 1).padStart(2, '0') + '-'
+ String(e.timestamp.getDate()).padStart(2, '0');
var dayPath = day + '|' + e.path.split('?')[0];
var visitorPaths = hammerMap.get(vKey);
if (!visitorPaths) {
visitorPaths = new Map();
hammerMap.set(vKey, visitorPaths);
}
var count = (visitorPaths.get(dayPath) || 0) + 1;
visitorPaths.set(dayPath, count);
if (count > threshold) {
result.set(vKey, 'hammering');
}
}
}
if (rules['speed-bot'] && rules['speed-bot'].active) {
var minPV = rules['speed-bot'].minPageviews || 5;
var maxMs = (rules['speed-bot'].maxSeconds || 10) * 1000;
var unflagged = humanEntries.filter(function(e) { return !result.has(e.ip + '|' + e.userAgent); });
if (unflagged.length > 0) {
var sessions = buildSessions(unflagged);
for (var i = 0; i < sessions.length; i++) {
var s = sessions[i];
if (s.pageviews > minPV && s.duration < maxMs) {
var vKey = s.ip + '|' + s.userAgent;
if (!result.has(vKey)) {
result.set(vKey, 'speed-bot');
}
}
}
}
}
if (rules['safari-prefetch'] && rules['safari-prefetch'].active) {
var unflagged = humanEntries.filter(function(e) { return !result.has(e.ip + '|' + e.userAgent); });
if (unflagged.length > 0) {
var sessions = buildSessions(unflagged);
for (var i = 0; i < sessions.length; i++) {
var s = sessions[i];
if (s.pageviews !== 1) continue;
var ref = (s.referrer || '').trim();
if (ref && ref !== '-') continue;
var ua = (s.userAgent || '').toLowerCase();
if (ua.indexOf('safari') === -1) continue;
if (ua.indexOf('iphone') === -1 && ua.indexOf('ipad') === -1) continue;
if (ua.indexOf('crios') !== -1 || ua.indexOf('fxios') !== -1) continue;
var vKey = s.ip + '|' + s.userAgent;
if (!result.has(vKey)) {
result.set(vKey, 'safari-prefetch');
}
}
}
}
return result;
}
</script>
<script>
const PAGE_SIZE = 50;
const tablePages = { raw: 1, topPages: 1, directories: 1, entryPages: 1, exitPages: 1, transitions: 1, sources: 1, campaigns: 1, hotlinking: 1, ipAnomalies: 1, campaignQuality: 1, crawlWaste: 1, notCrawled: 1, sessionAnalyse: 1, bounceByEntry: 1, conversionPaths: 1 };
let selectedBotName = null;
let selectedParamName = null;
let parametersSearch = '';
let collapsedBotCategories = null; // initialized on first render
const collapsedNavGroups = new Set();
let rawPathSearch = '';
let topPagesSearch = '';
let entryPagesSearch = '';
let exitPagesSearch = '';
let transitionsSearch = '';
let directoriesSearch = '';
let directoryDepth = 1;
let sourcesSearch = '';
let hotlinkingSearch = '';
let ipAnomaliesSearch = '';
let campaignQualitySearch = '';
let crawlBudgetSearch = '';
let sessionAnalyseSearch = '';
let navOverviewData = null;
let navOverviewNegate = false;
let navOverviewSessions = null;
let navOverviewShowPrev = 10;
let navOverviewShowNext = 10;
let journeyMinSteps = 1;
let journeyMinSessions = 1;
let conversionPathsSearch = '';
let convPathMinSteps = 1;
let convPathMinSessions = 1;
let convPathGoalFilter = '';
let navOverviewSourceFilter = '';
let sessionAnalyseSourceFilter = '';
let convPathSourceFilter = '';
let botTimeFilter = ''; // '' = alle, sonst Bot-Name
let searchMode = 'contains';
let searchNegate = false;
const BOT_FILTER_LABELS = { 'only-human': 'Nur Browser', 'only-bot': 'Nur Bots', 'only-attack': 'Nur Angriffe' };
function setSearchMode(val) {
searchMode = val;
document.querySelectorAll('.search-mode-select').forEach(s => s.value = val);
renderCurrentView();
}
function setSearchNegate(val) {
searchNegate = val;
document.querySelectorAll('.search-negate-toggle').forEach(b => {
b.classList.toggle('active', val);
});
renderCurrentView();
}
function invalidateNavOverviewCache() {
navOverviewData = null;
navOverviewSessions = null;
}
function getSessionReferrerDomain(session) {
var ref = session.referrer;
if (!ref || ref === '-') return '(direkt)';
var domain = normalizeReferrerHost(ref);
if (!domain) return '(direkt)';
var ownHost = (typeof AppState !== 'undefined') ? (AppState.ownHost || '').replace(/^www\./, '').toLowerCase() : '';
if (ownHost && domain === ownHost) return '(intern)';
return domain;
}
function getAvailableSources(sessions) {
var counts = new Map();
for (var i = 0; i < sessions.length; i++) {
var domain = getSessionReferrerDomain(sessions[i]);
counts.set(domain, (counts.get(domain) || 0) + 1);
}
return Array.from(counts.entries())
.map(function(e) { return { domain: e[0], count: e[1] }; })
.sort(function(a, b) { return b.count - a.count; });
}
function filterSessionsBySource(sessions, sourceFilter) {
if (!sourceFilter) return sessions;
return sessions.filter(function(s) {
return getSessionReferrerDomain(s) === sourceFilter;
});
}
function renderSourceDropdown(id, currentValue, sessions) {
var sources = getAvailableSources(sessions);
var html = '<label>Quelle <select id="' + id + '" style="min-width:8rem">';
html += '<option value="">Alle Quellen</option>';
for (var i = 0; i < sources.length; i++) {
var s = sources[i];
var sel = currentValue === s.domain ? ' selected' : '';
html += '<option value="' + escapeHtml(s.domain) + '"' + sel + '>' + escapeHtml(s.domain) + ' (' + s.count.toLocaleString('de-DE') + ')</option>';
}
html += '</select></label>';
return html;
}
function matchSearch(value, term) {
if (!term) return true;
const result = matchPattern(value, term, searchMode);
return searchNegate ? !result : result;
}
function matchPattern(value, pattern, mode) {
if (!pattern) return false;
const lower = value.toLowerCase();
if (mode === 'startsWith') return lower.indexOf(pattern.toLowerCase()) === 0;
if (mode === 'regex') {
try { return new RegExp(pattern, 'i').test(value); }
catch (e) { return false; }
}
return lower.indexOf(pattern.toLowerCase()) !== -1;
}
function compilePatterns(patterns) {
return patterns.map(p => {
if (p.matchMode === 'regex') {
try {
const re = new RegExp(p.pattern, 'i');
return { test: v => re.test(v) };
} catch (e) { return { test: () => false }; }
}
const lp = p.pattern.toLowerCase();
if (p.matchMode === 'startsWith') return { test: v => v.toLowerCase().indexOf(lp) === 0 };
return { test: v => v.toLowerCase().indexOf(lp) !== -1 };
});
}
function resultCountHtml(filteredCount, totalCount, label) {
const fmt = n => n.toLocaleString('de-DE');
if (filteredCount < totalCount) {
return `<div class="result-count">${fmt(filteredCount)} von ${fmt(totalCount)} ${label}</div>`;
}
return `<div class="result-count">${fmt(totalCount)} ${label}</div>`;
}
const sortState = {
raw: { key: null, dir: 'asc' },
topPages: { key: null, dir: 'asc' },
statusCodes: { key: null, dir: 'asc' },
bots: { key: null, dir: 'asc' },
entryPages: { key: null, dir: 'asc' },
exitPages: { key: null, dir: 'asc' },
transitions: { key: null, dir: 'asc' },
sources: { key: null, dir: 'asc' },
campaigns: { key: null, dir: 'asc' },
directories: { key: null, dir: 'asc' },
resourceTypes: { key: null, dir: 'asc' },
browsers: { key: null, dir: 'asc' },
osSystems: { key: null, dir: 'asc' },
devices: { key: null, dir: 'asc' },
clickIds: { key: null, dir: 'asc' },
hotlinking: { key: null, dir: 'asc' },
ipAnomalies: { key: null, dir: 'asc' },
campaignQuality: { key: null, dir: 'asc' },
crawlWaste: { key: null, dir: 'asc' },
notCrawled: { key: null, dir: 'asc' },
sessionAnalyse: { key: null, dir: 'asc' },
bounceByEntry: { key: null, dir: 'asc' },
conversionPaths: { key: null, dir: 'asc' }
};
function applySortState(tableId, key) {
const state = sortState[tableId];
if (state.key === key) {
state.dir = state.dir === 'asc' ? 'desc' : 'asc';
} else {
state.key = key;
state.dir = 'asc';
}
}
function sortData(data, tableId) {
const state = sortState[tableId];
if (!state.key) return data;
const key = state.key;
return [...data].sort((a, b) => {
let valA = a[key];
let valB = b[key];
if (valA instanceof Date) {
valA = valA.getTime();
valB = valB.getTime();
} else if (typeof valA === 'string') {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
}
if (valA < valB) return state.dir === 'asc' ? -1 : 1;
if (valA > valB) return state.dir === 'asc' ? 1 : -1;
return 0;
});
}
function sortIndicator(tableId, key) {
const state = sortState[tableId];
if (state.key !== key) return '';
return `<span class="sort-arrow">${state.dir === 'asc' ? '↑' : '↓'}</span>`;
}
function handleTableSort(tableId, key) {
applySortState(tableId, key);
if (tableId in tablePages) tablePages[tableId] = 1;
renderCurrentView();
}
const _searchConfig = {
raw: { inputId: 'raw-path-search', set(v) { rawPathSearch = v; } },
topPages: { inputId: 'top-pages-search', set(v) { topPagesSearch = v; } },
directories: { inputId: 'directories-search', set(v) { directoriesSearch = v; } },
entryPages: { inputId: 'entry-pages-search', set(v) { entryPagesSearch = v; } },
exitPages: { inputId: 'exit-pages-search', set(v) { exitPagesSearch = v; } },
transitions: { inputId: 'transitions-search', set(v) { transitionsSearch = v; } },
sources: { inputId: 'sources-search', set(v) { sourcesSearch = v; } },
parameters: { inputId: 'parameters-search', set(v) { parametersSearch = v; } },
hotlinking: { inputId: 'hotlinking-search', set(v) { hotlinkingSearch = v; } },
ipAnomalies: { inputId: 'ip-anomalies-search', set(v) { ipAnomaliesSearch = v; } },
campaignQuality: { inputId: 'campaign-quality-search', set(v) { campaignQualitySearch = v; } },
crawlBudget: { inputId: 'crawl-budget-search', set(v) { crawlBudgetSearch = v; } },
sessionAnalyse: { inputId: 'session-analyse-search', set(v) { sessionAnalyseSearch = v; } },
conversionPaths: { inputId: 'conversion-paths-search', set(v) { conversionPathsSearch = v; } }
};
function handleTableSearch(tableId) {
const cfg = _searchConfig[tableId];
if (!cfg) return;
const input = document.getElementById(cfg.inputId);
cfg.set(input ? input.value : '');
if (tableId in tablePages) tablePages[tableId] = 1;
if (tableId === 'sources') tablePages.campaigns = 1;
debouncedRender();
}
const _tableExportData = {};
function registerTableExport(tableId, name, headers, getRows) {
_tableExportData[tableId] = { name, headers, getRows };
}
function exportTable(tableId) {
const data = _tableExportData[tableId];
if (!data) return;
const rows = data.getRows();
if (!rows.length) {
showToast('Keine Daten zum Exportieren');
return;
}
const tsvSafe = v => v == null ? '' : String(v).replace(/[\t\n\r]/g, ' ');
const tsv = [data.headers.join('\t'), ...rows.map(r => r.map(tsvSafe).join('\t'))].join('\n');
const blob = new Blob([tsv], { type: 'text/tab-separated-values' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = data.name + '.tsv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`Export: ${data.name}.tsv (${rows.length.toLocaleString('de-DE')} Zeilen)`);
}
function renderTableFooter(containerId, opts) {
const el = document.getElementById(containerId);
if (!el) return;
const hasPag = opts.totalPages && opts.totalPages > 1;
let html = '';
if (opts.exportId) {
html += `<button class="secondary small"${hasPag ? ' style="margin-right:auto"' : ''} onclick="exportTable('${opts.exportId}')">Export</button>`;
}
if (hasPag) {
html += `<button ${opts.page === 1 ? 'disabled' : ''} data-dir="prev">Zurück</button>`;
html += `<span>Seite ${opts.page} / ${opts.totalPages}</span>`;
html += `<button ${opts.page === opts.totalPages ? 'disabled' : ''} data-dir="next">Weiter</button>`;
}
if (!html) { el.innerHTML = ''; return; }
el.innerHTML = html;
if (hasPag && opts.onPageChange) {
el.querySelectorAll('[data-dir]').forEach(btn => {
btn.addEventListener('click', () => opts.onPageChange(btn.dataset.dir));
});
}
}
function showToast(message) {
const toast = document.getElementById('toast');
if (!toast) return;
toast.textContent = message;
toast.classList.add('visible');
setTimeout(() => toast.classList.remove('visible'), 3000);
}
var _progressStart = null;
function showProgress(message) {
var now = performance.now();
var ts = new Date().toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:3});
if (_progressStart) {
console.log('[' + ts + '] [Progress] %c' + message + ' %c(vorheriger Schritt: ' + Math.round(now - _progressStart) + 'ms)', 'color:#6cf', 'color:#888');
} else {
console.log('[' + ts + '] [Progress] %c' + message, 'color:#6cf');
}
_progressStart = now;
const toast = document.getElementById('toast');
if (!toast) return;
toast.innerHTML = '<span class="progress-dots"></span> ' + message;
toast.classList.add('visible', 'progress');
const backdrop = document.getElementById('progress-backdrop');
if (backdrop) backdrop.classList.add('visible');
}
function showImportProgress(fileIndex, fileCount, imported, skipped, pct) {
const toast = document.getElementById('toast');
if (!toast) return;
const pctClamped = Math.min(100, Math.max(0, Math.round(pct)));
let fill = toast.querySelector('.progress-bar-fill');
if (!fill) {
toast.innerHTML = '<div><span class="progress-dots"></span> <span class="progress-msg-text"></span></div>'
+ '<div class="progress-bar-track"><div class="progress-bar-fill" style="width:0%"></div></div>'
+ '<div class="progress-stats"></div>';
fill = toast.querySelector('.progress-bar-fill');
}
toast.querySelector('.progress-msg-text').textContent = 'Importiere Datei ' + fileIndex + ' von ' + fileCount;
fill.style.width = pctClamped + '%';
let stats = imported.toLocaleString('de-DE') + ' importiert';
if (skipped > 0) stats += ' \u00b7 ' + skipped.toLocaleString('de-DE') + ' \u00fcbersprungen';
toast.querySelector('.progress-stats').textContent = stats;
toast.classList.add('visible', 'progress');
const backdrop = document.getElementById('progress-backdrop');
if (backdrop) backdrop.classList.add('visible');
}
function hideProgress() {
if (_progressStart) {
var ts = new Date().toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:3});
console.log('[' + ts + '] [Progress] %cFertig %c(letzter Schritt: ' + Math.round(performance.now() - _progressStart) + 'ms)', 'color:#6f6', 'color:#888');
_progressStart = null;
}
const toast = document.getElementById('toast');
if (!toast) return;
toast.classList.remove('progress');
setTimeout(() => toast.classList.remove('visible'), 1500);
const backdrop = document.getElementById('progress-backdrop');
if (backdrop) backdrop.classList.remove('visible');
}
function toggleFilterBar() {
const bar = document.getElementById('filter-bar');
const btn = document.getElementById('filter-toggle-btn');
if (!bar) return;
bar.classList.toggle('collapsed');
if (btn) {
const isOpen = !bar.classList.contains('collapsed');
btn.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg> ${isOpen ? 'Filter ausblenden' : 'Filter'}`;
}
}
function updateFilterIndicator() {
const btn = document.getElementById('filter-toggle-btn');
const infoEl = document.getElementById('active-filters-info');
if (!btn || !infoEl) return;
const filters = AppState.filters;
const tags = [];
if (filters.dateFrom) {
tags.push({ label: 'Von', value: filters.dateFrom, key: 'dateFrom' });
}
if (filters.dateTo) {
tags.push({ label: 'Bis', value: filters.dateTo, key: 'dateTo' });
}
if (filters.statusGroup) {
tags.push({ label: 'Status', value: filters.statusGroup, key: 'statusGroup' });
}
if (filters.resourceType) {
tags.push({ label: 'Typ', value: filters.resourceType, key: 'resourceType' });
}
if (filters.botFilter) {
tags.push({ label: 'Bots', value: BOT_FILTER_LABELS[filters.botFilter] || filters.botFilter, key: 'botFilter' });
}
if (filters.device) {
tags.push({ label: 'Gerät', value: filters.device, key: 'device' });
}
if (filters.botCategory) {
var catLabel;
if (filters.botCategory === 'ai-training') catLabel = 'KI-Crawler (Training)';
else if (filters.botCategory === 'ai-grounding') catLabel = 'KI-Crawler (Grounding)';
else if (filters.botCategory === 'ai-unknown') catLabel = 'KI-Crawler (Unbekannt)';
else catLabel = (typeof BOT_CATEGORY_LABELS !== 'undefined' && BOT_CATEGORY_LABELS[filters.botCategory]) || filters.botCategory;
tags.push({ label: 'Bot-Kategorie', value: catLabel, key: 'botCategory' });
}
if (filters.browser) {
tags.push({ label: 'Browser', value: filters.browser, key: 'browser' });
}
if (filters.os) {
tags.push({ label: 'OS', value: filters.os, key: 'os' });
}
if (filters.method) {
tags.push({ label: 'Methode', value: filters.method, key: 'method' });
}
if (filters.path) {
var modeLabel = searchMode === 'startsWith' ? 'beginnt mit' : (searchMode === 'regex' ? 'regex' : 'enthält');
tags.push({ label: 'Pfad (' + modeLabel + ')', value: filters.path, key: 'path' });
}
if (AppState.cleanupActive) {
const cleanupCount = AppState.patterns.filter(p => p.type === 'cleanup' && p.active).length;
if (cleanupCount > 0) {
tags.push({ label: 'Bereinigung', value: cleanupCount + ' Muster', key: 'cleanup' });
}
}
const hasActiveFilters = tags.length > 0;
if (hasActiveFilters) {
btn.classList.add('filter-active');
} else {
btn.classList.remove('filter-active');
}
if (hasActiveFilters) {
let html = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" style="opacity:0.6"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg> Aktive Filter: ';
html += tags.map(t =>
`<span class="filter-tag"><span class="filter-tag-label">${t.label}:</span> ${t.value}<span class="filter-tag-remove" onclick="removeFilter('${t.key}')">&times;</span></span>`
).join(' ');
infoEl.innerHTML = html;
} else {
infoEl.innerHTML = '';
}
}
const filterUiMap = {
dateFrom: 'filter-date-from',
dateTo: 'filter-date-to',
statusGroup: 'filter-status',
resourceType: 'filter-resource-type',
botFilter: 'filter-bots',
device: 'filter-device',
botCategory: 'filter-bot-category',
browser: 'filter-browser',
os: 'filter-os',
method: 'filter-method',
path: 'filter-path'
};
function removeFilter(key) {
if (key === 'cleanup') {
AppState.cleanupActive = false;
invalidateFilterCache();
Storage.saveAll(AppState);
renderWithProgress();
return;
}
AppState.filters[key] = key === 'dateFrom' || key === 'dateTo' ? null : '';
const el = document.getElementById(filterUiMap[key]);
if (el) el.value = '';
invalidateFilterCache();
Storage.saveAll(AppState);
renderWithProgress();
}
function initUI() {
const loadBtn = document.getElementById("load-logs-btn");
const fileInput = document.getElementById("log-file-input");
if (loadBtn && fileInput) {
loadBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
if (fileInput.files && fileInput.files.length > 0) {
handleLogsLoaded(fileInput.files);
}
});
}
document.getElementById("apply-filters-btn").addEventListener("click", () => {
for (const k in tablePages) tablePages[k] = 1;
applyFiltersFromUI();
});
document.getElementById("clear-filters-btn").addEventListener("click", () => {
document.getElementById("filter-date-from").value = "";
document.getElementById("filter-date-to").value = "";
document.getElementById("filter-status").value = "";
document.getElementById("filter-resource-type").value = "";
document.getElementById("filter-bots").value = "";
document.getElementById("filter-device").value = "";
const _botCatEl = document.getElementById("filter-bot-category");
if (_botCatEl) _botCatEl.value = "";
const _pathEl = document.getElementById("filter-path");
if (_pathEl) _pathEl.value = "";
AppState.cleanupActive = false;
for (const k in tablePages) tablePages[k] = 1;
applyFiltersFromUI();
});
}
function toggleNavGroup(groupId) {
const group = document.querySelector(`.nav-group[data-group="${groupId}"]`);
if (!group) return;
const items = group.querySelector('.nav-group-items');
const arrow = group.querySelector('.collapse-arrow');
if (!items) return;
if (collapsedNavGroups.has(groupId)) {
collapsedNavGroups.delete(groupId);
items.classList.remove('collapsed');
if (arrow) arrow.classList.add('open');
} else {
collapsedNavGroups.add(groupId);
items.classList.add('collapsed');
if (arrow) arrow.classList.remove('open');
}
}
function switchView(view) {
document.querySelectorAll(".nav-item").forEach(b => b.classList.remove("active"));
const navItem = document.querySelector(`.nav-item[data-view="${view}"]`);
if (navItem) navItem.classList.add("active");
document.querySelectorAll(".view").forEach(v => v.classList.remove("active"));
const viewEl = document.getElementById(`view-${view}`);
if (viewEl) viewEl.classList.add("active");
const activeGroupId = navItem ? (navItem.closest('.nav-group') || {}).dataset?.group : null;
document.querySelectorAll('.nav-group').forEach(g => {
const gid = g.dataset.group;
const items = g.querySelector('.nav-group-items');
const arrow = g.querySelector('.collapse-arrow');
if (!items) return;
if (gid === activeGroupId) {
g.classList.add('has-active-child');
collapsedNavGroups.delete(gid);
items.classList.remove('collapsed');
if (arrow) arrow.classList.add('open');
} else {
g.classList.remove('has-active-child');
collapsedNavGroups.add(gid);
items.classList.add('collapsed');
if (arrow) arrow.classList.remove('open');
}
});
const titleMap = {
raw: "Rohdaten",
overview: "Dashboard",
"top-pages": "Top-Seiten",
directories: "Verzeichnisse",
"entry-pages": "Einstiegsseiten",
"exit-pages": "Ausstiegsseiten",
transitions: "Pfadübergänge",
sources: "Quellen",
"conversions-report": "Conversions",
"status-codes": "Statuscodes",
"resource-types": "Ressourcentypen",
browsers: "Browser & Systeme",
bots: "Bots & Crawler",
"bot-time": "Bot-Zeitverhalten",
"bot-detection": "Bot-Erkennung",
cleanup: "Bereinigung",
conversions: "Ziele definieren",
parameters: "Parameter",
hotlinking: "Hotlinking",
"ip-anomalies": "IP-Auffälligkeiten",
"campaign-quality": "Kampagnenqualität",
"crawl-budget": "Crawl-Budget",
"session-analyse": "Session-Analyse",
"conversion-paths": "Conversion-Pfade",
"nav-overview": "Navigationsübersicht",
timeline: "Zeitverlauf",
"server-settings": "Einstellungen"
};
document.getElementById("view-title").textContent = titleMap[view] || "Ansicht";
localStorage.setItem('logalytrix-active-view', view);
if (typeof LOGALYTRIX_MODE !== 'undefined' && LOGALYTRIX_MODE === 'server') {
renderWithProgress();
return;
}
renderCurrentView();
}
let _searchDebounceTimer = null;
function debouncedRender() {
clearTimeout(_searchDebounceTimer);
_searchDebounceTimer = setTimeout(renderCurrentView, 200);
}
function handleDirectoryDepth() {
const sel = document.getElementById('directory-depth-select');
directoryDepth = parseInt(sel.value, 10) || 1;
tablePages.directories = 1;
renderCurrentView();
}
function renderRawTable(entries) {
const container = document.getElementById("raw-table-container");
if (!entries.length) {
container.innerHTML = "<div class='empty-hint'>Keine Daten vorhanden. Lade Logdateien über den Button oben.</div>";
document.getElementById("raw-pagination").innerHTML = "";
return;
}
let displayEntries = entries;
if (rawPathSearch) {
displayEntries = displayEntries.filter(e => matchSearch(e.path, rawPathSearch));
}
displayEntries = sortData(displayEntries, 'raw');
if (!displayEntries.length) {
container.innerHTML = `<div class='empty-hint'>Keine Treffer für „${escapeHtml(rawPathSearch)}"</div>`;
document.getElementById("raw-pagination").innerHTML = "";
return;
}
const totalPages = Math.ceil(displayEntries.length / PAGE_SIZE);
if (tablePages.raw > totalPages) tablePages.raw = totalPages;
const start = (tablePages.raw - 1) * PAGE_SIZE;
const slice = displayEntries.slice(start, start + PAGE_SIZE);
const countText = displayEntries.length < entries.length
? `${displayEntries.length.toLocaleString('de-DE')} von ${entries.length.toLocaleString('de-DE')} Einträge`
: `${entries.length.toLocaleString('de-DE')} Einträge`;
let html = '<div class="table-legend">';
html += '<span><span class="legend-swatch attack"></span> Angriff</span>';
html += '<span><span class="legend-swatch bot"></span> Bot</span>';
html += `<span class="table-legend-count">${countText}</span>`;
html += '</div>';
html += "<table><thead><tr>";
html += `<th onclick="handleTableSort('raw','timestamp')">Zeit${sortIndicator('raw','timestamp')}</th>`;
html += `<th onclick="handleTableSort('raw','ip')">IP${sortIndicator('raw','ip')}</th>`;
html += `<th onclick="handleTableSort('raw','method')">Methode${sortIndicator('raw','method')}</th>`;
html += `<th onclick="handleTableSort('raw','path')">Pfad${sortIndicator('raw','path')}</th>`;
html += `<th onclick="handleTableSort('raw','status')">Status${sortIndicator('raw','status')}</th>`;
html += `<th onclick="handleTableSort('raw','bytes')">Bytes${sortIndicator('raw','bytes')}</th>`;
html += `<th onclick="handleTableSort('raw','referrer')">Referrer${sortIndicator('raw','referrer')}</th>`;
html += `<th onclick="handleTableSort('raw','userAgent')">User Agent${sortIndicator('raw','userAgent')}</th>`;
html += "</tr></thead><tbody>";
for (const e of slice) {
let rowClass = '';
if (e.isAttack) rowClass = 'attack-row';
else if (isBotUserAgent(e.userAgent)) rowClass = 'bot-row';
html += `<tr${rowClass ? ' class="' + rowClass + '"' : ''}>
<td>${e.timestamp.toISOString().replace('T', ' ').slice(0, 19)}</td>
<td>${e.ip}</td>
<td>${e.method}</td>
<td title="${escapeHtml(e.path)}">${e.path.length > 60 ? escapeHtml(e.path.slice(0, 57)) + '...' : escapeHtml(e.path)}</td>
<td>${e.status}</td>
<td>${e.bytes.toLocaleString('de-DE')}</td>
<td>${escapeHtml(e.referrer || "-")}</td>
<td title="${escapeHtml(e.userAgent)}">${e.userAgent.length > 40 ? escapeHtml(e.userAgent.slice(0, 37)) + '...' : escapeHtml(e.userAgent)}</td>
</tr>`;
}
html += "</tbody></table>";
container.innerHTML = html;
registerTableExport('raw', 'Rohdaten', ['Zeit', 'IP', 'Methode', 'Pfad', 'Status', 'Bytes', 'Referrer', 'User-Agent'], () =>
displayEntries.map(e => [e.timestamp.toISOString().replace('T', ' ').slice(0, 19), e.ip, e.method, e.path, e.status, e.bytes, e.referrer || '', e.userAgent])
);
renderTableFooter('raw-pagination', {
page: tablePages.raw, totalPages, exportId: 'raw',
onPageChange(dir) {
if (dir === 'prev' && tablePages.raw > 1) tablePages.raw--;
if (dir === 'next' && tablePages.raw < totalPages) tablePages.raw++;
renderRawTable(entries);
}
});
}
function renderOverview(aggregates, filteredEntries) {
const container = document.getElementById("overview-metrics");
if (!aggregates || !aggregates.daily.length) {
container.innerHTML = "<div class='empty-hint'>Keine Daten vorhanden.</div>";
return;
}
const totalPageviews = aggregates.daily.reduce((sum, d) => sum + d.pageviews, 0);
const totalSessions = aggregates.daily.reduce((sum, d) => sum + d.sessions, 0);
const totalUsers = aggregates.daily.reduce((sum, d) => sum + d.users, 0);
const totalBotHits = (aggregates.bots.find(b => b.type === 'bot') || { count: 0 }).count;
const totalHumanHits = (aggregates.bots.find(b => b.type === 'human') || { count: 0 }).count;
let html = '';
html += `<div class="stats-grid">
<div class="metric-card">
<h4>Hits gesamt ${hintIcon('Jede Zeile im Logfile = ein Hit. Zählt alle HTTP-Requests inkl. Bilder, CSS, Bots etc.')}</h4>
<div class="value">${totalPageviews.toLocaleString('de-DE')}</div>
</div>
<div class="metric-card">
<h4>Sessions ${hintIcon('Zusammenhängende Aktivität einer IP + User-Agent-Kombination. Eine neue Session beginnt nach 30 Min. Inaktivität.')}</h4>
<div class="value">${totalSessions.toLocaleString('de-DE')}</div>
</div>
<div class="metric-card">
<h4>Nutzer ${hintIcon('Eindeutige Kombination aus IP-Adresse und User-Agent. Kein Tracking — ein Nutzer mit zwei Browsern zählt doppelt.')}</h4>
<div class="value">${totalUsers.toLocaleString('de-DE')}</div>
</div>
<div class="metric-card">
<h4>Tage</h4>
<div class="value">${aggregates.daily.length.toLocaleString('de-DE')}</div>
</div>
<div class="metric-card">
<h4>Browser / Bot ${hintIcon('Aufteilung der Hits nach User-Agent. Bots werden anhand bekannter Kennungen erkannt (Googlebot, GPTBot etc.).')}</h4>
<div class="value">${totalHumanHits.toLocaleString('de-DE')} / ${totalBotHits.toLocaleString('de-DE')}</div>
</div>
<div class="metric-card">
<h4>Angriffe ${hintIcon('Erkannte Angriffsversuche: Injection-Muster im Pfad, bekannte Scanner-Tools im User-Agent, Probing auf typische Schwachstellen-Pfade (nur bei HTTP-Fehlern).')}</h4>
<div class="value">${(aggregates.attackCount || 0).toLocaleString('de-DE')}</div>
</div>
<div class="metric-card">
<h4>Ø Hits/Tag</h4>
<div class="value">${Math.round(totalPageviews / aggregates.daily.length).toLocaleString('de-DE')}</div>
</div>
<div class="metric-card">
<h4>Ø Seiten/Session ${hintIcon('Durchschnittliche Anzahl Hits pro Session.')}</h4>
<div class="value">${totalSessions > 0 ? (totalPageviews / totalSessions).toFixed(1) : '\u2013'}</div>
</div>
<div class="metric-card">
<h4>Ø Seiten/Nutzer ${hintIcon('Durchschnittliche Anzahl Hits pro Nutzer. Nutzer = Summe der täglichen Unique Users (IP+UA).')}</h4>
<div class="value">${totalUsers > 0 ? (totalPageviews / totalUsers).toFixed(1) : '\u2013'}</div>
</div>
${aggregates.responseTimeMedian !== null ? `
<div class="metric-card">
<h4>Median RT ${hintIcon('Mittlere Antwortzeit: 50% der Requests waren schneller.')}</h4>
<div class="value">${formatResponseTime(aggregates.responseTimeMedian)}</div>
</div>
<div class="metric-card">
<h4>Ø RT ${hintIcon('Durchschnittliche Antwortzeit inkl. Ausreisser.')}</h4>
<div class="value">${formatResponseTime(aggregates.responseTimeAvg)}</div>
</div>
<div class="metric-card">
<h4>P95 RT ${hintIcon('95% der Requests waren schneller als dieser Wert.')}</h4>
<div class="value">${formatResponseTime(aggregates.responseTimeP95)}</div>
</div>` : ''}
</div>`;
html += `<div class="dashboard-section">
<div class="card">
<h3>${aggregates.daily.length <= 3 ? 'Stündlicher Traffic' : 'Täglicher Traffic'}</h3>
${renderDailyChart(aggregates.daily, aggregates.dateRange, filteredEntries)}
</div>
</div>`;
const hasRT = aggregates.responseTimeMedian !== null;
if (hasRT) {
html += `<div class="dashboard-section">
<div class="card">
<h3>${aggregates.daily.length <= 3 ? 'Stündliche Antwortzeiten' : 'Antwortzeiten'}</h3>
${renderDailyRTChart(aggregates.daily, aggregates.dateRange, filteredEntries)}
</div>
</div>`;
}
if (aggregates.statusList.length) {
html += `<div class="dashboard-section">
<div class="card">
<h3>HTTP-Status-Verteilung</h3>
${renderStatusDistribution(aggregates.statusList, totalPageviews)}
</div>
</div>`;
}
if (aggregates.methodList && aggregates.methodList.length) {
html += `<div class="dashboard-section">
<div class="card">
<h3>HTTP-Methoden</h3>
${renderMethodDistribution(aggregates.methodList, totalPageviews)}
</div>
</div>`;
}
if (aggregates.resourceTypes && aggregates.resourceTypes.length) {
html += `<div class="dashboard-section">
<div class="card">
<h3>Ressourcentypen ${hintIcon('Erkennung anhand der Dateiendung im Pfad. Pfade ohne Endung (z.\u00a0B. /blog/) gelten als Seite.')}</h3>
${renderResourceTypeDistribution(aggregates.resourceTypes, totalPageviews)}
</div>
</div>`;
}
if (aggregates.bots && aggregates.bots.length) {
html += `<div class="dashboard-section">
<div class="card">
<h3>Traffic-Typ</h3>
${renderTrafficTypeDistribution(aggregates.bots, aggregates.attackCount, totalPageviews)}
</div>
</div>`;
}
if (aggregates.deviceList && aggregates.deviceList.length) {
html += `<div class="dashboard-section">
<div class="card">
<h3>Geräte</h3>
${renderDeviceDistribution(aggregates.deviceList)}
</div>
</div>`;
}
const topPagesForOverview = aggregates.topPagesPreview || aggregates.topPages;
if (topPagesForOverview && topPagesForOverview.length) {
html += `<div class="dashboard-section">
<div class="card">
<h3>Top 5 Seiten</h3>
${renderTopPagesMini(topPagesForOverview.slice(0, 5))}
</div>
</div>`;
}
container.innerHTML = html;
container.addEventListener('click', function(e) {
const bar = e.target.closest('.chart-drilldown');
if (!bar) return;
const dateFrom = bar.dataset.dateFrom;
const dateTo = bar.dataset.dateTo;
if (!dateFrom || !dateTo) return;
document.getElementById('filter-date-from').value = dateFrom;
document.getElementById('filter-date-to').value = dateTo;
applyFiltersFromUI();
});
}
function fillDayGaps(daily, zeroFn, rangeMin, rangeMax) {
if (!rangeMin || !rangeMax) {
if (daily.length < 2) return daily;
rangeMin = daily[0].day;
rangeMax = daily[daily.length - 1].day;
}
const byDay = new Map();
for (const d of daily) byDay.set(d.day, d);
const result = [];
const cur = new Date(rangeMin + 'T00:00:00');
const end = new Date(rangeMax + 'T00:00:00');
while (cur <= end) {
const y = cur.getFullYear();
const m = String(cur.getMonth() + 1).padStart(2, '0');
const d = String(cur.getDate()).padStart(2, '0');
const key = y + '-' + m + '-' + d;
result.push(byDay.get(key) || zeroFn(key));
cur.setDate(cur.getDate() + 1);
}
return result;
}
function renderDailyChart(daily, dateRange, filteredEntries) {
if (!daily.length) return '<div class="empty-hint">Keine Daten</div>';
const rangeMin = dateRange ? dateRange.min : null;
const rangeMax = dateRange ? dateRange.max : null;
daily = fillDayGaps(daily, key => ({ day: key, pageviews: 0, sessions: 0, users: 0 }), rangeMin, rangeMax);
var _serverHourly = (typeof LOGALYTRIX_MODE !== 'undefined' && LOGALYTRIX_MODE === 'server'
&& _filteredCache && _filteredCache.aggregates && _filteredCache.aggregates.hourly)
? _filteredCache.aggregates.hourly : null;
if (daily.length <= 3 && (filteredEntries || _serverHourly)) {
const hourly = _serverHourly || buildHourlyData(filteredEntries);
if (hourly.length > 0) {
const maxVal = hourly.reduce((m, h) => h.pageviews > m ? h.pageviews : m, 0);
if (maxVal === 0) return '<div class="empty-hint">Keine Daten</div>';
let html = '<div class="chart-bar-container" id="traffic-chart">';
let prevDay = '';
for (const h of hourly) {
const heightPct = h.pageviews === 0 ? 0 : Math.max((h.pageviews / maxVal) * 100, 2);
const curDay = h.hour.slice(0, 10);
const dayBreak = prevDay && curDay !== prevDay ? ' chart-bar-daybreak' : '';
html += `<div class="chart-bar-wrapper${dayBreak}" data-tooltip="${h.label}: ${h.pageviews.toLocaleString('de-DE')} Hits">
<div class="chart-bar" style="height:${heightPct}%"></div>
<div class="chart-bar-label">${h.label.slice(-5)}</div>
</div>`;
prevDay = curDay;
}
html += '</div>';
html += `<div style="font-size:0.75rem;color:var(--text-hint);margin-top:6px;text-align:right">${hourly.length} Stunden</div>`;
return html;
}
}
let buckets, modeLabel;
if (daily.length <= 60) {
buckets = daily.map(d => ({
label: d.day.slice(5),
tooltip: d.day,
value: d.pageviews,
dateFrom: d.day,
dateTo: d.day
}));
modeLabel = 'Tage';
} else if (daily.length <= 365) {
const weekMap = new Map();
for (const d of daily) {
const dt = new Date(d.day + 'T00:00:00');
const wk = getISOWeekLabel(dt);
if (!weekMap.has(wk)) weekMap.set(wk, { value: 0, min: d.day, max: d.day });
const w = weekMap.get(wk);
w.value += d.pageviews;
if (d.day < w.min) w.min = d.day;
if (d.day > w.max) w.max = d.day;
}
buckets = Array.from(weekMap.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([wk, w]) => ({ label: wk, tooltip: 'KW ' + wk, value: w.value, dateFrom: w.min, dateTo: w.max }));
modeLabel = 'Wochen';
} else {
const monthMap = new Map();
for (const d of daily) {
const m = d.day.slice(0, 7);
if (!monthMap.has(m)) monthMap.set(m, { value: 0, min: d.day, max: d.day });
const mo = monthMap.get(m);
mo.value += d.pageviews;
if (d.day < mo.min) mo.min = d.day;
if (d.day > mo.max) mo.max = d.day;
}
buckets = Array.from(monthMap.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([m, mo]) => ({ label: m.slice(2), tooltip: m, value: mo.value, dateFrom: mo.min, dateTo: mo.max }));
modeLabel = 'Monate';
}
const maxVal = buckets.reduce((m, b) => b.value > m ? b.value : m, 0);
if (maxVal === 0) return '<div class="empty-hint">Keine Daten</div>';
let html = '<div class="chart-bar-container" id="traffic-chart">';
for (const b of buckets) {
const heightPct = b.value === 0 ? 0 : Math.max((b.value / maxVal) * 100, 2);
html += `<div class="chart-bar-wrapper chart-drilldown" data-tooltip="${b.tooltip}: ${b.value.toLocaleString('de-DE')} Hits" data-date-from="${b.dateFrom}" data-date-to="${b.dateTo}">
<div class="chart-bar" style="height:${heightPct}%"></div>
<div class="chart-bar-label">${b.label}</div>
</div>`;
}
html += '</div>';
html += `<div style="font-size:0.75rem;color:var(--text-hint);margin-top:6px;text-align:right">${daily.length} Tage, ${buckets.length} ${modeLabel}</div>`;
return html;
}
function getISOWeekLabel(date) {
const d = new Date(date.getTime());
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + 3 - (d.getDay() + 6) % 7);
const yearStart = new Date(d.getFullYear(), 0, 4);
const weekNo = Math.round(((d - yearStart) / 86400000 + 1) / 7);
return d.getFullYear() + '-W' + String(weekNo).padStart(2, '0');
}
function renderDailyRTChart(daily, dateRange, filteredEntries) {
if (!daily.length) return '<div class="empty-hint">Keine Daten</div>';
const rangeMin = dateRange ? dateRange.min : null;
const rangeMax = dateRange ? dateRange.max : null;
daily = fillDayGaps(daily, key => ({
day: key, pageviews: 0, sessions: 0, users: 0,
responseTimeMedian: null, responseTimeAvg: null, responseTimeP95: null
}), rangeMin, rangeMax);
if (daily.length <= 3 && filteredEntries && LOGALYTRIX_MODE !== 'server') {
const hourly = buildHourlyData(filteredEntries);
const hourlyWithRT = hourly.filter(h => h.responseTimeMedian !== null);
if (hourlyWithRT.length > 0) {
const maxVal = hourlyWithRT.reduce((m, h) => Math.max(m, h.responseTimeMedian || 0, h.responseTimeAvg || 0), 0);
if (maxVal === 0) return '<div class="empty-hint">Keine Antwortzeit-Daten</div>';
let html = '<div class="chart-bar-container" id="rt-chart">';
let prevDay = '';
for (const h of hourly) {
const medianPct = h.responseTimeMedian === null ? 0 : Math.max((h.responseTimeMedian / maxVal) * 100, 2);
const avgPct = h.responseTimeAvg === null ? 0 : Math.max((h.responseTimeAvg / maxVal) * 100, 2);
const curDay = h.hour.slice(0, 10);
const dayBreak = prevDay && curDay !== prevDay ? ' chart-bar-daybreak' : '';
const tooltipText = h.label + ': Median ' + formatResponseTime(h.responseTimeMedian) + ', \u00d8 ' + formatResponseTime(h.responseTimeAvg);
html += `<div class="chart-bar-wrapper${dayBreak}" data-tooltip="${tooltipText}">
<div style="display:flex;align-items:flex-end;flex:1;width:100%;gap:2px">
<div class="chart-bar" style="height:${medianPct}%;flex:1;width:auto"></div>
<div class="chart-bar chart-bar-secondary" style="height:${avgPct}%;flex:1;width:auto"></div>
</div>
<div class="chart-bar-label">${h.label.slice(-5)}</div>
</div>`;
prevDay = curDay;
}
html += '</div>';
html += `<div style="font-size:0.75rem;color:var(--text-hint);margin-top:6px;display:flex;justify-content:space-between">
<span>
<span style="display:inline-block;width:10px;height:10px;background:var(--accent);border-radius:2px;margin-right:3px"></span>Median
<span style="display:inline-block;width:10px;height:10px;background:var(--text-hint);border-radius:2px;margin:0 3px 0 10px"></span>Durchschnitt
</span>
<span>${hourly.length} Stunden</span>
</div>`;
return html;
}
}
let buckets, modeLabel;
if (daily.length <= 60) {
buckets = daily.map(d => ({
label: d.day.slice(5),
tooltip: d.day,
median: d.responseTimeMedian,
avg: d.responseTimeAvg
}));
modeLabel = 'Tage';
} else if (daily.length <= 365) {
const weekMap = new Map();
for (const d of daily) {
if (d.responseTimeMedian === null) continue;
const dt = new Date(d.day + 'T00:00:00');
const wk = getISOWeekLabel(dt);
if (!weekMap.has(wk)) weekMap.set(wk, { medians: [], avgs: [] });
const w = weekMap.get(wk);
w.medians.push(d.responseTimeMedian);
w.avgs.push(d.responseTimeAvg);
}
buckets = Array.from(weekMap.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([wk, w]) => ({
label: wk,
tooltip: 'KW ' + wk,
median: Math.round(w.medians.reduce((s, v) => s + v, 0) / w.medians.length),
avg: Math.round(w.avgs.reduce((s, v) => s + v, 0) / w.avgs.length)
}));
modeLabel = 'Wochen';
} else {
const monthMap = new Map();
for (const d of daily) {
if (d.responseTimeMedian === null) continue;
const m = d.day.slice(0, 7);
if (!monthMap.has(m)) monthMap.set(m, { medians: [], avgs: [] });
const mo = monthMap.get(m);
mo.medians.push(d.responseTimeMedian);
mo.avgs.push(d.responseTimeAvg);
}
buckets = Array.from(monthMap.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([m, mo]) => ({
label: m.slice(2),
tooltip: m,
median: Math.round(mo.medians.reduce((s, v) => s + v, 0) / mo.medians.length),
avg: Math.round(mo.avgs.reduce((s, v) => s + v, 0) / mo.avgs.length)
}));
modeLabel = 'Monate';
}
buckets = buckets.filter(b => b.median !== null);
if (!buckets.length) return '<div class="empty-hint">Keine Antwortzeit-Daten</div>';
const maxVal = buckets.reduce((m, b) => Math.max(m, b.median || 0, b.avg || 0), 0);
if (maxVal === 0) return '<div class="empty-hint">Keine Antwortzeit-Daten</div>';
let html = '<div class="chart-bar-container" id="rt-chart">';
for (const b of buckets) {
const medianPct = b.median === null ? 0 : Math.max((b.median / maxVal) * 100, 2);
const avgPct = b.avg === null ? 0 : Math.max((b.avg / maxVal) * 100, 2);
const tooltipText = b.tooltip + ': Median ' + formatResponseTime(b.median) + ', \u00d8 ' + formatResponseTime(b.avg);
html += `<div class="chart-bar-wrapper" data-tooltip="${tooltipText}">
<div style="display:flex;align-items:flex-end;flex:1;width:100%;gap:2px">
<div class="chart-bar" style="height:${medianPct}%;flex:1;width:auto"></div>
<div class="chart-bar chart-bar-secondary" style="height:${avgPct}%;flex:1;width:auto"></div>
</div>
<div class="chart-bar-label">${b.label}</div>
</div>`;
}
html += '</div>';
html += `<div style="font-size:0.75rem;color:var(--text-hint);margin-top:6px;display:flex;justify-content:space-between">
<span>
<span style="display:inline-block;width:10px;height:10px;background:var(--accent);border-radius:2px;margin-right:3px"></span>Median
<span style="display:inline-block;width:10px;height:10px;background:var(--text-hint);border-radius:2px;margin:0 3px 0 10px"></span>Durchschnitt
</span>
<span>${buckets.length} ${modeLabel}</span>
</div>`;
return html;
}
function renderStatusDistribution(statusList, total) {
const groups = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 };
const colors = { '2xx': '#7B9A6D', '3xx': '#00224E', '4xx': '#BDAC50', '5xx': '#EDD218' };
const lightGroups = new Set(['4xx', '5xx']);
for (const s of statusList) {
const code = s.status;
if (code >= 200 && code < 300) groups['2xx'] += s.count;
else if (code >= 300 && code < 400) groups['3xx'] += s.count;
else if (code >= 400 && code < 500) groups['4xx'] += s.count;
else if (code >= 500 && code < 600) groups['5xx'] += s.count;
}
let barHtml = '<div class="status-bar-track">';
let legendHtml = '<div class="status-bar-legend">';
for (const [group, count] of Object.entries(groups)) {
if (count === 0) continue;
const pct = (count / total * 100);
const pctStr = pct.toFixed(1);
const cls = lightGroups.has(group) ? ' seg-light' : '';
barHtml += `<div class="status-bar-segment${cls}" style="width:${pctStr}%;background:${colors[group]}" title="${group}: ${count.toLocaleString('de-DE')} (${pctStr}%)">${pct > 5 ? pctStr + '%' : ''}</div>`;
legendHtml += `<div class="status-bar-legend-item"><span class="status-dot" style="background:${colors[group]}"></span>${group}: ${count.toLocaleString('de-DE')} (${pctStr}%)</div>`;
}
barHtml += '</div>';
legendHtml += '</div>';
return barHtml + legendHtml;
}
function renderMethodDistribution(methodList, total) {
const colors = {
'GET': '#0E336F', 'POST': '#7B9A6D', 'HEAD': '#505874',
'OPTIONS': '#BDAC50', 'PUT': '#B8A953', 'DELETE': '#C0392B',
'PATCH': '#9A9369'
};
const defaultColor = '#64697B';
const lightMethods = new Set(['OPTIONS', 'PUT', 'DELETE', 'PATCH']);
let barHtml = '<div class="status-bar-track">';
let legendHtml = '<div class="status-bar-legend">';
for (const m of methodList) {
if (m.count === 0) continue;
const pct = (m.count / total * 100);
const pctStr = pct.toFixed(1);
const color = colors[m.method] || defaultColor;
const cls = (lightMethods.has(m.method) || !colors[m.method]) ? ' seg-light' : '';
barHtml += `<div class="status-bar-segment${cls}" style="width:${pctStr}%;background:${color}" title="${escapeHtml(m.method)}: ${m.count.toLocaleString('de-DE')} (${pctStr}%)">${pct > 5 ? pctStr + '%' : ''}</div>`;
legendHtml += `<div class="status-bar-legend-item"><span class="status-dot" style="background:${color}"></span>${escapeHtml(m.method)}: ${m.count.toLocaleString('de-DE')} (${pctStr}%)</div>`;
}
barHtml += '</div>';
legendHtml += '</div>';
return barHtml + legendHtml;
}
function renderResourceTypeDistribution(resourceTypes, total) {
const colors = {
'Seite': '#0E336F', 'Bild': '#1E3A71', 'CSS': '#3A4B72', 'JavaScript': '#505874',
'Schrift': '#64697B', 'Feed/Daten': '#808079', 'Dokument': '#9A9369',
'Media': '#B8A953', 'Archiv': '#D2BB3B', 'Source Map': '#F0D414', 'Sonstiges': '#FDEA45'
};
const lightTypes = new Set(['Dokument', 'Media', 'Archiv', 'Source Map', 'Sonstiges']);
let barHtml = '<div class="status-bar-track">';
let legendHtml = '<div class="status-bar-legend">';
for (const rt of resourceTypes) {
const pct = (rt.count / total * 100);
const pctStr = pct.toFixed(1);
const color = colors[rt.type] || '#bdc3c7';
const cls = lightTypes.has(rt.type) ? ' seg-light' : '';
barHtml += `<div class="status-bar-segment${cls}" style="width:${pctStr}%;background:${color}" title="${rt.type}: ${rt.count.toLocaleString('de-DE')} (${pctStr}%)">${pct > 4 ? pctStr + '%' : ''}</div>`;
legendHtml += `<div class="status-bar-legend-item"><span class="status-dot" style="background:${color}"></span>${rt.type}: ${rt.count.toLocaleString('de-DE')} (${pctStr}%)</div>`;
}
barHtml += '</div>';
legendHtml += '</div>';
return barHtml + legendHtml;
}
function renderTrafficTypeDistribution(bots, attackCount, total) {
const botHits = (bots.find(b => b.type === 'bot') || { count: 0 }).count;
const browserHits = total - botHits - attackCount;
const segments = [
{ label: 'Browser', count: browserHits, color: '#0E336F', light: false },
{ label: 'Bots', count: botHits, color: '#9A9369', light: true },
{ label: 'Angriffe', count: attackCount, color: '#EDD218', light: true }
];
let barHtml = '<div class="status-bar-track">';
let legendHtml = '<div class="status-bar-legend">';
for (const s of segments) {
if (s.count === 0) continue;
const pct = (s.count / total * 100);
const pctStr = pct.toFixed(1);
const cls = s.light ? ' seg-light' : '';
barHtml += `<div class="status-bar-segment${cls}" style="width:${pctStr}%;background:${s.color}" title="${s.label}: ${s.count.toLocaleString('de-DE')} (${pctStr}%)">${pct > 5 ? pctStr + '%' : ''}</div>`;
legendHtml += `<div class="status-bar-legend-item"><span class="status-dot" style="background:${s.color}"></span>${s.label}: ${s.count.toLocaleString('de-DE')} (${pctStr}%)</div>`;
}
barHtml += '</div>';
legendHtml += '</div>';
return barHtml + legendHtml;
}
function renderDeviceDistribution(deviceList) {
const order = ['Desktop', 'Smartphone', 'Tablet', 'Unbekannt'];
const colors = {
'Desktop': '#0E336F', 'Smartphone': '#64697B', 'Tablet': '#B8A953', 'Unbekannt': '#FDEA45'
};
const lightDevices = new Set(['Tablet', 'Unbekannt']);
const byName = new Map(deviceList.map(d => [d.name, d]));
const total = deviceList.reduce((sum, d) => sum + d.count, 0);
let barHtml = '<div class="status-bar-track">';
let legendHtml = '<div class="status-bar-legend">';
for (const name of order) {
const d = byName.get(name);
if (!d || d.count === 0) continue;
const pct = (d.count / total * 100);
const pctStr = pct.toFixed(1);
const color = colors[name] || '#FDEA45';
const cls = lightDevices.has(name) ? ' seg-light' : '';
barHtml += `<div class="status-bar-segment${cls}" style="width:${pctStr}%;background:${color}" title="${name}: ${d.count.toLocaleString('de-DE')} (${pctStr}%)">${pct > 5 ? pctStr + '%' : ''}</div>`;
legendHtml += `<div class="status-bar-legend-item"><span class="status-dot" style="background:${color}"></span>${name}: ${d.count.toLocaleString('de-DE')} (${pctStr}%)</div>`;
}
barHtml += '</div>';
legendHtml += '</div>';
return barHtml + legendHtml;
}
function renderTopPagesMini(pages) {
let html = '<ul class="top-pages-mini">';
for (const p of pages) {
html += `<li>
<span class="page-path" title="${escapeHtml(p.path)}">${escapeHtml(p.path)}</span>
<span class="page-count">${p.count.toLocaleString('de-DE')}</span>
</li>`;
}
html += '</ul>';
return html;
}
function renderTopPages(aggregates) {
const container = document.getElementById("top-pages-table");
if (!aggregates || !aggregates.topPages.length) {
container.innerHTML = "<div class='empty-hint'>Keine Daten vorhanden.</div>";
document.getElementById("top-pages-pagination").innerHTML = "";
return;
}
let pages = aggregates.topPages;
if (topPagesSearch) {
pages = pages.filter(p => matchSearch(p.path, topPagesSearch));
}
if (!pages.length) {
container.innerHTML = `<div class='empty-hint'>Keine Treffer für „${escapeHtml(topPagesSearch)}"</div>`;
document.getElementById("top-pages-pagination").innerHTML = "";
return;
}
pages = sortData(pages, 'topPages');
const totalPages = Math.ceil(pages.length / PAGE_SIZE);
if (tablePages.topPages > totalPages) tablePages.topPages = totalPages;
const start = (tablePages.topPages - 1) * PAGE_SIZE;
const slice = pages.slice(start, start + PAGE_SIZE);
let html = resultCountHtml(pages.length, aggregates.topPages.length, 'Seiten');
html += "<table><thead><tr>";
html += `<th onclick="handleTableSort('topPages','path')">Pfad${sortIndicator('topPages','path')}</th>`;
html += `<th onclick="handleTableSort('topPages','count')">Hits${sortIndicator('topPages','count')}</th>`;
html += `<th onclick="handleTableSort('topPages','botHits')">Bot-Hits${sortIndicator('topPages','botHits')}</th>`;
html += `<th onclick="handleTableSort('topPages','userHits')">User-Hits${sortIndicator('topPages','userHits')}</th>`;
html += `<th onclick="handleTableSort('topPages','bytes')">Bytes${sortIndicator('topPages','bytes')}</th>`;
html += "</tr></thead><tbody>";
for (const p of slice) {
html += `<tr>
<td title="${escapeHtml(p.path)}">${escapeHtml(p.path)}</td>
<td>${p.count.toLocaleString('de-DE')}</td>
<td>${(p.botHits || 0).toLocaleString('de-DE')}</td>
<td>${(p.userHits || 0).toLocaleString('de-DE')}</td>
<td>${formatBytes(p.bytes || 0)}</td>
</tr>`;
}
html += "</tbody></table>";
container.innerHTML = html;
registerTableExport('topPages', 'Top-Seiten', ['Pfad', 'Hits', 'Bot-Hits', 'User-Hits', 'Bytes'],
() => pages.map(p => [p.path, p.count, p.botHits || 0, p.userHits || 0, p.bytes || 0]));
renderTableFooter('top-pages-pagination', {
page: tablePages.topPages, totalPages, exportId: 'topPages',
onPageChange(dir) {
if (dir === 'prev' && tablePages.topPages > 1) tablePages.topPages--;
if (dir === 'next' && tablePages.topPages < totalPages) tablePages.topPages++;
renderTopPages(aggregates);
}
});
}
function renderDirectories(aggregates, filteredEntries) {
const container = document.getElementById("directories-table");
if (!aggregates || !aggregates.topPages.length) {
container.innerHTML = "<div class='empty-hint'>Keine Daten vorhanden.</div>";
document.getElementById("directories-pagination").innerHTML = "";
return;
}
var allDirs = buildDirectoryAggregates(filteredEntries, directoryDepth);
let dirs = allDirs;
if (directoriesSearch) {
dirs = dirs.filter(d => matchSearch(d.directory, directoriesSearch));
}
if (!dirs.length) {
container.innerHTML = `<div class='empty-hint'>Keine Treffer für „${escapeHtml(directoriesSearch)}"</div>`;
document.getElementById("directories-pagination").innerHTML = "";
return;
}
dirs = sortData(dirs, 'directories');
const totalPages = Math.ceil(dirs.length / PAGE_SIZE);
if (tablePages.directories > totalPages) tablePages.directories = totalPages;
const start = (tablePages.directories - 1) * PAGE_SIZE;
const slice = dirs.slice(start, start + PAGE_SIZE);
let html = resultCountHtml(dirs.length, allDirs.length, 'Verzeichnisse');
html += "<table><thead><tr>";
html += `<th onclick="handleTableSort('directories','directory')">Verzeichnis${sortIndicator('directories','directory')}</th>`;
html += `<th onclick="handleTableSort('directories','hits')">Hits${sortIndicator('directories','hits')}</th>`;
html += `<th onclick="handleTableSort('directories','botHits')">Bot-Hits${sortIndicator('directories','botHits')}</th>`;
html += `<th onclick="handleTableSort('directories','userHits')">User-Hits${sortIndicator('directories','userHits')}</th>`;
html += `<th onclick="handleTableSort('directories','bytes')">Bytes${sortIndicator('directories','bytes')}</th>`;
html += "</tr></thead><tbody>";
for (const d of slice) {
html += `<tr>
<td title="${escapeHtml(d.directory)}">${escapeHtml(d.directory)}</td>
<td>${d.hits.toLocaleString('de-DE')}</td>
<td>${d.botHits.toLocaleString('de-DE')}</td>
<td>${d.userHits.toLocaleString('de-DE')}</td>
<td>${formatBytes(d.bytes)}</td>
</tr>`;
}
html += "</tbody></table>";
container.innerHTML = html;
registerTableExport('directories', 'Verzeichnisse', ['Verzeichnis', 'Hits', 'Bot-Hits', 'User-Hits', 'Bytes'],
() => dirs.map(d => [d.directory, d.hits, d.botHits, d.userHits, d.bytes]));
renderTableFooter('directories-pagination', {
page: tablePages.directories, totalPages, exportId: 'directories',
onPageChange(dir) {
if (dir === 'prev' && tablePages.directories > 1) tablePages.directories--;
if (dir === 'next' && tablePages.directories < totalPages) tablePages.directories++;
renderDirectories(aggregates, filteredEntries);
}
});
}
function renderStatusCodes(aggregates) {
const container = document.getElementById("status-codes-table");
if (!aggregates || !aggregates.statusList.length) {
container.innerHTML = "<div class='empty-hint'>Keine Daten vorhanden.</div>";
renderTableFooter('status-codes-footer', {});
return;
}
let statusList = sortData(aggregates.statusList, 'statusCodes');
let html = resultCountHtml(statusList.length, aggregates.statusList.length, 'Statuscodes');
html += "<table><thead><tr>";
html += `<th onclick="handleTableSort('statusCodes','status')">Status${sortIndicator('statusCodes','status')}</th>`;
html += `<th onclick="handleTableSort('statusCodes','count')">Hits${sortIndicator('statusCodes','count')}</th>`;
html += "</tr></thead><tbody>";
for (const s of statusList) {
html += `<tr>
<td>${s.status}</td>
<td>${s.count.toLocaleString('de-DE')}</td>
</tr>`;
}
html += "</tbody></table>";
container.innerHTML = html;
registerTableExport('statusCodes', 'Statuscodes', ['Status', 'Hits'], () => statusList.map(s => [s.status, s.count]));
renderTableFooter('status-codes-footer', { exportId: 'statusCodes' });
}
function renderResourceTypes(aggregates) {
const container = document.getElementById("resource-types-table");
if (!aggregates || !aggregates.resourceTypes.length) {
container.innerHTML = "<div class='empty-hint'>Keine Daten vorhanden.</div>";
renderTableFooter('resource-types-footer', {});
return;
}
let resourceTypes = sortData(aggregates.resourceTypes, 'resourceTypes');
let html = resultCountHtml(resourceTypes.length, aggregates.resourceTypes.length, 'Typen');
html += "<table><thead><tr>";
html += `<th onclick="handleTableSort('resourceTypes','type')">Typ${sortIndicator('resourceTypes','type')}</th>`;
html += `<th onclick="handleTableSort('resourceTypes','count')">Hits${sortIndicator('resourceTypes','count')}</th>`;
html += "</tr></thead><tbody>";
for (const rt of resourceTypes) {
html += `<tr>
<td>${escapeHtml(rt.type)}</td>
<td>${rt.count.toLocaleString('de-DE')}</td>
</tr>`;
}
html += "</tbody></table>";
container.innerHTML = html;
registerTableExport('resourceTypes', 'Ressourcentypen', ['Typ', 'Hits'], () => resourceTypes.map(rt => [rt.type, rt.count]));
renderTableFooter('resource-types-footer', { exportId: 'resourceTypes' });
}
function renderBrowsers(aggregates) {
const deviceContainer = document.getElementById("device-table");
const browserContainer = document.getElementById("browsers-table");
const osContainer = document.getElementById("os-table");
if (!aggregates || !aggregates.browserList || !aggregates.browserList.length) {
if (deviceContainer) deviceContainer.innerHTML = "<div class='empty-hint'>Keine Daten vorhanden.</div>";
browserContainer.innerHTML = "";
osContainer.innerHTML = "";
return;
}
const convGoals = [];
if (aggregates.conversionData) {
for (const [idx, goal] of Object.entries(aggregates.conversionData)) {
convGoals.push({ index: idx, name: goal.name, byDevice: goal.byDevice, byBrowser: goal.byBrowser, byOS: goal.byOS });
}
}
if (deviceContainer && aggregates.deviceList && aggregates.deviceList.length) {
let devices = sortData(aggregates.deviceList, 'devices');
const convHeaders = convGoals.map(g => g.name);
let dHtml = resultCountHtml(devices.length, aggregates.deviceList.length, 'Kategorien');
dHtml += "<table><thead><tr>";
dHtml += `<th onclick="handleTableSort('devices','name')">Kategorie${sortIndicator('devices','name')}</th>`;
dHtml += `<th onclick="handleTableSort('devices','count')">Hits${sortIndicator('devices','count')}</th>`;
dHtml += `<th onclick="handleTableSort('devices','sessions')">Sessions${sortIndicator('devices','sessions')}</th>`;
dHtml += `<th onclick="handleTableSort('devices','users')">Nutzer${sortIndicator('devices','users')}</th>`;
for (const g of convGoals) {
dHtml += '<th>' + escapeHtml(g.name) + '</th>';
}
dHtml += "</tr></thead><tbody>";
for (const d of devices) {
dHtml += `<tr><td>${escapeHtml(d.name)}</td><td>${d.count.toLocaleString('de-DE')}</td><td>${d.sessions.toLocaleString('de-DE')}</td><td>${d.users.toLocaleString('de-DE')}</td>`;
for (const g of convGoals) {
const conv = (g.byDevice && g.byDevice[d.name]) || 0;
dHtml += '<td>' + (conv > 0 ? conv.toLocaleString('de-DE') : '') + '</td>';
}
dHtml += '</tr>';
}
dHtml += "</tbody></table>";
deviceContainer.innerHTML = dHtml;
registerTableExport('devices', 'Geraetekategorie', ['Kategorie', 'Hits', 'Sessions', 'Nutzer', ...convHeaders], () =>
devices.map(d => [d.name, d.count, d.sessions, d.users, ...convGoals.map(g => (g.byDevice && g.byDevice[d.name]) || 0)])
);
renderTableFooter('device-footer', { exportId: 'devices' });
} else if (deviceContainer) {
deviceContainer.innerHTML = '';
renderTableFooter('device-footer', {});
}
let browsers = sortData(aggregates.browserList, 'browsers');
const convHeaders = convGoals.map(g => g.name);
let html = resultCountHtml(browsers.length, aggregates.browserList.length, 'Browser');
html += "<table><thead><tr>";
html += `<th onclick="handleTableSort('browsers','name')">Browser${sortIndicator('browsers','name')}</th>`;
html += `<th onclick="handleTableSort('browsers','count')">Hits${sortIndicator('browsers','count')}</th>`;
html += `<th onclick="handleTableSort('browsers','sessions')">Sessions${sortIndicator('browsers','sessions')}</th>`;
html += `<th onclick="handleTableSort('browsers','users')">Nutzer${sortIndicator('browsers','users')}</th>`;
for (const g of convGoals) {
html += '<th>' + escapeHtml(g.name) + '</th>';
}
html += "</tr></thead><tbody>";
for (const b of browsers) {
html += `<tr><td>${escapeHtml(b.name)}</td><td>${b.count.toLocaleString('de-DE')}</td><td>${b.sessions.toLocaleString('de-DE')}</td><td>${b.users.toLocaleString('de-DE')}</td>`;
for (const g of convGoals) {
const conv = (g.byBrowser && g.byBrowser[b.name]) || 0;
html += '<td>' + (conv > 0 ? conv.toLocaleString('de-DE') : '') + '</td>';
}
html += '</tr>';
}
html += "</tbody></table>";
browserContainer.innerHTML = html;
registerTableExport('browsers', 'Browser', ['Browser', 'Hits', 'Sessions', 'Nutzer', ...convHeaders], () =>
browsers.map(b => [b.name, b.count, b.sessions, b.users, ...convGoals.map(g => (g.byBrowser && g.byBrowser[b.name]) || 0)])
);
renderTableFooter('browsers-footer', { exportId: 'browsers' });
let osList = sortData(aggregates.osList, 'osSystems');
html = resultCountHtml(osList.length, aggregates.osList.length, 'Systeme');
html += "<table><thead><tr>";
html += `<th onclick="handleTableSort('osSystems','name')">Betriebssystem${sortIndicator('osSystems','name')}</th>`;
html += `<th onclick="handleTableSort('osSystems','count')">Hits${sortIndicator('osSystems','count')}</th>`;
html += `<th onclick="handleTableSort('osSystems','sessions')">Sessions${sortIndicator('osSystems','sessions')}</th>`;
html += `<th onclick="handleTableSort('osSystems','users')">Nutzer${sortIndicator('osSystems','users')}</th>`;
for (const g of convGoals) {
html += '<th>' + escapeHtml(g.name) + '</th>';
}
html += "</tr></thead><tbody>";
for (const o of osList) {
html += `<tr><td>${escapeHtml(o.name)}</td><td>${o.count.toLocaleString('de-DE')}</td><td>${o.sessions.toLocaleString('de-DE')}</td><td>${o.users.toLocaleString('de-DE')}</td>`;
for (const g of convGoals) {
const conv = (g.byOS && g.byOS[o.name]) || 0;
html += '<td>' + (conv > 0 ? conv.toLocaleString('de-DE') : '') + '</td>';
}
html += '</tr>';
}
html += "</tbody></table>";
osContainer.innerHTML = html;
registerTableExport('osSystems', 'Betriebssysteme', ['Betriebssystem', 'Hits', 'Sessions', 'Nutzer', ...convHeaders], () =>
osList.map(o => [o.name, o.count, o.sessions, o.users, ...convGoals.map(g => (g.byOS && g.byOS[o.name]) || 0)])
);
renderTableFooter('os-footer', { exportId: 'osSystems' });
}
function renderConversionsReport(aggregates) {
const container = document.getElementById("conversions-report-content");
if (!aggregates || !aggregates.conversionData) {
container.innerHTML = "<div class='empty-hint'>Keine Conversion-Ziele definiert. Unter Tools \u2192 Ziele definieren anlegen.</div>";
return;
}
const convData = aggregates.conversionData;
const goalIndices = Object.keys(convData).sort();
if (goalIndices.length === 0) {
container.innerHTML = "<div class='empty-hint'>Keine Conversion-Ziele definiert. Unter Tools \u2192 Ziele definieren anlegen.</div>";
return;
}
let html = '';
for (const idx of goalIndices) {
const goal = convData[idx];
html += '<div class="card" style="margin-bottom:1rem">';
html += `<div class="stats-grid" style="margin-bottom:1rem"><div class="metric-card"><h4>${escapeHtml(goal.name)}</h4><div class="value">${goal.total.toLocaleString('de-DE')}</div></div></div>`;
if (goal.daily && goal.daily.length > 0) {
html += renderConversionChart(goal.daily, aggregates.dateRange);
} else {
html += '<div class="empty-hint">Keine Conversions im Zeitraum.</div>';
}
html += '</div>';
}
container.innerHTML = html;
}
function renderConversionChart(daily, dateRange) {
if (!daily.length) return '<div class="empty-hint">Keine Daten</div>';
const rangeMin = dateRange ? dateRange.min : null;
const rangeMax = dateRange ? dateRange.max : null;
daily = fillDayGaps(daily, key => ({ day: key, count: 0 }), rangeMin, rangeMax);
let buckets;
if (daily.length <= 60) {
buckets = daily.map(d => ({
label: d.day.slice(5),
tooltip: d.day,
value: d.count
}));
} else if (daily.length <= 365) {
const weekMap = new Map();
for (const d of daily) {
const dt = new Date(d.day + 'T00:00:00');
const wk = getISOWeekLabel(dt);
weekMap.set(wk, (weekMap.get(wk) || 0) + d.count);
}
buckets = Array.from(weekMap.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([wk, val]) => ({ label: wk, tooltip: 'KW ' + wk, value: val }));
} else {
const monthMap = new Map();
for (const d of daily) {
const m = d.day.slice(0, 7);
monthMap.set(m, (monthMap.get(m) || 0) + d.count);
}
buckets = Array.from(monthMap.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([m, val]) => ({ label: m.slice(2), tooltip: m, value: val }));
}
const maxVal = buckets.reduce((m, b) => b.value > m ? b.value : m, 0);
if (maxVal === 0) return '<div class="empty-hint">Keine Conversions</div>';
let html = '<div class="chart-bar-container">';
for (const b of buckets) {
const heightPct = b.value === 0 ? 0 : Math.max((b.value / maxVal) * 100, 2);
html += `<div class="chart-bar-wrapper" data-tooltip="${b.tooltip}: ${b.value.toLocaleString('de-DE')} Conversions">
<div class="chart-bar" style="height:${heightPct}%"></div>
<div class="chart-bar-label">${b.label}</div>
</div>`;
}
html += '</div>';
return html;
}
function renderBots(aggregates) {
const summaryGrid = document.getElementById("bots-summary-grid");
const listContainer = document.getElementById("bots-list-container");
const detailContainer = document.getElementById("bot-detail-container");
if (!aggregates || !aggregates.botDetailList || aggregates.botDetailList.length === 0) {
summaryGrid.innerHTML = "";
listContainer.innerHTML = "<div class='empty-hint'>Keine Bot-Daten vorhanden.</div>";
detailContainer.innerHTML = "";
return;
}
const botList = aggregates.botDetailList;
const totalBotHits = botList.reduce((s, b) => s + b.hits, 0);
const totalBotIPs = new Set(botList.flatMap(b => Array(b.uniqueIPs))).size; // approximate
const totalBotBytes = botList.reduce((s, b) => s + b.bytes, 0);
const humanHits = (aggregates.bots.find(b => b.type === 'human') || { count: 0 }).count;
const categories = {};
for (const [key, label] of Object.entries(BOT_DEFINITIONS.categories)) {
categories[key] = { label, bots: [] };
}
for (const bot of botList) {
const cat = categories[bot.category] || categories.other;
cat.bots.push(bot);
}
summaryGrid.innerHTML = `<div class="stats-grid" style="margin-bottom:15px">
<div class="metric-card">
<h4>Bot-Hits</h4>
<div class="value">${totalBotHits.toLocaleString('de-DE')}</div>
</div>
<div class="metric-card">
<h4>Browser-Hits</h4>
<div class="value">${humanHits.toLocaleString('de-DE')}</div>
</div>
<div class="metric-card">
<h4>Bot-Anteil</h4>
<div class="value">${totalBotHits + humanHits > 0 ? (totalBotHits / (totalBotHits + humanHits) * 100).toFixed(1) : 0}%</div>
</div>
<div class="metric-card">
<h4>Erkannte Bots</h4>
<div class="value">${botList.length}</div>
</div>
<div class="metric-card">
<h4>Bot-Traffic</h4>
<div class="value">${formatBytes(totalBotBytes)}</div>
</div>
<div class="metric-card">
<h4>Top-Bot</h4>
<div class="value" style="font-size:1rem">${escapeHtml(botList[0].name)}</div>
</div>
</div>`;
if (aggregates.botCategoryStats && aggregates.botCategoryStats.length > 0) {
const hasRT = aggregates.botCategoryStats.some(c => c.responseTimeMedian !== null);
let catHtml = '<h3 style="font-size:0.9rem;margin:15px 0 8px">Kategorie-Vergleich</h3>';
catHtml += '<table><thead><tr>';
catHtml += '<th style="cursor:default">Kategorie</th><th style="cursor:default">Hits</th><th style="cursor:default">Bots</th>';
catHtml += '<th style="cursor:default">429-Rate</th><th style="cursor:default">408-Rate</th><th style="cursor:default">Fehlerrate</th>';
if (hasRT) catHtml += '<th style="cursor:default">Median RT</th><th style="cursor:default">P95 RT</th>';
catHtml += '</tr></thead><tbody>';
for (const cat of aggregates.botCategoryStats) {
catHtml += '<tr>';
catHtml += `<td>${cat.indent ? '<span style="padding-left:1em;opacity:0.7">↳ </span>' : ''}${escapeHtml(cat.label)}</td>`;
catHtml += `<td>${cat.hits.toLocaleString('de-DE')}</td>`;
catHtml += `<td>${cat.botCount}</td>`;
catHtml += `<td>${cat.rate429.toFixed(1)}%</td>`;
catHtml += `<td>${cat.rate408.toFixed(1)}%</td>`;
catHtml += `<td>${cat.rateError.toFixed(1)}%</td>`;
if (hasRT) {
catHtml += `<td>${formatResponseTime(cat.responseTimeMedian)}</td>`;
catHtml += `<td>${formatResponseTime(cat.responseTimeP95)}</td>`;
}
catHtml += '</tr>';
}
catHtml += '</tbody></table>';
summaryGrid.insertAdjacentHTML('beforeend', catHtml);
}
if (!collapsedBotCategories) {
collapsedBotCategories = new Set(
Object.keys(BOT_DEFINITIONS.categories).filter(k => k !== 'ai' && k !== 'search')
);
}
let listHtml = '';
for (const [catKey, cat] of Object.entries(categories)) {
if (cat.bots.length === 0) continue;
const isCollapsed = collapsedBotCategories.has(catKey);
listHtml += `<div class="bot-category-label" onclick="toggleBotCategory('${catKey}')">
<span class="collapse-arrow${isCollapsed ? '' : ' open'}">\u25B6</span> ${cat.label} (${cat.bots.length})
</div>`;
listHtml += `<ul class="bot-list${isCollapsed ? ' collapsed' : ''}">`;
for (const bot of cat.bots) {
const isSelected = selectedBotName === bot.name;
const purposeBadge = bot.aiPurpose ? ` <span class="ai-purpose-badge ${bot.aiPurpose}">${AI_PURPOSE_LABELS[bot.aiPurpose]}</span>` : '';
listHtml += `<li class="${isSelected ? 'selected' : ''}" onclick="selectBot('${escapeHtml(bot.name.replace(/'/g, "\\'"))}')" title="${bot.uniqueIPs} IP(s), ${formatBytes(bot.bytes)}">
<span class="bot-name">${escapeHtml(bot.name)}${purposeBadge}</span>
<span class="bot-hits">${bot.hits.toLocaleString('de-DE')} Hits</span>
</li>`;
}
listHtml += '</ul>';
}
listContainer.innerHTML = listHtml;
if (selectedBotName) {
const bot = botList.find(b => b.name === selectedBotName);
if (bot) {
renderBotDetail(detailContainer, bot);
} else {
selectedBotName = null;
detailContainer.innerHTML = '<div class="empty-hint">Wähle einen Bot aus der Liste, um dessen Top-Seiten zu sehen.</div>';
}
} else {
detailContainer.innerHTML = '<div class="empty-hint">Wähle einen Bot aus der Liste, um dessen Top-Seiten zu sehen.</div>';
}
}
function selectBot(name) {
selectedBotName = selectedBotName === name ? null : name;
renderCurrentView();
}
function toggleBotCategory(catKey) {
if (!collapsedBotCategories) return;
if (collapsedBotCategories.has(catKey)) {
collapsedBotCategories.delete(catKey);
} else {
collapsedBotCategories.add(catKey);
}
renderCurrentView();
}
function renderBotDetail(container, bot) {
const catLabels = BOT_DEFINITIONS.categories;
const totalHits = bot.hits;
const hasRT = bot.responseTimeMedian !== null;
const cols = hasRT ? 'repeat(auto-fit,minmax(100px,1fr))' : 'repeat(3,1fr)';
const purposeBadge = bot.aiPurpose ? ` <span class="ai-purpose-badge ${bot.aiPurpose}">${AI_PURPOSE_LABELS[bot.aiPurpose]}</span>` : '';
let html = `<div class="bot-detail-section">
<h3>${escapeHtml(bot.name)}</h3>
<div style="font-size:0.8rem;color:var(--text-hint);margin-bottom:12px">${catLabels[bot.category] || 'Bot'}${purposeBadge}</div>
<div class="stats-grid" style="grid-template-columns:${cols};margin-bottom:15px">
<div class="metric-card">
<h4>Hits</h4>
<div class="value" style="font-size:1.2rem">${bot.hits.toLocaleString('de-DE')}</div>
</div>
<div class="metric-card">
<h4>Pfade</h4>
<div class="value" style="font-size:1.2rem">${bot.uniquePaths.toLocaleString('de-DE')}</div>
</div>
<div class="metric-card">
<h4>IPs</h4>
<div class="value" style="font-size:1.2rem">${bot.uniqueIPs.toLocaleString('de-DE')}</div>
</div>
<div class="metric-card">
<h4>Traffic</h4>
<div class="value" style="font-size:1.2rem">${formatBytes(bot.bytes)}</div>
</div>`;
const count429 = (bot.statusCounts && bot.statusCounts[429]) || 0;
const errorCount = bot.statusCounts
? Object.entries(bot.statusCounts)
.filter(([code]) => { const s = parseInt(code, 10); return s >= 400 && s <= 599; })
.reduce((sum, [, c]) => sum + c, 0)
: 0;
html += `<div class="metric-card">
<h4>429-Rate</h4>
<div class="value" style="font-size:1.2rem">${totalHits > 0 ? (count429 / totalHits * 100).toFixed(1) : '0.0'}%</div>
</div>
<div class="metric-card">
<h4>Fehlerrate</h4>
<div class="value" style="font-size:1.2rem">${totalHits > 0 ? (errorCount / totalHits * 100).toFixed(1) : '0.0'}%</div>
</div>`;
if (hasRT) {
html += `<div class="metric-card">
<h4>Median RT</h4>
<div class="value" style="font-size:1.2rem">${formatResponseTime(bot.responseTimeMedian)}</div>
</div>
<div class="metric-card">
<h4>P95 RT</h4>
<div class="value" style="font-size:1.2rem">${formatResponseTime(bot.responseTimeP95)}</div>
</div>`;
}
html += '</div>'; // close stats-grid
if (bot.topPages.length > 0) {
const shownPages = bot.topPages.length;
const totalPages = bot.uniquePaths || shownPages;
html += `<h3 style="font-size:0.9rem;margin-bottom:8px">Top ${shownPages} von ${totalPages.toLocaleString('de-DE')} Pfaden</h3>`;
html += '<table><thead><tr><th style="cursor:default">Pfad</th><th style="cursor:default">Hits</th></tr></thead><tbody>';
for (const p of bot.topPages) {
html += `<tr>
<td title="${escapeHtml(p.path)}">${escapeHtml(p.path.length > 50 ? p.path.slice(0, 47) + '...' : p.path)}</td>
<td>${p.count.toLocaleString('de-DE')}</td>
</tr>`;
}
html += '</tbody></table>';
}
const statusEntries = bot.statusCounts
? Object.entries(bot.statusCounts)
.map(([code, count]) => ({ code: parseInt(code, 10), count }))
.filter(s => s.code > 0)
.sort((a, b) => a.code - b.code)
: [];
if (statusEntries.length > 0) {
html += '<h3 style="font-size:0.9rem;margin:15px 0 8px">Statuscodes</h3>';
html += '<table><thead><tr><th style="cursor:default">Code</th><th style="cursor:default">Anzahl</th><th style="cursor:default">Anteil</th></tr></thead><tbody>';
for (const s of statusEntries) {
const pct = totalHits > 0 ? (s.count / totalHits * 100).toFixed(1) : '0.0';
html += `<tr><td>${s.code}</td><td>${s.count.toLocaleString('de-DE')}</td><td>${pct}%</td></tr>`;
}
html += '</tbody></table>';
}
html += '</div>';
container.innerHTML = html;
}
function renderEntryPages(aggregates) {
const container = document.getElementById("entry-pages-table");
if (!aggregates || !aggregates.entryPages || !aggregates.entryPages.length) {
container.innerHTML = "<div class='empty-hint'>Keine Einstiegsdaten vorhanden.</div>";
renderTableFooter('entry-pages-pagination', {});
return;
}
let data = aggregates.entryPages;
if (entryPagesSearch) {
data = data.filter(p => matchSearch(p.path, entryPagesSearch));
}
if (!data.length) {
container.innerHTML = `<div class='empty-hint'>Keine Treffer für „${escapeHtml(entryPagesSearch)}"</div>`;
renderTableFooter('entry-pages-pagination', {});
return;
}
data = sortData(data, 'entryPages');
const totalPages = Math.ceil(data.length / PAGE_SIZE);
if (tablePages.entryPages > totalPages) tablePages.entryPages = totalPages;
const start = (tablePages.entryPages - 1) * PAGE_SIZE;
const slice = data.slice(start, start + PAGE_SIZE);
let html = resultCountHtml(data.length, aggregates.entryPages.length, 'Seiten');
html += '<table><thead><tr>';
html += `<th onclick="handleTableSort('entryPages','path')">Seite${sortIndicator('entryPages','path')}</th>`;
html += `<th onclick="handleTableSort('entryPages','count')">Sessions${sortIndicator('entryPages','count')}</th>`;
html += '</tr></thead><tbody>';
for (const p of slice) {
html += `<tr>
<td title="${escapeHtml(p.path)}">${escapeHtml(p.path.length > 60 ? p.path.slice(0, 57) + '...' : p.path)}</td>
<td>${p.count.toLocaleString('de-DE')}</td>
</tr>`;
}
html += '</tbody></table>';
container.innerHTML = html;
registerTableExport('entryPages', 'Einstiegsseiten', ['Seite', 'Sessions'], () => data.map(p => [p.path, p.count]));
renderTableFooter('entry-pages-pagination', {
page: tablePages.entryPages, totalPages, exportId: 'entryPages',
onPageChange(dir) {
if (dir === 'prev' && tablePages.entryPages > 1) tablePages.entryPages--;
if (dir === 'next' && tablePages.entryPages < totalPages) tablePages.entryPages++;
renderEntryPages(aggregates);
}
});
}
function renderExitPages(aggregates) {
const container = document.getElementById("exit-pages-table");
if (!aggregates || !aggregates.exitPages || !aggregates.exitPages.length) {
container.innerHTML = "<div class='empty-hint'>Keine Ausstiegsdaten vorhanden.</div>";
renderTableFooter('exit-pages-pagination', {});
return;
}
let data = aggregates.exitPages;
if (exitPagesSearch) {
data = data.filter(p => matchSearch(p.path, exitPagesSearch));
}
if (!data.length) {
container.innerHTML = `<div class='empty-hint'>Keine Treffer für „${escapeHtml(exitPagesSearch)}"</div>`;
renderTableFooter('exit-pages-pagination', {});
return;
}
data = sortData(data, 'exitPages');
const totalPages = Math.ceil(data.length / PAGE_SIZE);
if (tablePages.exitPages > totalPages) tablePages.exitPages = totalPages;
const start = (tablePages.exitPages - 1) * PAGE_SIZE;
const slice = data.slice(start, start + PAGE_SIZE);
let html = resultCountHtml(data.length, aggregates.exitPages.length, 'Seiten');
html += '<table><thead><tr>';
html += `<th onclick="handleTableSort('exitPages','path')">Seite${sortIndicator('exitPages','path')}</th>`;
html += `<th onclick="handleTableSort('exitPages','count')">Sessions${sortIndicator('exitPages','count')}</th>`;
html += '</tr></thead><tbody>';
for (const p of slice) {
html += `<tr>
<td title="${escapeHtml(p.path)}">${escapeHtml(p.path.length > 60 ? p.path.slice(0, 57) + '...' : p.path)}</td>
<td>${p.count.toLocaleString('de-DE')}</td>
</tr>`;
}
html += '</tbody></table>';
container.innerHTML = html;
registerTableExport('exitPages', 'Ausstiegsseiten', ['Seite', 'Sessions'], () => data.map(p => [p.path, p.count]));
renderTableFooter('exit-pages-pagination', {
page: tablePages.exitPages, totalPages, exportId: 'exitPages',
onPageChange(dir) {
if (dir === 'prev' && tablePages.exitPages > 1) tablePages.exitPages--;
if (dir === 'next' && tablePages.exitPages < totalPages) tablePages.exitPages++;
renderExitPages(aggregates);
}
});
}
function renderTransitions(aggregates) {
const container = document.getElementById("transitions-table");
if (!aggregates || !aggregates.pathSequences || !aggregates.pathSequences.length) {
container.innerHTML = "<div class='empty-hint'>Keine Übergangsdaten vorhanden.</div>";
renderTableFooter('transitions-pagination', {});
return;
}
let data = aggregates.pathSequences;
if (transitionsSearch) {
data = data.filter(s =>
matchSearch(s.from, transitionsSearch) ||
matchSearch(s.to, transitionsSearch)
);
}
if (!data.length) {
container.innerHTML = `<div class='empty-hint'>Keine Treffer für „${escapeHtml(transitionsSearch)}"</div>`;
renderTableFooter('transitions-pagination', {});
return;
}
data = sortData(data, 'transitions');
const totalPages = Math.ceil(data.length / PAGE_SIZE);
if (tablePages.transitions > totalPages) tablePages.transitions = totalPages;
const start = (tablePages.transitions - 1) * PAGE_SIZE;
const slice = data.slice(start, start + PAGE_SIZE);
let html = resultCountHtml(data.length, aggregates.pathSequences.length, 'Übergänge');
html += '<table><thead><tr>';
html += `<th onclick="handleTableSort('transitions','from')">Von${sortIndicator('transitions','from')}</th>`;
html += '<th style="cursor:default;text-align:center;width:30px">&rarr;</th>';
html += `<th onclick="handleTableSort('transitions','to')">Nach${sortIndicator('transitions','to')}</th>`;
html += `<th onclick="handleTableSort('transitions','count')">Sessions${sortIndicator('transitions','count')}</th>`;
html += '</tr></thead><tbody>';
for (const s of slice) {
html += `<tr>
<td title="${escapeHtml(s.from)}">${escapeHtml(s.from.length > 45 ? s.from.slice(0, 42) + '...' : s.from)}</td>
<td style="text-align:center;color:var(--text-hint)">&rarr;</td>
<td title="${escapeHtml(s.to)}">${escapeHtml(s.to.length > 45 ? s.to.slice(0, 42) + '...' : s.to)}</td>
<td>${s.count.toLocaleString('de-DE')}</td>
</tr>`;
}
html += '</tbody></table>';
container.innerHTML = html;
registerTableExport('transitions', 'Pfaduebergaenge', ['Von', 'Nach', 'Sessions'], () => data.map(s => [s.from, s.to, s.count]));
renderTableFooter('transitions-pagination', {
page: tablePages.transitions, totalPages, exportId: 'transitions',
onPageChange(dir) {
if (dir === 'prev' && tablePages.transitions > 1) tablePages.transitions--;
if (dir === 'next' && tablePages.transitions < totalPages) tablePages.transitions++;
renderTransitions(aggregates);
}
});
}
function analyzeNavOverview() {
var input = document.getElementById('nav-overview-search');
var pattern = input ? input.value.trim() : '';
if (!pattern) {
document.getElementById('nav-overview-content').innerHTML = '<div class="empty-hint">Seite oder Muster eingeben und \u201eAnalysieren\u201c klicken.</div>';
return;
}
var data = getFilteredData();
var sessions = filterSessionsBySource(data.sessions, navOverviewSourceFilter);
navOverviewData = buildNavigationOverview(sessions, pattern, searchMode, searchNegate);
navOverviewNegate = searchNegate;
navOverviewSessions = data.sessions;
navOverviewShowPrev = 10;
navOverviewShowNext = 10;
renderNavOverviewResult();
}
function renderNavOverview(sessions) {
var container = document.getElementById('nav-overview-content');
if (navOverviewData) {
renderNavOverviewResult();
} else {
container.innerHTML = '<div class="empty-hint">Seite oder Muster eingeben und \u201eAnalysieren\u201c klicken.</div>';
}
}
function renderNavOverviewResult() {
var container = document.getElementById('nav-overview-content');
var d = navOverviewData;
if (!d || d.totalHits === 0) {
container.innerHTML = '<div class="empty-hint">Keine Seiten gefunden, die dem Muster entsprechen.</div>';
return;
}
var html = '<div class="journey-filters">';
html += renderSourceDropdown('nav-overview-source', navOverviewSourceFilter, navOverviewSessions || []);
html += '</div>';
html += '<div class="nav-overview-flow">';
html += '<div class="nav-overview-col nav-overview-prev">';
html += '<h3 class="nav-overview-col-title">&larr; Vorherige Seiten</h3>';
html += renderNavOverviewList(d.previousPages, navOverviewShowPrev, 'prev');
html += '</div>';
html += '<div class="nav-overview-col nav-overview-center">';
html += '<div class="nav-overview-current">';
var patternLabel = navOverviewNegate ? '<span class="nav-overview-negate">NICHT</span> ' + escapeHtml(d.pattern) : escapeHtml(d.pattern);
html += '<div class="nav-overview-pattern">' + patternLabel + '</div>';
html += '<div class="nav-overview-hits">' + d.totalHits.toLocaleString('de-DE') + ' Aufrufe</div>';
html += '</div>';
html += '<div class="nav-overview-kpis">';
html += '<div class="nav-overview-kpi nav-overview-kpi-entry">';
html += '<div class="nav-overview-kpi-label">Einstiege</div>';
html += '<div class="nav-overview-kpi-value">' + d.entries.toLocaleString('de-DE') + '</div>';
html += '<div class="nav-overview-kpi-pct">' + d.entriesPercent.toLocaleString('de-DE') + '%</div>';
html += '</div>';
html += '<div class="nav-overview-kpi nav-overview-kpi-exit">';
html += '<div class="nav-overview-kpi-label">Ausstiege</div>';
html += '<div class="nav-overview-kpi-value">' + d.exits.toLocaleString('de-DE') + '</div>';
html += '<div class="nav-overview-kpi-pct">' + d.exitsPercent.toLocaleString('de-DE') + '%</div>';
html += '</div>';
html += '</div>';
html += '</div>';
html += '<div class="nav-overview-col nav-overview-next">';
html += '<h3 class="nav-overview-col-title">N\u00e4chste Seiten &rarr;</h3>';
html += renderNavOverviewList(d.nextPages, navOverviewShowNext, 'next');
html += '</div>';
html += '</div>';
container.innerHTML = html;
var srcSelect = document.getElementById('nav-overview-source');
if (srcSelect) {
srcSelect.onchange = function() {
navOverviewSourceFilter = this.value;
analyzeNavOverview();
};
}
}
function renderNavOverviewList(pages, showCount, direction) {
if (!pages.length) return '<div class="nav-overview-empty">Keine</div>';
var visible = pages.slice(0, showCount);
var remaining = pages.slice(showCount);
var html = '<div class="nav-overview-list">';
for (var i = 0; i < visible.length; i++) {
var p = visible[i];
var displayPath = p.path.length > 40 ? p.path.slice(0, 37) + '\u2026' : p.path;
html += '<div class="nav-overview-item">';
html += '<span class="nav-overview-path" title="' + escapeHtml(p.path) + '">' + escapeHtml(displayPath) + '</span>';
html += '<span class="nav-overview-count">' + p.count.toLocaleString('de-DE') + '</span>';
html += '<span class="nav-overview-pct">' + p.percent.toLocaleString('de-DE') + '%</span>';
html += '</div>';
}
if (remaining.length > 0) {
var remCount = 0;
for (var j = 0; j < remaining.length; j++) remCount += remaining[j].count;
var totalCount = 0;
for (var k = 0; k < pages.length; k++) totalCount += pages[k].count;
var remPct = totalCount ? (remCount / totalCount * 100).toFixed(1) : '0';
html += '<div class="nav-overview-remaining">' + remaining.length + ' weitere (' + remCount.toLocaleString('de-DE') + ' Aufrufe, ' + remPct.replace('.', ',') + '%)</div>';
html += '<button type="button" class="nav-overview-more" onclick="navOverviewLoadMore(\'' + direction + '\')">' + Math.min(10, remaining.length) + ' mehr anzeigen</button>';
}
html += '</div>';
return html;
}
function navOverviewLoadMore(direction) {
if (direction === 'prev') navOverviewShowPrev += 10;
else navOverviewShowNext += 10;
renderNavOverviewResult();
}
function renderSources(aggregates) {
const container = document.getElementById("sources-content");
if (!aggregates) {
container.innerHTML = "<div class='empty-hint'>Keine Daten vorhanden.</div>";
return;
}
let html = '';
const currentHost = AppState.ownHost || '(nicht konfiguriert)';
html += '<div class="card" style="padding:0.75rem 1rem;margin-bottom:0.5rem;display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap">';
html += '<span style="opacity:0.7">Eigener Host:</span>';
html += '<strong id="own-host-display">' + escapeHtml(currentHost) + '</strong>';
html += ' <button class="btn btn-sm" id="own-host-edit-btn" style="padding:0.2rem 0.5rem;font-size:0.8rem">Ändern</button>';
html += '<input type="text" id="own-host-input" value="' + escapeHtml(AppState.ownHost || '') + '" style="display:none;width:15rem" placeholder="z.B. example.com">';
html += '<button class="btn btn-sm" id="own-host-save-btn" style="display:none;padding:0.2rem 0.5rem;font-size:0.8rem">Speichern</button>';
html += '<button class="btn btn-sm" id="own-host-cancel-btn" style="display:none;padding:0.2rem 0.5rem;font-size:0.8rem">Abbrechen</button>';
html += '</div>';
const convGoals = [];
if (aggregates.conversionData) {
for (const [idx, goal] of Object.entries(aggregates.conversionData)) {
convGoals.push({ index: idx, name: goal.name, byReferrer: goal.byReferrer, byCampaign: goal.byCampaign, byClickId: goal.byClickId, total: goal.total });
}
}
const allReferrers = aggregates.topReferrers || [];
let referrersWithUnattr = allReferrers;
if (convGoals.length > 0) {
const hasUnattr = convGoals.some(g => g.byReferrer && g.byReferrer['(intern/unbekannt)'] > 0);
if (hasUnattr && !allReferrers.some(r => r.domain === '(intern/unbekannt)')) {
referrersWithUnattr = [...allReferrers, { domain: '(intern/unbekannt)', count: 0, sessions: 0, users: 0 }];
}
}
let referrers = referrersWithUnattr;
if (sourcesSearch) {
referrers = referrers.filter(r => matchSearch(r.domain, sourcesSearch));
}
const convHeaders = convGoals.map(g => g.name);
let totalRefPages = 1;
if (referrers.length > 0) {
let refData = sortData(referrers, 'sources');
totalRefPages = Math.ceil(refData.length / PAGE_SIZE);
if (tablePages.sources > totalRefPages) tablePages.sources = totalRefPages;
const refStart = (tablePages.sources - 1) * PAGE_SIZE;
const refSlice = refData.slice(refStart, refStart + PAGE_SIZE);
html += '<div class="card"><h3>Top-Referrer</h3>';
html += resultCountHtml(referrers.length, referrersWithUnattr.length, 'Referrer');
html += '<div class="table-container"><table><thead><tr>';
html += `<th onclick="handleTableSort('sources','domain')">Domain${sortIndicator('sources','domain')}</th>`;
html += `<th onclick="handleTableSort('sources','count')">Hits${sortIndicator('sources','count')}</th>`;
html += `<th onclick="handleTableSort('sources','sessions')">Sessions${sortIndicator('sources','sessions')}</th>`;
html += `<th onclick="handleTableSort('sources','users')">Nutzer${sortIndicator('sources','users')}</th>`;
for (const g of convGoals) {
html += '<th>' + escapeHtml(g.name) + '</th>';
}
html += '</tr></thead><tbody>';
for (const r of refSlice) {
html += '<tr><td>' + escapeHtml(r.domain) + '</td><td>' + (r.count > 0 ? r.count.toLocaleString('de-DE') : '') + '</td><td>' + (r.sessions > 0 ? r.sessions.toLocaleString('de-DE') : '') + '</td><td>' + (r.users > 0 ? r.users.toLocaleString('de-DE') : '') + '</td>';
for (const g of convGoals) {
const conv = (g.byReferrer && g.byReferrer[r.domain]) || 0;
html += '<td>' + (conv > 0 ? conv.toLocaleString('de-DE') : '') + '</td>';
}
html += '</tr>';
}
html += '</tbody></table></div></div>';
html += '<div class="pagination" id="sources-footer"></div>';
} else {
html += '<div class="card"><h3>Top-Referrer</h3><div class="empty-hint">Keine Referrer-Daten vorhanden.</div></div>';
}
const allCampaigns = aggregates.campaigns || [];
let campaignsWithOther = allCampaigns;
if (convGoals.length > 0) {
const hasAndere = convGoals.some(g => g.byCampaign && g.byCampaign['(Andere)'] > 0);
if (hasAndere && !allCampaigns.some(c => c.source === '(Andere)')) {
campaignsWithOther = [...allCampaigns, { source: '(Andere)', medium: '', campaign: '', count: 0, sessions: 0, users: 0 }];
}
}
let campaigns = campaignsWithOther;
if (sourcesSearch) {
campaigns = campaigns.filter(c =>
matchSearch(c.source, sourcesSearch) ||
matchSearch(c.medium, sourcesSearch) ||
matchSearch(c.campaign, sourcesSearch)
);
}
let totalCampPages = 1;
if (campaigns.length > 0) {
let campData = sortData(campaigns, 'campaigns');
totalCampPages = Math.ceil(campData.length / PAGE_SIZE);
if (tablePages.campaigns > totalCampPages) tablePages.campaigns = totalCampPages;
const campStart = (tablePages.campaigns - 1) * PAGE_SIZE;
const campSlice = campData.slice(campStart, campStart + PAGE_SIZE);
html += '<div class="card"><h3>UTM-Kampagnen</h3>';
html += resultCountHtml(campaigns.length, campaignsWithOther.length, 'Kampagnen');
html += '<div class="table-container"><table><thead><tr>';
html += `<th onclick="handleTableSort('campaigns','source')">Source${sortIndicator('campaigns','source')}</th>`;
html += `<th onclick="handleTableSort('campaigns','medium')">Medium${sortIndicator('campaigns','medium')}</th>`;
html += `<th onclick="handleTableSort('campaigns','campaign')">Campaign${sortIndicator('campaigns','campaign')}</th>`;
html += `<th onclick="handleTableSort('campaigns','count')">Hits${sortIndicator('campaigns','count')}</th>`;
html += `<th onclick="handleTableSort('campaigns','sessions')">Sessions${sortIndicator('campaigns','sessions')}</th>`;
html += `<th onclick="handleTableSort('campaigns','users')">Nutzer${sortIndicator('campaigns','users')}</th>`;
for (const g of convGoals) {
html += '<th>' + escapeHtml(g.name) + '</th>';
}
html += '</tr></thead><tbody>';
for (const c of campSlice) {
html += '<tr>';
html += '<td>' + escapeHtml(c.source) + '</td>';
html += '<td>' + escapeHtml(c.medium) + '</td>';
html += '<td>' + escapeHtml(c.campaign) + '</td>';
html += '<td>' + (c.count > 0 ? c.count.toLocaleString('de-DE') : '') + '</td>';
html += '<td>' + (c.sessions > 0 ? c.sessions.toLocaleString('de-DE') : '') + '</td>';
html += '<td>' + (c.users > 0 ? c.users.toLocaleString('de-DE') : '') + '</td>';
for (const g of convGoals) {
const isAndere = c.source === '(Andere)' && !c.medium && !c.campaign;
const campKey = isAndere ? '(Andere)' : (c.source + '|' + c.medium + '|' + c.campaign);
const conv = (g.byCampaign && g.byCampaign[campKey]) || 0;
html += '<td>' + (conv > 0 ? conv.toLocaleString('de-DE') : '') + '</td>';
}
html += '</tr>';
}
html += '</tbody></table></div></div>';
html += '<div class="pagination" id="campaigns-footer"></div>';
}
const allClickIds = Object.entries(aggregates.clickIds || {})
.filter(([, v]) => v > 0)
.map(([param, count]) => ({
label: CLICK_ID_LABELS[param] || param, param, count,
sessions: (aggregates.clickIdSessions && aggregates.clickIdSessions[param]) || 0,
users: (aggregates.clickIdUsers && aggregates.clickIdUsers[param]) || 0
}));
let clickIds = allClickIds;
if (sourcesSearch) {
clickIds = clickIds.filter(c => matchSearch(c.label, sourcesSearch) || matchSearch(c.param, sourcesSearch));
}
if (clickIds.length > 0) {
let cidData = sortData(clickIds, 'clickIds');
html += '<div class="card"><h3>Klick-IDs</h3>';
html += resultCountHtml(clickIds.length, allClickIds.length, 'Klick-IDs');
html += '<div class="table-container"><table><thead><tr>';
html += `<th onclick="handleTableSort('clickIds','label')">Plattform${sortIndicator('clickIds','label')}</th>`;
html += `<th onclick="handleTableSort('clickIds','param')">Parameter${sortIndicator('clickIds','param')}</th>`;
html += `<th onclick="handleTableSort('clickIds','count')">Hits${sortIndicator('clickIds','count')}</th>`;
html += `<th onclick="handleTableSort('clickIds','sessions')">Sessions${sortIndicator('clickIds','sessions')}</th>`;
html += `<th onclick="handleTableSort('clickIds','users')">Nutzer${sortIndicator('clickIds','users')}</th>`;
for (const g of convGoals) {
html += '<th>' + escapeHtml(g.name) + '</th>';
}
html += '</tr></thead><tbody>';
for (const c of cidData) {
html += '<tr><td>' + escapeHtml(c.label) + '</td>';
html += '<td><code>' + escapeHtml(c.param) + '</code></td>';
html += '<td>' + c.count.toLocaleString('de-DE') + '</td>';
html += '<td>' + (c.sessions > 0 ? c.sessions.toLocaleString('de-DE') : '') + '</td>';
html += '<td>' + (c.users > 0 ? c.users.toLocaleString('de-DE') : '') + '</td>';
for (const g of convGoals) {
const conv = (g.byClickId && g.byClickId[c.label]) || 0;
html += '<td>' + (conv > 0 ? conv.toLocaleString('de-DE') : '') + '</td>';
}
html += '</tr>';
}
html += '</tbody></table></div>';
html += '<div class="pagination" id="clickids-footer"></div>';
html += '</div>';
}
container.innerHTML = html;
if (referrers.length > 0) {
registerTableExport('sources', 'Referrer', ['Domain', 'Hits', 'Sessions', 'Nutzer', ...convHeaders], () =>
sortData(referrers, 'sources').map(r => [r.domain, r.count, r.sessions, r.users, ...convGoals.map(g => (g.byReferrer && g.byReferrer[r.domain]) || 0)])
);
renderTableFooter('sources-footer', {
page: tablePages.sources, totalPages: totalRefPages, exportId: 'sources',
onPageChange(dir) {
if (dir === 'prev' && tablePages.sources > 1) tablePages.sources--;
if (dir === 'next' && tablePages.sources < totalRefPages) tablePages.sources++;
renderSources(aggregates);
}
});
}
if (campaigns.length > 0) {
registerTableExport('campaigns', 'Kampagnen', ['Source', 'Medium', 'Campaign', 'Hits', 'Sessions', 'Nutzer', ...convHeaders], () =>
sortData(campaigns, 'campaigns').map(c => {
const isAndere = c.source === '(Andere)' && !c.medium && !c.campaign;
const campKey = isAndere ? '(Andere)' : (c.source + '|' + c.medium + '|' + c.campaign);
return [c.source, c.medium, c.campaign, c.count, c.sessions, c.users, ...convGoals.map(g => (g.byCampaign && g.byCampaign[campKey]) || 0)];
})
);
renderTableFooter('campaigns-footer', {
page: tablePages.campaigns, totalPages: totalCampPages, exportId: 'campaigns',
onPageChange(dir) {
if (dir === 'prev' && tablePages.campaigns > 1) tablePages.campaigns--;
if (dir === 'next' && tablePages.campaigns < totalCampPages) tablePages.campaigns++;
renderSources(aggregates);
}
});
}
if (clickIds.length > 0) {
registerTableExport('clickIds', 'Klick-IDs', ['Plattform', 'Parameter', 'Hits', 'Sessions', 'Nutzer', ...convHeaders], () =>
sortData(clickIds, 'clickIds').map(c => [c.label, c.param, c.count, c.sessions, c.users, ...convGoals.map(g => (g.byClickId && g.byClickId[c.label]) || 0)])
);
renderTableFooter('clickids-footer', { exportId: 'clickIds' });
}
const editBtn = document.getElementById('own-host-edit-btn');
const saveBtn = document.getElementById('own-host-save-btn');
const cancelBtn = document.getElementById('own-host-cancel-btn');
const hostInput = document.getElementById('own-host-input');
const hostDisplay = document.getElementById('own-host-display');
if (editBtn) {
editBtn.addEventListener('click', () => {
hostDisplay.style.display = 'none';
editBtn.style.display = 'none';
hostInput.style.display = '';
saveBtn.style.display = '';
cancelBtn.style.display = '';
hostInput.focus();
});
}
if (saveBtn) {
saveBtn.addEventListener('click', () => {
const val = hostInput.value.trim().replace(/^www\./, '').toLowerCase();
AppState.ownHost = val;
invalidateFilterCache();
Storage.saveAll(AppState);
renderWithProgress();
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
hostInput.value = AppState.ownHost || '';
hostDisplay.style.display = '';
editBtn.style.display = '';
hostInput.style.display = 'none';
saveBtn.style.display = 'none';
cancelBtn.style.display = 'none';
});
}
}
function handleBotTimeFilter() {
const select = document.getElementById('bot-time-filter');
botTimeFilter = select ? select.value : '';
renderCurrentView();
}
function renderBotTime(aggregates) {
const container = document.getElementById("bot-time-content");
if (!aggregates || !aggregates.botHeatmap) {
container.innerHTML = "<div class='empty-hint'>Keine Bot-Daten vorhanden.</div>";
return;
}
const select = document.getElementById('bot-time-filter');
if (select && aggregates.botDetailList) {
const currentVal = botTimeFilter;
const opts = ['<option value="">Alle Bots</option>'];
for (const bot of aggregates.botDetailList) {
const sel = bot.name === currentVal ? ' selected' : '';
opts.push(`<option value="${escapeHtml(bot.name)}"${sel}>${escapeHtml(bot.name)} (${bot.hits.toLocaleString('de-DE')})</option>`);
}
select.innerHTML = opts.join('');
}
let matrix;
let filterLabel = 'Alle Bots';
if (botTimeFilter && aggregates.botHeatmapPerBot && aggregates.botHeatmapPerBot[botTimeFilter]) {
matrix = aggregates.botHeatmapPerBot[botTimeFilter];
filterLabel = botTimeFilter;
} else {
matrix = aggregates.botHeatmap;
}
const dayLabels = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const dayLabelsFull = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
let totalBotHits = 0;
let maxVal = 0;
const dayTotals = new Array(7).fill(0);
const hourTotals = new Array(24).fill(0);
for (let d = 0; d < 7; d++) {
for (let h = 0; h < 24; h++) {
const v = matrix[d][h];
totalBotHits += v;
dayTotals[d] += v;
hourTotals[h] += v;
if (v > maxVal) {
maxVal = v;
}
}
}
if (totalBotHits === 0) {
container.innerHTML = `<div class='empty-hint'>Keine Heatmap-Daten für „${escapeHtml(filterLabel)}".</div>`;
return;
}
const busiestDayIdx = dayTotals.indexOf(Math.max(...dayTotals));
const busiestHourIdx = hourTotals.indexOf(Math.max(...hourTotals));
let html = '';
html += `<div class="stats-grid">
<div class="metric-card">
<h4>Hits</h4>
<div class="value">${totalBotHits.toLocaleString('de-DE')}</div>
</div>
<div class="metric-card">
<h4>Aktivster Tag</h4>
<div class="value">${dayLabelsFull[busiestDayIdx]}</div>
</div>
<div class="metric-card">
<h4>Aktivste Stunde</h4>
<div class="value">${busiestHourIdx}:00–${busiestHourIdx}:59</div>
</div>
</div>`;
html += '<div class="card"><h3>Bot-Aktivität nach Wochentag und Stunde</h3>';
html += '<div class="heatmap-grid">';
html += '<div class="heatmap-label"></div>';
for (let h = 0; h < 24; h++) {
html += `<div class="heatmap-col-label">${h}</div>`;
}
for (let d = 0; d < 7; d++) {
html += `<div class="heatmap-row-label">${dayLabels[d]}</div>`;
for (let h = 0; h < 24; h++) {
const v = matrix[d][h];
const intensity = maxVal > 0 ? v / maxVal : 0;
const level = intensity === 0 ? 0 : intensity < 0.25 ? 1 : intensity < 0.5 ? 2 : intensity < 0.75 ? 3 : 4;
const colors = [
'transparent',
'rgba(217,67,13,0.2)',
'rgba(217,67,13,0.4)',
'rgba(217,67,13,0.65)',
'rgba(217,67,13,0.9)'
];
html += `<div class="heatmap-cell" style="background:${colors[level]}" data-tooltip="${dayLabelsFull[d]} ${h}:00–${h}:59: ${v.toLocaleString('de-DE')} Hits"></div>`;
}
}
html += '</div>';
html += '<div class="heatmap-legend">';
html += '<span>Wenig</span>';
const legendColors = ['rgba(217,67,13,0.1)', 'rgba(217,67,13,0.2)', 'rgba(217,67,13,0.4)', 'rgba(217,67,13,0.65)', 'rgba(217,67,13,0.9)'];
for (const c of legendColors) {
html += `<div class="heatmap-legend-block" style="background:${c}"></div>`;
}
html += '<span>Viel</span>';
html += '</div>';
html += '</div>';
container.innerHTML = html;
}
function selectParam(name) {
selectedParamName = selectedParamName === name ? null : name;
renderCurrentView();
}
function renderParameters(aggregates) {
const listContainer = document.getElementById('parameters-list-container');
const detailContainer = document.getElementById('parameter-detail-container');
if (!aggregates || !aggregates.queryParamList || aggregates.queryParamList.length === 0) {
listContainer.innerHTML = '<div class="empty-hint">Keine Query-Parameter gefunden.</div>';
detailContainer.innerHTML = '';
return;
}
const allParamList = aggregates.queryParamList;
let paramList = allParamList;
if (parametersSearch) {
paramList = paramList.filter(p => matchSearch(p.name, parametersSearch));
}
let listHtml = resultCountHtml(paramList.length, allParamList.length, 'Parameter');
listHtml += '<ul class="bot-list">';
for (const param of paramList) {
const isSelected = selectedParamName === param.name;
listHtml += `<li class="${isSelected ? 'selected' : ''}" onclick="selectParam('${escapeHtml(param.name.replace(/'/g, "\\'"))}')">
<span class="bot-name">${escapeHtml(param.name)}</span>
<span class="bot-hits">${param.count.toLocaleString('de-DE')}</span>
</li>`;
}
listHtml += '</ul>';
listContainer.innerHTML = listHtml;
if (selectedParamName) {
const param = aggregates.queryParamList.find(p => p.name === selectedParamName);
if (param) {
renderParameterDetail(detailContainer, param);
} else {
selectedParamName = null;
detailContainer.innerHTML = '<div class="empty-hint">Wähle einen Parameter aus der Liste, um dessen Top-Werte zu sehen.</div>';
}
} else {
detailContainer.innerHTML = '<div class="empty-hint">Wähle einen Parameter aus der Liste, um dessen Top-Werte zu sehen.</div>';
}
}
function renderParameterDetail(container, param) {
let html = `<div class="bot-detail-section">
<h3>${escapeHtml(param.name)}</h3>
<div class="stats-grid" style="grid-template-columns:repeat(2,1fr);margin-bottom:15px">
<div class="metric-card">
<h4>Fundstellen</h4>
<div class="value" style="font-size:1.2rem">${param.count.toLocaleString('de-DE')}</div>
</div>
<div class="metric-card">
<h4>Eindeutige Werte</h4>
<div class="value" style="font-size:1.2rem">${param.topValues.length.toLocaleString('de-DE')}</div>
</div>
</div>`;
if (param.topValues.length > 0) {
html += '<h3 style="font-size:0.9rem;margin-bottom:8px">Top Werte</h3>';
html += '<table><thead><tr><th style="cursor:default">Wert</th><th style="cursor:default">Anzahl</th></tr></thead><tbody>';
for (const v of param.topValues) {
const displayVal = v.value.length > 60 ? v.value.slice(0, 57) + '...' : v.value;
html += `<tr>
<td title="${escapeHtml(v.value)}">${escapeHtml(displayVal)}</td>
<td>${v.count.toLocaleString('de-DE')}</td>
</tr>`;
}
html += '</tbody></table>';
}
if (param.topPaths && param.topPaths.length > 0) {
html += '<h3 style="font-size:0.9rem;margin:15px 0 8px">Top Pfade</h3>';
html += '<table><thead><tr><th style="cursor:default">Pfad</th><th style="cursor:default">Anzahl</th></tr></thead><tbody>';
for (const p of param.topPaths) {
html += `<tr>
<td title="${escapeHtml(p.path)}">${escapeHtml(p.path.length > 50 ? p.path.slice(0, 47) + '...' : p.path)}</td>
<td>${p.count.toLocaleString('de-DE')}</td>
</tr>`;
}
html += '</tbody></table>';
}
html += '</div>';
container.innerHTML = html;
}
function renderHotlinking(aggregates) {
const container = document.getElementById('hotlinking-content');
const list = aggregates.hotlinkList || [];
if (!list.length) {
container.innerHTML = '<div class="empty-hint">Keine externen Ressourcen-Einbindungen gefunden.</div>';
return;
}
let filtered = list;
if (hotlinkingSearch) {
filtered = list.filter(h => matchSearch(h.domain, hotlinkingSearch));
}
const sorted = sortData(filtered.slice(), 'hotlinking');
const page = tablePages.hotlinking || 1;
const start = (page - 1) * PAGE_SIZE;
const pageItems = sorted.slice(start, start + PAGE_SIZE);
let html = resultCountHtml(filtered.length, list.length, 'Domains');
html += `<table><thead><tr>
<th onclick="handleTableSort('hotlinking','domain')" style="cursor:pointer">Domain ${sortIndicator('hotlinking','domain')}</th>
<th onclick="handleTableSort('hotlinking','hits')" style="cursor:pointer;text-align:right">Hits ${sortIndicator('hotlinking','hits')}</th>
<th onclick="handleTableSort('hotlinking','bytes')" style="cursor:pointer;text-align:right">Bytes ${sortIndicator('hotlinking','bytes')}</th>
<th>Typen</th>
</tr></thead><tbody>`;
for (let i = 0; i < pageItems.length; i++) {
const h = pageItems[i];
const rowId = 'hl-detail-' + i;
const typesStr = h.byType.map(t => escapeHtml(t.type)).join(', ');
html += `<tr class="hotlink-row" style="cursor:pointer" onclick="document.getElementById('${rowId}').classList.toggle('collapsed')">
<td><span class="collapse-arrow" style="font-size:0.7rem;margin-right:4px">\u25B6</span>${escapeHtml(h.domain)}</td>
<td style="text-align:right">${h.hits.toLocaleString('de-DE')}</td>
<td style="text-align:right">${formatBytes(h.bytes)}</td>
<td>${typesStr}</td>
</tr>`;
html += `<tr id="${rowId}" class="collapsed"><td colspan="4" style="padding:0">
<table style="margin:4px 0 8px 20px;font-size:0.85rem;width:calc(100% - 20px)"><thead><tr>
<th>Pfad</th><th>Typ</th><th style="text-align:right">Hits</th><th style="text-align:right">Bytes</th>
</tr></thead><tbody>`;
for (const p of h.byPath) {
html += `<tr><td>${escapeHtml(p.path)}</td><td>${escapeHtml(p.type)}</td><td style="text-align:right">${p.hits.toLocaleString('de-DE')}</td><td style="text-align:right">${formatBytes(p.bytes)}</td></tr>`;
}
html += '</tbody></table></td></tr>';
}
html += '</tbody></table>';
html += '<div id="hotlinking-pagination" class="table-footer"></div>';
container.innerHTML = html;
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
renderTableFooter('hotlinking-pagination', {
page, totalPages,
onPageChange: function(dir) {
tablePages.hotlinking = dir === 'next' ? page + 1 : page - 1;
renderHotlinking(aggregates);
}
});
}
function renderIpAnomalies(aggregates) {
const container = document.getElementById('ip-anomalies-content');
const list = aggregates.ipAnomalyList || [];
if (!list.length) {
container.innerHTML = '<div class="empty-hint">Keine auff\u00e4lligen IPs gefunden.</div>';
return;
}
let filtered = list;
if (ipAnomaliesSearch) {
filtered = list.filter(a => matchSearch(a.ip, ipAnomaliesSearch) || matchSearch(a.subnet, ipAnomaliesSearch) || a.flags.some(f => matchSearch(f, ipAnomaliesSearch)));
}
const sorted = sortData(filtered.slice(), 'ipAnomalies');
const page = tablePages.ipAnomalies || 1;
const start = (page - 1) * PAGE_SIZE;
const pageItems = sorted.slice(start, start + PAGE_SIZE);
let html = resultCountHtml(filtered.length, list.length, 'IPs');
html += '<div style="margin-bottom:10px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">';
html += '<div style="font-size:0.8rem;color:var(--text-hint)">';
html += '<span class="flag-tag flag-rate">Rate</span> Hohe Request-Frequenz (Spitze pro Stunde) ';
html += '<span class="flag-tag flag-fehler">Fehler</span> Hoher Anteil 4xx/5xx-Antworten ';
html += '<span class="flag-tag flag-scan">Scan</span> Auffällig viele verschiedene Pfade aufgerufen';
html += '</div>';
html += '<button class="secondary small" onclick="exportIpList()">IP-Liste exportieren</button></div>';
html += '<table><thead><tr>';
html += '<th onclick="handleTableSort(\'ipAnomalies\',\'ip\')" style="cursor:pointer">IP ' + sortIndicator('ipAnomalies','ip') + '</th>';
html += '<th onclick="handleTableSort(\'ipAnomalies\',\'subnet\')" style="cursor:pointer">Subnetz ' + sortIndicator('ipAnomalies','subnet') + '</th>';
html += '<th onclick="handleTableSort(\'ipAnomalies\',\'hits\')" style="cursor:pointer;text-align:right">Hits ' + sortIndicator('ipAnomalies','hits') + '</th>';
html += '<th onclick="handleTableSort(\'ipAnomalies\',\'maxHitsPerHour\')" style="cursor:pointer;text-align:right">Max/h ' + hintIcon('Höchste Anzahl Hits in einer einzelnen Stunde. Grundlage für das Rate-Flag.') + ' ' + sortIndicator('ipAnomalies','maxHitsPerHour') + '</th>';
html += '<th onclick="handleTableSort(\'ipAnomalies\',\'errorRate\')" style="cursor:pointer;text-align:right">Fehlerrate ' + sortIndicator('ipAnomalies','errorRate') + '</th>';
html += '<th onclick="handleTableSort(\'ipAnomalies\',\'pathCount\')" style="cursor:pointer;text-align:right">Pfade ' + sortIndicator('ipAnomalies','pathCount') + '</th>';
html += '<th>Flags</th>';
html += '<th onclick="handleTableSort(\'ipAnomalies\',\'bytes\')" style="cursor:pointer;text-align:right">Bytes ' + sortIndicator('ipAnomalies','bytes') + '</th>';
html += '</tr></thead><tbody>';
for (let i = 0; i < pageItems.length; i++) {
const a = pageItems[i];
const rowId = 'ip-detail-' + i;
const flagsHtml = a.flags.map(f => '<span class="flag-tag flag-' + f.toLowerCase() + '">' + f + '</span>').join('');
html += '<tr style="cursor:pointer" onclick="document.getElementById(\'' + rowId + '\').classList.toggle(\'collapsed\')">';
html += '<td><span class="collapse-arrow" style="font-size:0.7rem;margin-right:4px">\u25B6</span>' + escapeHtml(a.ip) + '</td>';
html += '<td>' + escapeHtml(a.subnet) + '</td>';
html += '<td style="text-align:right">' + a.hits.toLocaleString('de-DE') + '</td>';
html += '<td style="text-align:right">' + (a.maxHitsPerHour || 0).toLocaleString('de-DE') + '</td>';
html += '<td style="text-align:right">' + a.errorRate.toFixed(1) + '%</td>';
html += '<td style="text-align:right">' + a.pathCount.toLocaleString('de-DE') + '</td>';
html += '<td>' + flagsHtml + '</td>';
html += '<td style="text-align:right">' + formatBytes(a.bytes) + '</td>';
html += '</tr>';
const firstDate = new Date(a.firstSeen).toLocaleDateString('de-DE');
const lastDate = new Date(a.lastSeen).toLocaleDateString('de-DE');
html += '<tr id="' + rowId + '" class="collapsed"><td colspan="8" style="padding:8px 8px 8px 24px;font-size:0.85rem">';
html += '<div style="margin-bottom:6px"><strong>Zeitraum:</strong> ' + firstDate + ' \u2013 ' + lastDate + '</div>';
html += '<div style="margin-bottom:6px"><strong>Status:</strong> 2xx: ' + a.statusGroups.s2xx + ', 3xx: ' + a.statusGroups.s3xx + ', 4xx: ' + a.statusGroups.s4xx + ', 5xx: ' + a.statusGroups.s5xx + '</div>';
if (a.isBot) html += '<div style="margin-bottom:6px"><strong>Bot:</strong> ' + (a.botNames && a.botNames.length ? escapeHtml(a.botNames.join(', ')) : 'Ja') + '</div>';
if (a.isAttack) html += '<div><strong>Angriff:</strong> Ja</div>';
html += '</td></tr>';
}
html += '</tbody></table>';
html += '<div id="ip-anomalies-pagination" class="table-footer"></div>';
container.innerHTML = html;
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
renderTableFooter('ip-anomalies-pagination', {
page, totalPages,
onPageChange: function(dir) {
tablePages.ipAnomalies = dir === 'next' ? page + 1 : page - 1;
renderIpAnomalies(aggregates);
}
});
}
function exportIpList() {
const { aggregates: agg } = getFilteredData();
const list = agg.ipAnomalyList || [];
if (!list.length) return;
const text = list.map(a => a.ip).join('\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'ip-anomalies.txt';
a.click();
URL.revokeObjectURL(url);
}
function renderCampaignQuality(aggregates) {
const container = document.getElementById('campaign-quality-content');
const list = aggregates.campaignQualityList || [];
if (!list.length) {
container.innerHTML = '<div class="empty-hint">Keine auff\u00e4lligen Kampagnen-Muster gefunden.</div>';
return;
}
let filtered = list;
if (campaignQualitySearch) {
filtered = list.filter(s => matchSearch(s.subnet, campaignQualitySearch) || s.signals.some(sig => matchSearch(sig, campaignQualitySearch)));
}
const sorted = sortData(filtered.slice(), 'campaignQuality');
const page = tablePages.campaignQuality || 1;
const start = (page - 1) * PAGE_SIZE;
const pageItems = sorted.slice(start, start + PAGE_SIZE);
let html = resultCountHtml(filtered.length, list.length, 'Subnetze');
html += '<div style="margin-bottom:10px"><button class="secondary small" onclick="exportCampaignQuality()">Nachweis exportieren</button></div>';
html += '<table><thead><tr>';
html += '<th onclick="handleTableSort(\'campaignQuality\',\'subnet\')" style="cursor:pointer">Subnetz ' + sortIndicator('campaignQuality','subnet') + '</th>';
html += '<th onclick="handleTableSort(\'campaignQuality\',\'ipCount\')" style="cursor:pointer;text-align:right">IPs ' + sortIndicator('campaignQuality','ipCount') + '</th>';
html += '<th onclick="handleTableSort(\'campaignQuality\',\'totalClickIdHits\')" style="cursor:pointer;text-align:right">Click-ID-Hits ' + sortIndicator('campaignQuality','totalClickIdHits') + '</th>';
html += '<th onclick="handleTableSort(\'campaignQuality\',\'totalClickIdDays\')" style="cursor:pointer;text-align:right">Click-ID-Tage ' + sortIndicator('campaignQuality','totalClickIdDays') + '</th>';
html += '<th>Signale</th>';
html += '</tr></thead><tbody>';
for (let i = 0; i < pageItems.length; i++) {
const s = pageItems[i];
const rowId = 'cq-detail-' + i;
const signalsHtml = s.signals.map(function(sig) {
return '<span class="flag-tag flag-' + sig.toLowerCase() + '">' + sig + '</span>';
}).join('');
html += '<tr style="cursor:pointer" onclick="document.getElementById(\'' + rowId + '\').classList.toggle(\'collapsed\')">';
html += '<td><span class="collapse-arrow" style="font-size:0.7rem;margin-right:4px">\u25B6</span>' + escapeHtml(s.subnet) + '.*</td>';
html += '<td style="text-align:right">' + s.ips.length + '</td>';
html += '<td style="text-align:right">' + s.totalClickIdHits.toLocaleString('de-DE') + '</td>';
html += '<td style="text-align:right">' + s.totalClickIdDays + '</td>';
html += '<td>' + signalsHtml + '</td>';
html += '</tr>';
const hasBot = s.ips.some(function(ip) { return ip.isBot; });
html += '<tr id="' + rowId + '" class="collapsed"><td colspan="5" style="padding:0">';
html += '<table style="margin:4px 0 8px 20px;font-size:0.85rem;width:calc(100% - 20px)"><thead><tr>';
html += '<th>IP</th><th style="text-align:right">Hits</th><th style="text-align:right">Click-ID-Hits</th>';
html += '<th style="text-align:right">Click-ID-Tage</th><th>Click-IDs</th><th>Kampagnen</th>';
if (hasBot) html += '<th>Bot</th>';
html += '</tr></thead><tbody>';
for (const ip of s.ips) {
const cidLabels = ip.clickIds.map(function(k) { return CLICK_ID_LABELS[k] || k; }).join(', ');
const campLabels = ip.campaigns.map(function(c) {
return c.split('|').filter(function(p) { return p !== '(leer)'; }).join(' / ');
}).join('; ');
html += '<tr>';
html += '<td>' + escapeHtml(ip.ip) + '</td>';
html += '<td style="text-align:right">' + ip.hits.toLocaleString('de-DE') + '</td>';
html += '<td style="text-align:right">' + ip.clickIdHits.toLocaleString('de-DE') + '</td>';
html += '<td style="text-align:right">' + ip.clickIdDays + '</td>';
html += '<td>' + escapeHtml(cidLabels) + '</td>';
html += '<td>' + escapeHtml(campLabels) + '</td>';
if (hasBot) html += '<td>' + (ip.isBot ? (ip.botNames && ip.botNames.length ? escapeHtml(ip.botNames.join(', ')) : 'Ja') : '') + '</td>';
html += '</tr>';
}
html += '</tbody></table></td></tr>';
}
html += '</tbody></table>';
html += '<div id="campaign-quality-pagination" class="table-footer"></div>';
container.innerHTML = html;
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
renderTableFooter('campaign-quality-pagination', {
page, totalPages,
onPageChange: function(dir) {
tablePages.campaignQuality = dir === 'next' ? page + 1 : page - 1;
renderCampaignQuality(aggregates);
}
});
}
function exportCampaignQuality() {
const { aggregates: agg } = getFilteredData();
const list = agg.campaignQualityList || [];
if (!list.length) return;
const dateRange = agg.dateRange || {};
const exportData = {
zeitraum: { von: dateRange.min || '', bis: dateRange.max || '' },
subnetze: list.map(function(s) {
return {
subnet: s.subnet,
signale: s.signals,
totalClickIdHits: s.totalClickIdHits,
totalClickIdDays: s.totalClickIdDays,
ips: s.ips.map(function(ip) {
return {
ip: ip.ip, hits: ip.hits,
clickIdHits: ip.clickIdHits, clickIdDays: ip.clickIdDays,
clickIds: ip.clickIds, campaigns: ip.campaigns, isBot: ip.isBot
};
})
};
})
};
const json = JSON.stringify(exportData, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'kampagnenqualitaet.json';
a.click();
URL.revokeObjectURL(url);
}
function hintIcon(text) {
return `<span class="hint-icon">?<span class="hint-text">${text}</span></span>`;
}
function escapeHtml(str) {
if (!str && str !== 0) return '';
if (typeof str !== 'string') str = String(str);
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const val = bytes / Math.pow(1024, i);
return val.toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
}
function formatResponseTime(microseconds) {
if (microseconds === null || microseconds === undefined) return '\u2014';
const ms = microseconds / 1000;
if (ms < 1) return '<1 ms';
if (ms < 1000) return ms.toFixed(0) + ' ms';
return (ms / 1000).toFixed(2) + ' s';
}
function formatDuration(ms) {
if (ms < 1000) return '0s';
var totalSec = Math.floor(ms / 1000);
var h = Math.floor(totalSec / 3600);
var m = Math.floor((totalSec % 3600) / 60);
var s = totalSec % 60;
if (h > 0) return h + 'h ' + m + 'm';
if (m > 0) return m + 'm ' + s + 's';
return s + 's';
}
function renderCleanup() {
const container = document.getElementById('cleanup-patterns-table');
const toggle = document.getElementById('cleanup-active-toggle');
if (toggle) toggle.checked = AppState.cleanupActive;
const patterns = AppState.patterns.filter(p => p.type === 'cleanup');
if (patterns.length === 0) {
container.innerHTML = '<div class="empty-hint">Keine Bereinigungsmuster definiert.</div>';
return;
}
const modeLabels = { contains: 'Enthält', startsWith: 'Beginnt mit', regex: 'Regex' };
let html = '<div class="table-container"><table><thead><tr>';
html += '<th>Muster</th><th>Modus</th><th>Aktiv</th><th>Aktionen</th>';
html += '</tr></thead><tbody>';
for (const p of patterns) {
html += `<tr>
<td><code>${escapeHtml(p.pattern)}</code></td>
<td>${modeLabels[p.matchMode] || p.matchMode}</td>
<td><input type="checkbox" ${p.active ? 'checked' : ''} onchange="togglePatternActive('${p.id}')"></td>
<td><button class="secondary small" onclick="removePattern('${p.id}')">Löschen</button></td>
</tr>`;
}
html += '</tbody></table></div>';
const activePatterns = patterns.filter(p => p.active);
if (activePatterns.length > 0 && AppState.rawEntries.length > 0) {
const matchCount = AppState.rawEntries.filter(e =>
activePatterns.some(cp => matchPattern(e.path, cp.pattern, cp.matchMode))
).length;
html += `<div class="cleanup-preview">${matchCount.toLocaleString('de-DE')} von ${AppState.rawEntries.length.toLocaleString('de-DE')} Einträgen würden gefiltert.</div>`;
}
container.innerHTML = html;
}
function addCleanupPattern() {
const input = document.getElementById('cleanup-pattern-input');
const modeSelect = document.getElementById('cleanup-mode-select');
const pattern = input.value.trim();
if (!pattern) return;
AppState.patterns.push({
id: crypto.randomUUID(),
pattern: pattern,
matchMode: modeSelect.value,
type: 'cleanup',
active: true
});
input.value = '';
invalidateFilterCache();
Storage.saveAll(AppState);
renderCurrentView();
}
function togglePatternActive(id) {
const p = AppState.patterns.find(p => p.id === id);
if (p) p.active = !p.active;
invalidateFilterCache();
Storage.saveAll(AppState);
renderCurrentView();
}
function removePattern(id) {
AppState.patterns = AppState.patterns.filter(p => p.id !== id);
invalidateFilterCache();
Storage.saveAll(AppState);
renderCurrentView();
}
function toggleCleanupActive() {
AppState.cleanupActive = !AppState.cleanupActive;
invalidateFilterCache();
Storage.saveAll(AppState);
renderCurrentView();
}
function purgeDatabase() {
const activePatterns = AppState.patterns.filter(p => p.type === 'cleanup' && p.active);
if (activePatterns.length === 0) {
showToast('Keine aktiven Bereinigungsmuster vorhanden.');
return;
}
const matchCount = AppState.rawEntries.filter(e =>
activePatterns.some(cp => matchPattern(e.path, cp.pattern, cp.matchMode))
).length;
if (matchCount === 0) {
showToast('Keine Treffer gefunden.');
return;
}
if (!confirm(matchCount.toLocaleString('de-DE') + ' Einträge werden unwiderruflich gelöscht. Fortfahren?')) return;
AppState.rawEntries = AppState.rawEntries.filter(e =>
!activePatterns.some(cp => matchPattern(e.path, cp.pattern, cp.matchMode))
);
AppState.sessions = buildSessions(AppState.rawEntries);
AppState.aggregates = buildAggregates(AppState.rawEntries, AppState.sessions);
invalidateFilterCache();
Storage.saveAll(AppState);
updateDataStatus();
renderCurrentView();
showToast(matchCount.toLocaleString('de-DE') + ' Einträge entfernt.');
}
function renderHeuristicRules() {
var container = document.getElementById('heuristic-rules-container');
if (!container) return;
var countEl = document.getElementById('heuristic-ua-pattern-count');
if (countEl) countEl.textContent = BOT_DEFINITIONS.bots.length;
if (!AppState.heuristicRules) {
AppState.heuristicRules = JSON.parse(JSON.stringify(HEURISTIC_DEFAULTS));
}
var rules = AppState.heuristicRules;
var html = '<div class="table-container"><table><thead><tr>';
html += '<th>Heuristik</th><th>Einstellung</th><th>Aktiv</th>';
html += '</tr></thead><tbody>';
html += '<tr><td><strong>' + escapeHtml(HEURISTIC_DEFAULTS['unknown-browser'].label) + '</strong>'
+ '<div style="font-size:12px;color:var(--text-muted)">Besucher mit nicht erkanntem Browser</div></td>';
html += '<td>\u2014</td>';
html += '<td><input type="checkbox" ' + (rules['unknown-browser'].active ? 'checked' : '')
+ ' onchange="toggleHeuristicRule(\'unknown-browser\')"></td></tr>';
html += '<tr><td><strong>' + escapeHtml(HEURISTIC_DEFAULTS['hammering'].label) + '</strong>'
+ '<div style="font-size:12px;color:var(--text-muted)">Ein Pfad wird &gt;N\u00d7 pro Tag vom selben Besucher abgerufen</div></td>';
html += '<td><label>Schwellwert: <input type="number" id="heuristic-hammering-threshold" '
+ 'value="' + (rules['hammering'].threshold || 20)
+ '" min="3" max="1000" style="width:60px" '
+ 'onchange="updateHeuristicParam(\'hammering\',\'threshold\',parseInt(this.value))"> Hits/Pfad/Tag</label></td>';
html += '<td><input type="checkbox" ' + (rules['hammering'].active ? 'checked' : '')
+ ' onchange="toggleHeuristicRule(\'hammering\')"></td></tr>';
html += '<tr><td><strong>' + escapeHtml(HEURISTIC_DEFAULTS['speed-bot'].label) + '</strong>'
+ '<div style="font-size:12px;color:var(--text-muted)">Session mit ungewöhnlich vielen Seitenaufrufen in kurzer Zeit</div></td>';
html += '<td><label>&gt; <input type="number" id="heuristic-speed-pv" '
+ 'value="' + (rules['speed-bot'].minPageviews || 5)
+ '" min="2" max="100" style="width:50px" '
+ 'onchange="updateHeuristicParam(\'speed-bot\',\'minPageviews\',parseInt(this.value))">'
+ ' PV in &lt; <input type="number" id="heuristic-speed-sec" '
+ 'value="' + (rules['speed-bot'].maxSeconds || 10)
+ '" min="1" max="300" style="width:50px" '
+ 'onchange="updateHeuristicParam(\'speed-bot\',\'maxSeconds\',parseInt(this.value))">'
+ ' Sek.</label></td>';
html += '<td><input type="checkbox" ' + (rules['speed-bot'].active ? 'checked' : '')
+ ' onchange="toggleHeuristicRule(\'speed-bot\')"></td></tr>';
html += '<tr><td><strong>' + escapeHtml(HEURISTIC_DEFAULTS['safari-prefetch'].label) + '</strong>'
+ '<div style="font-size:12px;color:var(--text-muted)">Safari iOS mit Direct-Channel und Bounce (1 Seitenaufruf)</div></td>';
html += '<td>\u2014</td>';
html += '<td><input type="checkbox" ' + (rules['safari-prefetch'].active ? 'checked' : '')
+ ' onchange="toggleHeuristicRule(\'safari-prefetch\')"></td></tr>';
var honeypotPaths = (rules['honeypot'].paths || []).join(', ');
html += '<tr><td><strong>' + escapeHtml(HEURISTIC_DEFAULTS['honeypot'].label) + '</strong>'
+ '<div style="font-size:12px;color:var(--text-muted)">Besuch eines Honeypot-Pfads disqualifiziert den gesamten Besucher</div></td>';
html += '<td><input type="text" id="heuristic-honeypot-paths" '
+ 'value="' + escapeHtml(honeypotPaths) + '" '
+ 'placeholder="/spiderfalle, /trap.html" style="width:100%" '
+ 'oninput="updateHoneypotPaths(this.value)"></td>';
html += '<td><input type="checkbox" ' + (rules['honeypot'].active ? 'checked' : '')
+ ' onchange="toggleHeuristicRule(\'honeypot\')"></td></tr>';
html += '</tbody></table></div>';
if (AppState.heuristicBotVisitors && AppState.heuristicBotVisitors.size > 0) {
var counts = {};
AppState.heuristicBotVisitors.forEach(function(ruleId) {
counts[ruleId] = (counts[ruleId] || 0) + 1;
});
html += '<div class="cleanup-preview">';
html += AppState.heuristicBotVisitors.size.toLocaleString('de-DE') + ' Besucher als heuristische Bots erkannt: ';
var parts = [];
for (var key in counts) {
parts.push(HEURISTIC_DEFAULTS[key].label + ' (' + counts[key].toLocaleString('de-DE') + ')');
}
html += parts.join(', ');
html += '</div>';
}
container.innerHTML = html;
var hasActive = Object.keys(rules).some(function(k) { return rules[k].active; });
var actionsEl = document.getElementById('heuristic-actions');
if (actionsEl) actionsEl.style.display = hasActive ? '' : 'none';
}
function toggleHeuristicRule(ruleId) {
if (!AppState.heuristicRules) AppState.heuristicRules = JSON.parse(JSON.stringify(HEURISTIC_DEFAULTS));
AppState.heuristicRules[ruleId].active = !AppState.heuristicRules[ruleId].active;
Storage.saveAll(AppState);
renderHeuristicRules();
}
function updateHeuristicParam(ruleId, param, value) {
if (!AppState.heuristicRules) return;
AppState.heuristicRules[ruleId][param] = value;
Storage.saveAll(AppState);
}
function updateHoneypotPaths(value) {
if (!AppState.heuristicRules) return;
AppState.heuristicRules['honeypot'].paths = value.split(',')
.map(function(p) { return p.trim(); })
.filter(function(p) { return p.length > 0; });
Storage.saveAll(AppState);
}
function syncHeuristicInputs() {
if (!AppState.heuristicRules) return;
var el;
el = document.getElementById('heuristic-honeypot-paths');
if (el) updateHoneypotPaths(el.value);
el = document.getElementById('heuristic-hammering-threshold');
if (el) AppState.heuristicRules['hammering'].threshold = parseInt(el.value) || 20;
el = document.getElementById('heuristic-speed-pv');
if (el) AppState.heuristicRules['speed-bot'].minPageviews = parseInt(el.value) || 5;
el = document.getElementById('heuristic-speed-sec');
if (el) AppState.heuristicRules['speed-bot'].maxSeconds = parseInt(el.value) || 10;
}
function runHeuristics() {
syncHeuristicInputs();
if (!AppState.rawEntries || AppState.rawEntries.length === 0) {
showToast('Keine Daten geladen.');
return;
}
showProgress('Heuristische Bot-Erkennung\u2026');
requestAnimationFrame(function() {
AppState.heuristicBotVisitors = runHeuristicAnalysis(AppState.rawEntries, AppState.heuristicRules);
invalidateFilterCache();
Storage.saveAll(AppState);
hideProgress();
renderHeuristicRules();
showToast(AppState.heuristicBotVisitors.size.toLocaleString('de-DE') + ' Besucher als heuristische Bots erkannt.');
});
}
function resetHeuristics() {
AppState.heuristicBotVisitors = new Map();
invalidateFilterCache();
Storage.saveAll(AppState);
renderHeuristicRules();
showToast('Heuristische Bot-Erkennung zurückgesetzt.');
}
function renderConversions() {
const container = document.getElementById('conversion-goals-container');
const modelSelect = document.getElementById('attribution-model-select');
if (modelSelect) modelSelect.value = AppState.attributionModel || 'last';
let html = '';
for (let i = 1; i <= 3; i++) {
const pattern = AppState.patterns.find(p => p.type === 'conversion' && p.goalIndex === i);
html += renderGoalSlot(i, pattern);
}
container.innerHTML = html;
}
function renderGoalSlot(index, pattern) {
const modeOpts = [
{ value: 'contains', label: 'Enthält' },
{ value: 'startsWith', label: 'Beginnt mit' },
{ value: 'regex', label: 'Regex' }
];
let html = '<div class="goal-slot">';
html += '<div class="goal-slot-header"><h4>Ziel ' + index + (pattern && pattern.goalName ? ': ' + escapeHtml(pattern.goalName) : '') + '</h4>';
if (pattern) {
html += '<button class="secondary small" onclick="removeConversionGoal(' + index + ')">Entfernen</button>';
}
html += '</div>';
const name = pattern ? escapeHtml(pattern.goalName || '') : '';
const pat = pattern ? escapeHtml(pattern.pattern) : '';
const mode = pattern ? pattern.matchMode : 'contains';
const countMode = pattern ? pattern.countMode : 'all';
html += '<div class="goal-form">';
html += '<div class="form-field"><label>Name</label><input type="text" id="goal-name-' + index + '" value="' + name + '" placeholder="z.B. Kauf"></div>';
html += '<div class="form-field"><label>Muster</label><input type="text" id="goal-pattern-' + index + '" value="' + pat + '" placeholder="Pfad-Muster"></div>';
html += '<div class="form-field"><label>Modus</label><select id="goal-mode-' + index + '">';
for (const opt of modeOpts) {
html += '<option value="' + opt.value + '"' + (mode === opt.value ? ' selected' : '') + '>' + opt.label + '</option>';
}
html += '</select></div>';
html += '</div>';
html += '<div class="goal-count-mode">';
html += '<label><input type="radio" name="goal-count-' + index + '" value="all"' + (countMode === 'all' ? ' checked' : '') + '> Alle Aufrufe</label>';
html += '<label><input type="radio" name="goal-count-' + index + '" value="unique"' + (countMode === 'unique' ? ' checked' : '') + '> Unique pro User</label>';
html += '</div>';
html += '<button class="primary small" style="margin-top:0.5rem" onclick="saveConversionGoal(' + index + ')">Speichern</button>';
if (pattern) {
const data = getFilteredData();
if (data.aggregates && data.aggregates.conversionData && data.aggregates.conversionData[index]) {
const total = data.aggregates.conversionData[index].total;
html += '<div class="goal-preview">' + total.toLocaleString('de-DE') + ' Conversions gefunden.</div>';
} else {
html += '<div class="goal-preview">0 Conversions gefunden.</div>';
}
}
html += '</div>';
return html;
}
function saveConversionGoal(index) {
const name = document.getElementById('goal-name-' + index).value.trim();
const pattern = document.getElementById('goal-pattern-' + index).value.trim();
const mode = document.getElementById('goal-mode-' + index).value;
const countRadio = document.querySelector('input[name="goal-count-' + index + '"]:checked');
const countMode = countRadio ? countRadio.value : 'all';
if (!pattern) { showToast('Bitte ein Muster eingeben.'); return; }
AppState.patterns = AppState.patterns.filter(p => !(p.type === 'conversion' && p.goalIndex === index));
AppState.patterns.push({
id: crypto.randomUUID(),
pattern: pattern,
matchMode: mode,
type: 'conversion',
goalIndex: index,
goalName: name || 'Ziel ' + index,
countMode: countMode
});
invalidateFilterCache();
Storage.saveAll(AppState);
refreshConversionsView();
}
function removeConversionGoal(index) {
AppState.patterns = AppState.patterns.filter(p => !(p.type === 'conversion' && p.goalIndex === index));
invalidateFilterCache();
Storage.saveAll(AppState);
refreshConversionsView();
}
function handleAttributionModelChange() {
const select = document.getElementById('attribution-model-select');
AppState.attributionModel = select.value;
invalidateFilterCache();
Storage.saveAll(AppState);
refreshConversionsView();
}
function refreshConversionsView() {
showProgress('Conversions werden berechnet\u2026');
setTimeout(function() {
renderConversions();
hideProgress();
}, 0);
}
function formatBotNameList(names, limit) {
limit = limit || 3;
if (names.length <= limit) return names.map(function(n) { return escapeHtml(n); }).join(', ');
var visible = names.slice(0, limit).map(function(n) { return escapeHtml(n); }).join(', ');
var hidden = names.slice(limit).map(function(n) { return escapeHtml(n); }).join(', ');
var id = 'bn-' + Math.random().toString(36).substr(2, 6);
return visible
+ '<span id="' + id + '-rest" style="display:none">, ' + hidden + '</span> '
+ '<a id="' + id + '-toggle" href="#" onclick="var r=document.getElementById(\'' + id + '-rest\');'
+ 'r.style.display=\'inline\';this.style.display=\'none\';return false;" '
+ 'style="color:var(--accent);white-space:nowrap">+' + (names.length - limit) + ' mehr</a>';
}
function renderBandwidthBar(segments, total) {
var barHtml = '<div class="status-bar-track">';
var legendHtml = '<div class="status-bar-legend">';
for (var s = 0; s < segments.length; s++) {
var seg = segments[s];
if (seg.value <= 0) continue;
var pct = (seg.value / total * 100).toFixed(1);
barHtml += '<div class="status-bar-segment" style="width:' + pct + '%;background:' + seg.color
+ '" title="' + escapeHtml(seg.label) + ': ' + formatBytes(seg.value) + ' (' + pct + '%)">'
+ (parseFloat(pct) > 5 ? pct + '%' : '') + '</div>';
legendHtml += '<div class="status-bar-legend-item"><span class="status-dot" style="background:' + seg.color + '"></span>'
+ escapeHtml(seg.label) + ': ' + formatBytes(seg.value) + ' (' + pct + '%)</div>';
}
barHtml += '</div>';
legendHtml += '</div>';
return barHtml + legendHtml;
}
function renderSessionAnalyse(sessions) {
var container = document.getElementById("session-analyse-content");
var data;
{
if (!sessions || !sessions.length) {
container.innerHTML = "<div class='empty-hint'>Keine Daten vorhanden.</div>";
return;
}
var filtered = filterSessionsBySource(sessions, sessionAnalyseSourceFilter);
data = buildSessionAnalysis(filtered);
}
if (data.kpis.totalSessions === 0) {
container.innerHTML = "<div class='empty-hint'>Keine Sessions mit Seiten-Hits gefunden.</div>";
return;
}
var html = '<div class="journey-filters">';
html += renderSourceDropdown('session-analyse-source', sessionAnalyseSourceFilter, sessions);
html += '</div>';
html += '<div class="stats-grid">';
html += '<div class="metric-card"><h4>Sessions</h4><div class="value">'
+ data.kpis.totalSessions.toLocaleString('de-DE') + '</div></div>';
html += '<div class="metric-card"><h4>Bounce-Rate</h4><div class="value">'
+ data.kpis.bounceRate.toLocaleString('de-DE') + '%</div></div>';
html += '<div class="metric-card"><h4>Ø Seiten/Session</h4><div class="value">'
+ data.kpis.avgPagesPerSession.toLocaleString('de-DE') + '</div></div>';
html += '<div class="metric-card"><h4>Median Pfadlänge</h4><div class="value">'
+ data.kpis.medianPathLength.toLocaleString('de-DE') + '</div></div>';
html += '<div class="metric-card"><h4>Median Dauer</h4><div class="value">'
+ formatDuration(data.kpis.medianDuration) + '</div></div>';
html += '</div>';
var maxCount = Math.max.apply(null, data.durationHistogram.map(function(b) { return b.count; }));
if (maxCount > 0) {
html += '<h3>Session-Dauer-Verteilung</h3>';
html += '<div class="histogram">';
for (var h_i = 0; h_i < data.durationHistogram.length; h_i++) {
var bucket = data.durationHistogram[h_i];
var pct = (bucket.count / maxCount * 100).toFixed(1);
html += '<div class="histogram-row">'
+ '<span class="histogram-label">' + escapeHtml(bucket.label) + '</span>'
+ '<div class="histogram-bar-container">'
+ '<div class="histogram-bar" style="width:' + pct + '%"></div>'
+ '</div>'
+ '<span class="histogram-value">' + bucket.count.toLocaleString('de-DE') + '</span>'
+ '</div>';
}
html += '</div>';
}
html += '<h3>Häufigste Journeys</h3>';
html += '<div class="journey-filters">'
+ '<label>Min. Schritte <input type="number" id="journey-min-steps" value="' + journeyMinSteps + '" min="1" max="50" style="width:4rem"></label>'
+ '<label>Min. Sessions <input type="number" id="journey-min-sessions" value="' + journeyMinSessions + '" min="1" style="width:4rem"></label>'
+ '</div>';
var journeys = data.journeys;
if (journeyMinSteps > 1) {
journeys = journeys.filter(function(j) { return j.journey.length >= journeyMinSteps; });
}
if (journeyMinSessions > 1) {
journeys = journeys.filter(function(j) { return j.count >= journeyMinSessions; });
}
if (sessionAnalyseSearch) {
journeys = journeys.filter(function(j) {
return j.journey.some(function(step) { return matchSearch(step, sessionAnalyseSearch); });
});
}
for (var jp = 0; jp < journeys.length; jp++) {
journeys[jp].journeyStr = journeys[jp].journey.join(' → ');
journeys[jp].steps = journeys[jp].journey.length;
}
journeys = sortData(journeys.slice(), 'sessionAnalyse');
if (journeys.length) {
var totalJourneyPages = Math.ceil(journeys.length / PAGE_SIZE);
if (tablePages.sessionAnalyse > totalJourneyPages) tablePages.sessionAnalyse = totalJourneyPages;
var jStart = (tablePages.sessionAnalyse - 1) * PAGE_SIZE;
var jSlice = journeys.slice(jStart, jStart + PAGE_SIZE);
html += resultCountHtml(journeys.length, data.journeys.length, 'Journeys');
html += '<table><thead><tr>';
html += '<th onclick="handleTableSort(\'sessionAnalyse\',\'journeyStr\')">Journey' + sortIndicator('sessionAnalyse','journeyStr') + '</th>';
html += '<th onclick="handleTableSort(\'sessionAnalyse\',\'steps\')">Schritte' + sortIndicator('sessionAnalyse','steps') + '</th>';
html += '<th onclick="handleTableSort(\'sessionAnalyse\',\'count\')">Sessions' + sortIndicator('sessionAnalyse','count') + '</th>';
html += '<th onclick="handleTableSort(\'sessionAnalyse\',\'avgDuration\')">Ø Dauer' + sortIndicator('sessionAnalyse','avgDuration') + '</th>';
html += '</tr></thead><tbody>';
for (var ji = 0; ji < jSlice.length; ji++) {
var j = jSlice[ji];
var steps = j.journey.length;
var maxDisplay = 10;
var journeyDisplay;
if (steps <= maxDisplay) {
journeyDisplay = j.journey.map(escapeHtml).join(' → ');
} else {
journeyDisplay = j.journey.slice(0, maxDisplay).map(escapeHtml).join(' → ')
+ ' …+' + (steps - maxDisplay);
}
html += '<tr>'
+ '<td class="journey-cell" title="' + escapeHtml(j.journey.join(' → ')) + '">' + journeyDisplay + '</td>'
+ '<td>' + steps + '</td>'
+ '<td>' + j.count.toLocaleString('de-DE') + '</td>'
+ '<td>' + formatDuration(j.avgDuration) + '</td>'
+ '</tr>';
}
html += '</tbody></table>';
html += '<div id="session-analyse-pagination" class="pagination"></div>';
} else {
html += '<div class="empty-hint">Keine Journeys gefunden.</div>';
}
html += '<h3>Bounce-Rate pro Einstiegsseite</h3>';
var bounceData = data.bounceByEntry;
if (sessionAnalyseSearch) {
bounceData = bounceData.filter(function(b) { return matchSearch(b.path, sessionAnalyseSearch); });
}
bounceData = sortData(bounceData.slice(), 'bounceByEntry');
if (bounceData.length) {
var totalBouncePages = Math.ceil(bounceData.length / PAGE_SIZE);
if (tablePages.bounceByEntry > totalBouncePages) tablePages.bounceByEntry = totalBouncePages;
var bStart = (tablePages.bounceByEntry - 1) * PAGE_SIZE;
var bSlice = bounceData.slice(bStart, bStart + PAGE_SIZE);
html += resultCountHtml(bounceData.length, data.bounceByEntry.length, 'Seiten');
html += '<table><thead><tr>';
html += '<th onclick="handleTableSort(\'bounceByEntry\',\'path\')">Seite' + sortIndicator('bounceByEntry','path') + '</th>';
html += '<th onclick="handleTableSort(\'bounceByEntry\',\'sessions\')">Sessions' + sortIndicator('bounceByEntry','sessions') + '</th>';
html += '<th onclick="handleTableSort(\'bounceByEntry\',\'bounces\')">Bounces' + sortIndicator('bounceByEntry','bounces') + '</th>';
html += '<th onclick="handleTableSort(\'bounceByEntry\',\'bounceRate\')">Bounce-Rate' + sortIndicator('bounceByEntry','bounceRate') + '</th>';
html += '</tr></thead><tbody>';
for (var bi = 0; bi < bSlice.length; bi++) {
var b = bSlice[bi];
html += '<tr>'
+ '<td title="' + escapeHtml(b.path) + '">' + escapeHtml(b.path) + '</td>'
+ '<td>' + b.sessions.toLocaleString('de-DE') + '</td>'
+ '<td>' + b.bounces.toLocaleString('de-DE') + '</td>'
+ '<td>' + b.bounceRate.toLocaleString('de-DE') + '%</td>'
+ '</tr>';
}
html += '</tbody></table>';
html += '<div id="bounce-by-entry-pagination" class="pagination"></div>';
} else {
html += '<div class="empty-hint">Keine Einstiegsseiten gefunden.</div>';
}
container.innerHTML = html;
var srcEl = document.getElementById('session-analyse-source');
if (srcEl) {
srcEl.onchange = function() {
sessionAnalyseSourceFilter = this.value;
tablePages.sessionAnalyse = 1;
tablePages.bounceByEntry = 1;
renderSessionAnalyse(sessions);
};
}
var minStepsEl = document.getElementById('journey-min-steps');
var minSessionsEl = document.getElementById('journey-min-sessions');
if (minStepsEl) {
minStepsEl.onchange = function() {
journeyMinSteps = Math.max(1, parseInt(this.value) || 1);
tablePages.sessionAnalyse = 1;
renderSessionAnalyse(sessions);
};
}
if (minSessionsEl) {
minSessionsEl.onchange = function() {
journeyMinSessions = Math.max(1, parseInt(this.value) || 1);
tablePages.sessionAnalyse = 1;
renderSessionAnalyse(sessions);
};
}
if (journeys.length) {
var tjp = Math.ceil(journeys.length / PAGE_SIZE);
registerTableExport('sessionAnalyse', 'Journeys', ['Journey', 'Schritte', 'Sessions', 'Avg Dauer'],
function() { return journeys.map(function(j) { return [j.journey.join(' \u2192 '), j.journey.length, j.count, formatDuration(j.avgDuration)]; }); });
renderTableFooter('session-analyse-pagination', {
page: tablePages.sessionAnalyse, totalPages: tjp, exportId: 'sessionAnalyse',
onPageChange: function(dir) {
if (dir === 'prev' && tablePages.sessionAnalyse > 1) tablePages.sessionAnalyse--;
if (dir === 'next' && tablePages.sessionAnalyse < tjp) tablePages.sessionAnalyse++;
renderSessionAnalyse(sessions);
}
});
}
if (bounceData.length) {
var tbp = Math.ceil(bounceData.length / PAGE_SIZE);
registerTableExport('bounceByEntry', 'Bounce-Rate', ['Seite', 'Sessions', 'Bounces', 'Bounce-Rate'],
function() { return bounceData.map(function(b) { return [b.path, b.sessions, b.bounces, b.bounceRate + '%']; }); });
renderTableFooter('bounce-by-entry-pagination', {
page: tablePages.bounceByEntry, totalPages: tbp, exportId: 'bounceByEntry',
onPageChange: function(dir) {
if (dir === 'prev' && tablePages.bounceByEntry > 1) tablePages.bounceByEntry--;
if (dir === 'next' && tablePages.bounceByEntry < tbp) tablePages.bounceByEntry++;
renderSessionAnalyse(sessions);
}
});
}
}
function renderConversionPaths(sessions) {
var container = document.getElementById("conversion-paths-content");
var goals = (AppState.patterns || []).filter(function(p) { return p.type === 'conversion'; });
if (!goals.length) {
container.innerHTML = "<div class='empty-hint'>Keine aktiven Conversion-Ziele definiert.</div>";
return;
}
var activeGoals = goals;
if (convPathGoalFilter) {
activeGoals = goals.filter(function(g) { return g.goalIndex === parseInt(convPathGoalFilter); });
}
var data;
{
if (!sessions || !sessions.length) {
container.innerHTML = "<div class='empty-hint'>Keine Daten vorhanden.</div>";
return;
}
var filtered = filterSessionsBySource(sessions, convPathSourceFilter);
data = buildSessionAnalysis(filtered);
}
if (data.kpis.totalSessions === 0) {
container.innerHTML = "<div class='empty-hint'>Keine Sessions mit Seiten-Hits gefunden.</div>";
return;
}
function isConversionStep(step) {
return activeGoals.some(function(g) { return matchPattern(step, g.pattern, g.matchMode); });
}
var convJourneys = data.journeys.filter(function(j) {
return j.journey.some(isConversionStep);
});
var totalConvSessions = convJourneys.reduce(function(s, j) { return s + j.count; }, 0);
var convRate = data.kpis.totalSessions > 0 ? Math.round(totalConvSessions / data.kpis.totalSessions * 1000) / 10 : 0;
var convDurations = [];
{
for (var si = 0; si < filtered.length; si++) {
var deduped = deduplicateSessionPages(filtered[si]);
if (deduped.length === 0) continue;
if (!deduped.some(isConversionStep)) continue;
convDurations.push(filtered[si].duration || 0);
}
}
var bucketLimits = [1000, 10000, 30000, 60000, 300000, 900000, 1800000];
var bucketLabels = ['0s', '1\u201310s', '10\u201330s', '30s\u20131m', '1\u20135m', '5\u201315m', '15\u201330m', '30m+'];
var histogram = bucketLabels.map(function(label) { return { label: label, count: 0 }; });
for (var di = 0; di < convDurations.length; di++) {
var dur = convDurations[di];
var bucketIdx = bucketLimits.length;
for (var b = 0; b < bucketLimits.length; b++) {
if (dur < bucketLimits[b]) { bucketIdx = b; break; }
}
histogram[bucketIdx].count++;
}
var html = '';
html += '<div class="stats-grid">';
html += '<div class="metric-card"><h4>Conversion-Sessions</h4><div class="value">'
+ totalConvSessions.toLocaleString('de-DE') + '</div></div>';
html += '<div class="metric-card"><h4>Conversion-Rate</h4><div class="value">'
+ convRate.toLocaleString('de-DE') + '%</div></div>';
html += '<div class="metric-card"><h4>Unique Pfade</h4><div class="value">'
+ convJourneys.length.toLocaleString('de-DE') + '</div></div>';
var convPathLengths = [];
for (var ci = 0; ci < convJourneys.length; ci++) {
for (var cr = 0; cr < convJourneys[ci].count; cr++) {
convPathLengths.push(convJourneys[ci].journey.length);
}
}
function median(arr) {
if (arr.length === 0) return 0;
var sorted = arr.slice().sort(function(a, b) { return a - b; });
var mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
html += '<div class="metric-card"><h4>Median Pfadl\u00e4nge</h4><div class="value">'
+ median(convPathLengths).toLocaleString('de-DE') + '</div></div>';
html += '<div class="metric-card"><h4>Median Dauer</h4><div class="value">'
+ formatDuration(median(convDurations)) + '</div></div>';
html += '</div>';
var maxCount = Math.max.apply(null, histogram.map(function(b) { return b.count; }));
if (maxCount > 0) {
html += '<h3>Session-Dauer-Verteilung (Conversions)</h3>';
html += '<div class="histogram">';
for (var h_i = 0; h_i < histogram.length; h_i++) {
var bucket = histogram[h_i];
var pct = (bucket.count / maxCount * 100).toFixed(1);
html += '<div class="histogram-row">'
+ '<span class="histogram-label">' + escapeHtml(bucket.label) + '</span>'
+ '<div class="histogram-bar-container">'
+ '<div class="histogram-bar" style="width:' + pct + '%"></div>'
+ '</div>'
+ '<span class="histogram-value">' + bucket.count.toLocaleString('de-DE') + '</span>'
+ '</div>';
}
html += '</div>';
}
html += '<h3>H\u00e4ufigste Conversion-Pfade</h3>';
html += '<div class="journey-filters">';
html += '<label>Ziel <select id="conv-path-goal-filter" style="min-width:8rem">';
html += '<option value="">Alle Ziele</option>';
for (var gi = 0; gi < goals.length; gi++) {
var g = goals[gi];
var sel = convPathGoalFilter === String(g.goalIndex) ? ' selected' : '';
html += '<option value="' + g.goalIndex + '"' + sel + '>' + escapeHtml(g.goalName || 'Ziel ' + g.goalIndex) + '</option>';
}
html += '</select></label>';
html += renderSourceDropdown('conv-path-source', convPathSourceFilter, sessions);
html += '<label>Min. Schritte <input type="number" id="conv-path-min-steps" value="' + convPathMinSteps + '" min="1" max="50" style="width:4rem"></label>'
+ '<label>Min. Sessions <input type="number" id="conv-path-min-sessions" value="' + convPathMinSessions + '" min="1" style="width:4rem"></label>';
html += '</div>';
var journeys = convJourneys;
if (convPathMinSteps > 1) {
journeys = journeys.filter(function(j) { return j.journey.length >= convPathMinSteps; });
}
if (convPathMinSessions > 1) {
journeys = journeys.filter(function(j) { return j.count >= convPathMinSessions; });
}
if (conversionPathsSearch) {
journeys = journeys.filter(function(j) {
return j.journey.some(function(step) { return matchSearch(step, conversionPathsSearch); });
});
}
for (var jp = 0; jp < journeys.length; jp++) {
journeys[jp].journeyStr = journeys[jp].journey.join(' \u2192 ');
journeys[jp].steps = journeys[jp].journey.length;
}
journeys = sortData(journeys.slice(), 'conversionPaths');
if (journeys.length) {
var totalPages_j = Math.ceil(journeys.length / PAGE_SIZE);
if (tablePages.conversionPaths > totalPages_j) tablePages.conversionPaths = totalPages_j;
var jStart = (tablePages.conversionPaths - 1) * PAGE_SIZE;
var jSlice = journeys.slice(jStart, jStart + PAGE_SIZE);
html += resultCountHtml(journeys.length, convJourneys.length, 'Pfade');
html += '<table><thead><tr>';
html += '<th onclick="handleTableSort(\'conversionPaths\',\'journeyStr\')">Pfad' + sortIndicator('conversionPaths','journeyStr') + '</th>';
html += '<th onclick="handleTableSort(\'conversionPaths\',\'steps\')">Schritte' + sortIndicator('conversionPaths','steps') + '</th>';
html += '<th onclick="handleTableSort(\'conversionPaths\',\'count\')">Sessions' + sortIndicator('conversionPaths','count') + '</th>';
html += '<th onclick="handleTableSort(\'conversionPaths\',\'avgDuration\')">\u00d8 Dauer' + sortIndicator('conversionPaths','avgDuration') + '</th>';
html += '</tr></thead><tbody>';
for (var ji = 0; ji < jSlice.length; ji++) {
var j = jSlice[ji];
var steps = j.journey.length;
var maxDisplay = 10;
var parts = steps <= maxDisplay ? j.journey : j.journey.slice(0, maxDisplay);
var journeyDisplay = parts.map(function(step) {
if (isConversionStep(step)) {
return '<span class="conversion-step">' + escapeHtml(step) + '</span>';
}
return escapeHtml(step);
}).join(' \u2192 ');
if (steps > maxDisplay) journeyDisplay += ' \u2026+' + (steps - maxDisplay);
html += '<tr>'
+ '<td class="journey-cell" title="' + escapeHtml(j.journey.join(' \u2192 ')) + '">' + journeyDisplay + '</td>'
+ '<td>' + steps + '</td>'
+ '<td>' + j.count.toLocaleString('de-DE') + '</td>'
+ '<td>' + formatDuration(j.avgDuration) + '</td>'
+ '</tr>';
}
html += '</tbody></table>';
html += '<div id="conversion-paths-pagination" class="pagination"></div>';
} else {
html += '<div class="empty-hint">Keine Conversion-Pfade gefunden.</div>';
}
container.innerHTML = html;
var goalSelect = document.getElementById('conv-path-goal-filter');
if (goalSelect) {
goalSelect.onchange = function() {
convPathGoalFilter = this.value;
tablePages.conversionPaths = 1;
renderConversionPaths(sessions);
};
}
var cpSrcSelect = document.getElementById('conv-path-source');
if (cpSrcSelect) {
cpSrcSelect.onchange = function() {
convPathSourceFilter = this.value;
tablePages.conversionPaths = 1;
renderConversionPaths(sessions);
};
}
var minStepsEl = document.getElementById('conv-path-min-steps');
if (minStepsEl) {
minStepsEl.onchange = function() {
convPathMinSteps = Math.max(1, parseInt(this.value) || 1);
tablePages.conversionPaths = 1;
renderConversionPaths(sessions);
};
}
var minSessionsEl = document.getElementById('conv-path-min-sessions');
if (minSessionsEl) {
minSessionsEl.onchange = function() {
convPathMinSessions = Math.max(1, parseInt(this.value) || 1);
tablePages.conversionPaths = 1;
renderConversionPaths(sessions);
};
}
if (journeys.length) {
var tjp = Math.ceil(journeys.length / PAGE_SIZE);
registerTableExport('conversionPaths', 'Conversion-Pfade', ['Pfad', 'Schritte', 'Sessions', 'Avg Dauer'],
function() { return journeys.map(function(j) { return [j.journey.join(' \u2192 '), j.journey.length, j.count, formatDuration(j.avgDuration)]; }); });
renderTableFooter('conversion-paths-pagination', {
page: tablePages.conversionPaths, totalPages: tjp, exportId: 'conversionPaths',
onPageChange: function(dir) {
if (dir === 'prev' && tablePages.conversionPaths > 1) tablePages.conversionPaths--;
if (dir === 'next' && tablePages.conversionPaths < tjp) tablePages.conversionPaths++;
renderConversionPaths(sessions);
}
});
}
}
function renderCrawlBudget(aggregates, filteredEntries) {
var container = document.getElementById("crawl-budget-content");
{
if (!aggregates || !filteredEntries.length) {
container.innerHTML = "<div class='empty-hint'>Keine Daten vorhanden.</div>";
return;
}
var data = buildCrawlBudgetData(filteredEntries);
}
var totalHits, botHits;
{
totalHits = filteredEntries.length;
botHits = filteredEntries.filter(function(e) { return isEffectiveBot(e); }).length;
}
var botPct = totalHits > 0 ? (botHits / totalHits * 100).toFixed(1) : '0';
var html = '<div class="stats-grid">';
html += '<div class="metric-card"><h4>Bot-Hits</h4><div class="value">'
+ botHits.toLocaleString('de-DE') + ' <small>(' + botPct + '%)</small>'
+ '</div></div>';
html += '<div class="metric-card"><h4>Bot-Bandwidth</h4><div class="value">'
+ formatBytes(aggregates.totalBotBytes || 0)
+ '</div></div>';
html += '<div class="metric-card"><h4>Crawl-Waste</h4><div class="value">'
+ data.crawlWaste.total.toLocaleString('de-DE') + ' <small>(' + data.crawlWaste.rate + '%)</small>'
+ '</div></div>';
html += '<div class="metric-card"><h4>Nicht gecrawlt</h4><div class="value">'
+ data.notCrawled.length.toLocaleString('de-DE')
+ '</div></div>';
html += '</div>';
var totalBytes = (aggregates.totalBotBytes || 0) + (aggregates.totalBrowserBytes || 0)
+ (aggregates.totalAttackBytes || 0);
if (totalBytes > 0) {
html += '<h3>Bandwidth nach Quelle</h3>';
html += renderBandwidthBar([
{ label: 'Bot', value: aggregates.totalBotBytes || 0, color: '#5b8c85' },
{ label: 'Browser', value: aggregates.totalBrowserBytes || 0, color: '#4a90d9' },
{ label: 'Angriff', value: aggregates.totalAttackBytes || 0, color: '#d94a4a' }
], totalBytes);
}
if (aggregates.resourceTypes && aggregates.resourceTypes.length) {
var rtColors = {
'Seite': '#4a90d9', 'Bild': '#5b8c85', 'CSS': '#d9a54a',
'JavaScript': '#d9744a', 'Schrift': '#8c5bd9', 'Media': '#d94a8c', 'Andere': '#888'
};
var rtTotal = aggregates.resourceTypes.reduce(function(s, r) { return s + (r.bytes || 0); }, 0);
if (rtTotal > 0) {
html += '<h3>Bandwidth nach Ressourcentyp</h3>';
html += renderBandwidthBar(
aggregates.resourceTypes.map(function(r) {
return { label: r.type, value: r.bytes || 0, color: rtColors[r.type] || '#888' };
}),
rtTotal
);
}
}
html += '<h3>Crawl-Waste (Bot-Hits auf 4xx/5xx)</h3>';
var wastePaths = data.crawlWaste.paths;
if (crawlBudgetSearch) {
wastePaths = wastePaths.filter(function(p) {
return matchSearch(p.path, crawlBudgetSearch)
|| p.botNames.some(function(n) { return matchSearch(n, crawlBudgetSearch); });
});
}
wastePaths = sortData(wastePaths.slice(), 'crawlWaste');
if (wastePaths.length) {
var totalWastePages = Math.ceil(wastePaths.length / PAGE_SIZE);
if (tablePages.crawlWaste > totalWastePages) tablePages.crawlWaste = totalWastePages;
var wStart = (tablePages.crawlWaste - 1) * PAGE_SIZE;
var wSlice = wastePaths.slice(wStart, wStart + PAGE_SIZE);
html += resultCountHtml(wastePaths.length, data.crawlWaste.paths.length, 'Pfade');
html += '<table><thead><tr>';
html += '<th onclick="handleTableSort(\'crawlWaste\',\'path\')">Pfad' + sortIndicator('crawlWaste','path') + '</th>';
html += '<th onclick="handleTableSort(\'crawlWaste\',\'botHits\')">Bot-Hits' + sortIndicator('crawlWaste','botHits') + '</th>';
html += '<th>Status</th>';
html += '<th>Bot-Name(n)</th>';
html += '</tr></thead><tbody>';
for (var i = 0; i < wSlice.length; i++) {
var p = wSlice[i];
var statusStr = Object.entries(p.statusCounts).map(function(pair) { return pair[0] + ': ' + pair[1]; }).join(', ');
html += '<tr>'
+ '<td title="' + escapeHtml(p.path) + '">' + escapeHtml(p.path) + '</td>'
+ '<td>' + p.botHits.toLocaleString('de-DE') + '</td>'
+ '<td>' + statusStr + '</td>'
+ '<td>' + formatBotNameList(p.botNames) + '</td>'
+ '</tr>';
}
html += '</tbody></table>';
html += '<div id="crawl-waste-pagination" class="pagination"></div>';
} else {
html += '<div class="empty-hint">Kein Crawl-Waste gefunden.</div>';
}
html += '<h3>Nicht gecrawlt (Seiten ohne Bot-Hits)</h3>';
var ncPaths = data.notCrawled;
if (crawlBudgetSearch) {
ncPaths = ncPaths.filter(function(p) { return matchSearch(p.path, crawlBudgetSearch); });
}
ncPaths = sortData(ncPaths.slice(), 'notCrawled');
if (ncPaths.length) {
var totalNcPages = Math.ceil(ncPaths.length / PAGE_SIZE);
if (tablePages.notCrawled > totalNcPages) tablePages.notCrawled = totalNcPages;
var ncStart = (tablePages.notCrawled - 1) * PAGE_SIZE;
var ncSlice = ncPaths.slice(ncStart, ncStart + PAGE_SIZE);
html += resultCountHtml(ncPaths.length, data.notCrawled.length, 'Seiten');
html += '<table><thead><tr>';
html += '<th onclick="handleTableSort(\'notCrawled\',\'path\')">Pfad' + sortIndicator('notCrawled','path') + '</th>';
html += '<th onclick="handleTableSort(\'notCrawled\',\'userHits\')">User-Hits' + sortIndicator('notCrawled','userHits') + '</th>';
html += '<th onclick="handleTableSort(\'notCrawled\',\'bytes\')">Bytes' + sortIndicator('notCrawled','bytes') + '</th>';
html += '</tr></thead><tbody>';
for (var j = 0; j < ncSlice.length; j++) {
var nc = ncSlice[j];
html += '<tr>'
+ '<td title="' + escapeHtml(nc.path) + '">' + escapeHtml(nc.path) + '</td>'
+ '<td>' + nc.userHits.toLocaleString('de-DE') + '</td>'
+ '<td>' + formatBytes(nc.bytes) + '</td>'
+ '</tr>';
}
html += '</tbody></table>';
html += '<div id="not-crawled-pagination" class="pagination"></div>';
} else {
html += '<div class="empty-hint">Alle Seiten werden von Bots gecrawlt.</div>';
}
container.innerHTML = html;
if (wastePaths.length) {
var twp = Math.ceil(wastePaths.length / PAGE_SIZE);
renderTableFooter('crawl-waste-pagination', {
page: tablePages.crawlWaste, totalPages: twp,
onPageChange: function(dir) {
if (dir === 'prev' && tablePages.crawlWaste > 1) tablePages.crawlWaste--;
if (dir === 'next' && tablePages.crawlWaste < twp) tablePages.crawlWaste++;
renderCrawlBudget(aggregates, filteredEntries);
}
});
}
if (ncPaths.length) {
var tnp = Math.ceil(ncPaths.length / PAGE_SIZE);
renderTableFooter('not-crawled-pagination', {
page: tablePages.notCrawled, totalPages: tnp,
onPageChange: function(dir) {
if (dir === 'prev' && tablePages.notCrawled > 1) tablePages.notCrawled--;
if (dir === 'next' && tablePages.notCrawled < tnp) tablePages.notCrawled++;
renderCrawlBudget(aggregates, filteredEntries);
}
});
}
}
function getTimelineColor(dimension, entity) {
var dimColors = TIMELINE_COLORS[dimension];
if (dimColors && dimColors[entity]) return dimColors[entity];
if (entity === 'Sonstige') return TIMELINE_COLORS._sonstige;
return TIMELINE_COLORS._fallback;
}
function formatYLabel(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return String(n);
}
var _timelineConfigPosition = null;
var TIMELINE_DIMENSIONS = [
{ key: 'statusCode', label: 'Statuscode' },
{ key: 'trafficType', label: 'Bot vs. Browser' },
{ key: 'botCategory', label: 'Bot-Kategorie' },
{ key: 'device', label: 'Gerätekategorie' },
{ key: 'resourceType', label: 'Ressourcentyp' },
{ key: 'browser', label: 'Browser' },
{ key: 'os', label: 'Betriebssystem' },
{ key: 'method', label: 'HTTP-Methode' }
];
function refreshTimeline() {
renderTimeline(getFilteredData().entries);
}
function renderTimeline(filteredEntries) {
var container = document.getElementById('timeline-content');
if (!container) return;
var cards = AppState.timelineCards || [];
var html = '';
if (cards.length === 0 && !_timelineConfigPosition) {
html += '<div class="timeline-empty">';
html += '<div style="margin-bottom:1.5rem">Noch keine Karten angelegt. Erstelle eine Karte, um Metriken im Zeitverlauf zu vergleichen.</div>';
html += '<button class="timeline-add-btn" onclick="_timelineConfigPosition=\'bottom\';refreshTimeline()">+ Karte hinzufügen</button>';
html += '</div>';
container.innerHTML = html;
return;
}
var sharedGranularity = null;
if (filteredEntries && filteredEntries.length > 0 && cards.length > 0) {
var probe = buildTimelineData(filteredEntries, cards[0].dimension, cards[0].values || []);
sharedGranularity = probe.granularity;
}
html += '<div class="timeline-canvas">';
if (cards.length > 0 && cards.length < 10) {
html += '<button class="timeline-add-btn" onclick="_timelineConfigPosition=\'top\';refreshTimeline()">+ Karte oben einfügen</button>';
}
if (_timelineConfigPosition === 'top' || _timelineConfigPosition === 0) {
html += renderTimelineConfig(0);
}
for (var ci = 0; ci < cards.length; ci++) {
var card = cards[ci];
var data = (filteredEntries && filteredEntries.length > 0)
? buildTimelineData(filteredEntries, card.dimension, card.values || [], sharedGranularity)
: { buckets: [], granularity: 'day' };
if (card.type === 'line') {
html += renderTimelineLineCard(card, data, ci);
} else {
html += renderTimelineStackedCard(card, data, ci);
}
if (ci < cards.length - 1 && cards.length < 10) {
if (_timelineConfigPosition === ci + 1) {
html += renderTimelineConfig(ci + 1);
} else {
html += '<button class="timeline-add-btn timeline-add-between" onclick="_timelineConfigPosition=' + (ci + 1) + ';refreshTimeline()">+</button>';
}
}
}
if (_timelineConfigPosition === 'bottom') {
html += renderTimelineConfig(cards.length);
}
if (cards.length > 0 && cards.length < 10) {
html += '<button class="timeline-add-btn" onclick="_timelineConfigPosition=\'bottom\';refreshTimeline()">+ Karte unten einfügen</button>';
}
html += '</div>';
container.innerHTML = html;
if (_timelineConfigPosition !== null) {
setTimeout(tlPopulateDatalist, 0);
}
initTimelineDragDrop(container);
}
function initTimelineDragDrop(container) {
container.querySelectorAll('.timeline-card[draggable]').forEach(function(el) {
el.addEventListener('dragstart', function(e) {
e.dataTransfer.setData('text/plain', el.dataset.cardIdx);
el.classList.add('timeline-card-dragging');
});
el.addEventListener('dragend', function() {
el.classList.remove('timeline-card-dragging');
container.querySelectorAll('.timeline-card-dragover').forEach(function(x) { x.classList.remove('timeline-card-dragover'); });
});
el.addEventListener('dragover', function(e) {
e.preventDefault();
el.classList.add('timeline-card-dragover');
});
el.addEventListener('dragleave', function() {
el.classList.remove('timeline-card-dragover');
});
el.addEventListener('drop', function(e) {
e.preventDefault();
el.classList.remove('timeline-card-dragover');
var fromIdx = parseInt(e.dataTransfer.getData('text/plain'), 10);
var toIdx = parseInt(el.dataset.cardIdx, 10);
if (fromIdx === toIdx || isNaN(fromIdx) || isNaN(toIdx)) return;
var moved = AppState.timelineCards.splice(fromIdx, 1)[0];
AppState.timelineCards.splice(toIdx, 0, moved);
for (var i = 0; i < AppState.timelineCards.length; i++) AppState.timelineCards[i].position = i;
Storage.saveAll(AppState);
refreshTimeline();
});
});
}
function renderTimelineConfig(insertPosition) {
var html = '<div class="timeline-config" id="timeline-config-form">';
html += '<div><label>Chart-Typ</label><select id="tl-cfg-type" onchange="tlConfigTypeChanged()"><option value="line">Linien</option><option value="stacked">Stacked Bars</option></select></div>';
html += '<div><label>Dimension</label><select id="tl-cfg-dim" onchange="tlPopulateDatalist()">';
for (var d = 0; d < TIMELINE_DIMENSIONS.length; d++) {
html += '<option value="' + TIMELINE_DIMENSIONS[d].key + '">' + TIMELINE_DIMENSIONS[d].label + '</option>';
}
html += '</select></div>';
html += '<div id="tl-cfg-value-wrap"><label>Wert</label><input id="tl-cfg-value" placeholder="z.B. 200, 2xx" list="tl-cfg-value-list"><datalist id="tl-cfg-value-list"></datalist></div>';
html += '<div><button class="timeline-add-btn" onclick="tlConfigAdd(' + insertPosition + ')">Anlegen</button></div>';
html += '<div><button class="timeline-btn-sm" onclick="_timelineConfigPosition=null;refreshTimeline()">Abbrechen</button></div>';
html += '</div>';
return html;
}
function tlConfigTypeChanged() {
var wrap = document.getElementById('tl-cfg-value-wrap');
if (!wrap) return;
var type = document.getElementById('tl-cfg-type').value;
wrap.style.display = type === 'stacked' ? 'none' : '';
if (type === 'line') tlPopulateDatalist();
}
function tlGetDimensionOptions(dim) {
if (dim === 'statusCode') return ['2xx', '3xx', '4xx', '5xx', '200', '301', '302', '404', '500', '503'];
if (dim === 'trafficType') return ['Browser', 'Bots', 'Angriffe'];
if (dim === 'device') return ['Desktop', 'Smartphone', 'Tablet'];
if (dim === 'method') return ['GET', 'POST', 'HEAD', 'PUT', 'DELETE'];
if (dim === 'botCategory') {
var opts = [];
if (typeof BOT_CATEGORY_LABELS !== 'undefined') {
for (var k in BOT_CATEGORY_LABELS) opts.push(BOT_CATEGORY_LABELS[k]);
}
return opts;
}
if (dim === 'resourceType') return ['Seite', 'Bild', 'CSS', 'JavaScript', 'Schrift', 'Feed/Daten', 'Dokument', 'Media', 'Sonstiges'];
if (dim === 'browser' || dim === 'os') {
var fd = getFilteredData();
if (fd && fd.filteredAggregates) {
var list = dim === 'browser' ? fd.filteredAggregates.browserList : fd.filteredAggregates.osList;
if (list) return list.map(function(item) { return item.name; });
}
}
return [];
}
function tlPopulateDatalist() {
var dl = document.getElementById('tl-cfg-value-list');
var dimSel = document.getElementById('tl-cfg-dim');
if (!dl || !dimSel) return;
var options = tlGetDimensionOptions(dimSel.value);
dl.innerHTML = '';
for (var o = 0; o < options.length; o++) {
dl.innerHTML += '<option value="' + escapeHtml(options[o]) + '">';
}
}
function tlConfigAdd(insertPosition) {
var type = document.getElementById('tl-cfg-type').value;
var dim = document.getElementById('tl-cfg-dim').value;
var valueInput = document.getElementById('tl-cfg-value');
var values = [];
if (type === 'line' && valueInput && valueInput.value.trim()) {
values = [valueInput.value.trim()];
}
var card = {
id: String(Date.now()),
type: type,
mode: 'percent',
dimension: dim,
values: values,
position: insertPosition
};
AppState.timelineCards.splice(insertPosition, 0, card);
for (var i = 0; i < AppState.timelineCards.length; i++) {
AppState.timelineCards[i].position = i;
}
_timelineConfigPosition = null;
Storage.saveAll(AppState);
refreshTimeline();
}
function tlRemoveCard(idx) {
AppState.timelineCards.splice(idx, 1);
for (var i = 0; i < AppState.timelineCards.length; i++) {
AppState.timelineCards[i].position = i;
}
Storage.saveAll(AppState);
refreshTimeline();
}
function tlAddLine(cardIdx) {
var input = document.getElementById('tl-addline-' + cardIdx);
if (!input || !input.value.trim()) return;
var card = AppState.timelineCards[cardIdx];
if (!card || card.values.indexOf(input.value.trim()) !== -1) return;
card.values.push(input.value.trim());
Storage.saveAll(AppState);
refreshTimeline();
}
function tlRemoveLine(cardIdx, value) {
var card = AppState.timelineCards[cardIdx];
if (!card) return;
card.values = card.values.filter(function(v) { return v !== value; });
if (card.values.length === 0) {
tlRemoveCard(cardIdx);
} else {
Storage.saveAll(AppState);
refreshTimeline();
}
}
function tlSetMode(cardIdx, mode) {
var card = AppState.timelineCards[cardIdx];
if (!card) return;
card.mode = mode;
Storage.saveAll(AppState);
refreshTimeline();
}
function renderTimelineLineCard(card, data, cardIdx) {
var dimLabel = '';
for (var d = 0; d < TIMELINE_DIMENSIONS.length; d++) {
if (TIMELINE_DIMENSIONS[d].key === card.dimension) { dimLabel = TIMELINE_DIMENSIONS[d].label; break; }
}
var html = '<div class="timeline-card" draggable="true" data-card-idx="' + cardIdx + '">';
html += '<div class="timeline-card-header">';
html += '<div class="timeline-card-header-left"><span>' + escapeHtml(dimLabel) + '</span></div>';
html += '<div class="timeline-card-header-right">';
var dlId = 'tl-addline-list-' + cardIdx;
var dlOptions = tlGetDimensionOptions(card.dimension);
var existingVals = card.values || [];
html += '<input id="tl-addline-' + cardIdx + '" placeholder="Wert..." list="' + dlId + '" style="width:80px;padding:2px 6px;font-size:0.75rem;background:var(--input-bg);border:1px solid var(--border);border-radius:3px;color:var(--text)">';
html += '<datalist id="' + dlId + '">';
for (var dli = 0; dli < dlOptions.length; dli++) {
if (existingVals.indexOf(dlOptions[dli]) === -1) html += '<option value="' + escapeHtml(dlOptions[dli]) + '">';
}
html += '</datalist>';
html += '<button class="timeline-btn-sm" onclick="tlAddLine(' + cardIdx + ')">+ Linie</button>';
html += '<button class="timeline-btn-sm timeline-btn-delete" onclick="tlRemoveCard(' + cardIdx + ')">✕</button>';
html += '</div></div>';
html += '<div class="timeline-card-body">';
if (!data.buckets.length || !card.values.length) {
html += '<div class="empty-hint">Keine Daten</div>';
html += '</div></div>';
return html;
}
html += '<div class="timeline-legend">';
for (var vi = 0; vi < card.values.length; vi++) {
var val = card.values[vi];
var color = getTimelineColor(card.dimension, val);
html += '<div class="timeline-legend-item">';
html += '<div class="timeline-legend-dot" style="background:' + color + '"></div>';
html += '<span>' + escapeHtml(val) + '</span>';
html += '<span class="timeline-legend-remove" onclick="tlRemoveLine(' + cardIdx + ',\'' + escapeHtml(val).replace(/'/g, "\\'") + '\')">✕</span>';
html += '</div>';
}
html += '</div>';
var maxY = 0;
for (var bi = 0; bi < data.buckets.length; bi++) {
for (var vi2 = 0; vi2 < card.values.length; vi2++) {
var v = data.buckets[bi].values[card.values[vi2]] || 0;
if (v > maxY) maxY = v;
}
}
if (maxY === 0) maxY = 1;
var chartH = 150;
html += '<div class="timeline-chart-area">';
html += '<div class="timeline-y-label" style="top:-4px">' + formatYLabel(maxY) + '</div>';
html += '<div class="timeline-y-label" style="top:' + (chartH / 2 - 4) + 'px">' + formatYLabel(Math.round(maxY / 2)) + '</div>';
html += '<div class="timeline-y-label" style="bottom:-4px">0</div>';
var svgW = 1000;
html += '<svg width="100%" height="100%" viewBox="0 0 ' + svgW + ' ' + chartH + '" preserveAspectRatio="none" style="overflow:visible">';
var n = data.buckets.length;
for (var li = 0; li < card.values.length; li++) {
var lineVal = card.values[li];
var lineColor = getTimelineColor(card.dimension, lineVal);
var points = [];
for (var pi = 0; pi < n; pi++) {
var x = n === 1 ? svgW / 2 : (pi / (n - 1)) * svgW;
var yVal = data.buckets[pi].values[lineVal] || 0;
var y = chartH - (yVal / maxY) * chartH;
points.push(Math.round(x) + ',' + Math.round(y));
}
html += '<polyline points="' + points.join(' ') + '" fill="none" stroke="' + lineColor + '" stroke-width="2" vector-effect="non-scaling-stroke"/>';
for (var di = 0; di < n; di++) {
var dx = n === 1 ? svgW / 2 : (di / (n - 1)) * svgW;
var dVal = data.buckets[di].values[lineVal] || 0;
var dy = chartH - (dVal / maxY) * chartH;
html += '<circle cx="' + Math.round(dx) + '" cy="' + Math.round(dy) + '" r="4" fill="' + lineColor + '" opacity="0" style="pointer-events:all"><title>' + escapeHtml(data.buckets[di].key) + ': ' + escapeHtml(lineVal) + ' = ' + dVal.toLocaleString('de-DE') + '</title></circle>';
}
}
html += '</svg>';
html += '</div>';
html += '<div class="timeline-x-labels">';
var step = Math.max(1, Math.floor(n / 6));
for (var xi = 0; xi < n; xi += step) {
html += '<span>' + escapeHtml(data.buckets[xi].label) + '</span>';
}
html += '</div>';
html += '</div></div>';
return html;
}
function renderTimelineStackedCard(card, data, cardIdx) {
var dimLabel = '';
for (var d = 0; d < TIMELINE_DIMENSIONS.length; d++) {
if (TIMELINE_DIMENSIONS[d].key === card.dimension) { dimLabel = TIMELINE_DIMENSIONS[d].label; break; }
}
var html = '<div class="timeline-card" draggable="true" data-card-idx="' + cardIdx + '">';
html += '<div class="timeline-card-header">';
html += '<div class="timeline-card-header-left"><span>' + escapeHtml(dimLabel) + '</span>';
html += '<div class="timeline-mode-toggle">';
html += '<button class="' + (card.mode === 'percent' ? 'active' : '') + '" onclick="tlSetMode(' + cardIdx + ',\'percent\')">100%</button>';
html += '<button class="' + (card.mode === 'volume' ? 'active' : '') + '" onclick="tlSetMode(' + cardIdx + ',\'volume\')">Volumen</button>';
html += '</div></div>';
html += '<div class="timeline-card-header-right">';
html += '<button class="timeline-btn-sm timeline-btn-delete" onclick="tlRemoveCard(' + cardIdx + ')">✕</button>';
html += '</div></div>';
html += '<div class="timeline-card-body">';
if (!data.buckets.length) {
html += '<div class="empty-hint">Keine Daten</div>';
html += '</div></div>';
return html;
}
var entitySet = new Set();
for (var bi = 0; bi < data.buckets.length; bi++) {
for (var ek in data.buckets[bi].values) entitySet.add(ek);
}
var entities = Array.from(entitySet);
html += '<div class="timeline-legend">';
for (var ei = 0; ei < entities.length; ei++) {
var color = getTimelineColor(card.dimension, entities[ei]);
html += '<div class="timeline-legend-item"><div class="timeline-legend-dot" style="background:' + color + '"></div><span>' + escapeHtml(entities[ei]) + '</span></div>';
}
html += '</div>';
var maxTotal = 0;
if (card.mode === 'volume') {
for (var bi2 = 0; bi2 < data.buckets.length; bi2++) {
var total = 0;
for (var ek2 in data.buckets[bi2].values) total += data.buckets[bi2].values[ek2];
if (total > maxTotal) maxTotal = total;
}
if (maxTotal === 0) maxTotal = 1;
}
var barAreaH = 150;
html += '<div style="display:flex;align-items:flex-end;gap:1px;height:' + barAreaH + 'px;border-left:1px solid var(--border);border-bottom:1px solid var(--border);margin-left:40px">';
for (var bi3 = 0; bi3 < data.buckets.length; bi3++) {
var bVals = data.buckets[bi3].values;
var bTotal = 0;
for (var ek3 in bVals) bTotal += bVals[ek3];
var barH = card.mode === 'percent' ? barAreaH : (bTotal === 0 ? 0 : Math.max((bTotal / maxTotal) * barAreaH, 2));
html += '<div class="timeline-stacked-bar" style="height:' + barH + 'px" data-tooltip="' + escapeHtml(data.buckets[bi3].key) + ': ' + bTotal.toLocaleString('de-DE') + ' Hits">';
for (var ei2 = 0; ei2 < entities.length; ei2++) {
var eVal = bVals[entities[ei2]] || 0;
if (eVal === 0 && card.mode === 'percent') continue;
if (card.mode === 'percent') {
var segFlex = bTotal === 0 ? 0 : (eVal / bTotal) * 100;
html += '<div class="timeline-stacked-segment" style="flex:' + segFlex.toFixed(2) + ';background:' + getTimelineColor(card.dimension, entities[ei2]) + '" title="' + escapeHtml(entities[ei2]) + ': ' + eVal.toLocaleString('de-DE') + '"></div>';
} else {
var segH = maxTotal === 0 ? 0 : (eVal / maxTotal) * barAreaH;
html += '<div class="timeline-stacked-segment" style="height:' + Math.max(segH, 0).toFixed(1) + 'px;background:' + getTimelineColor(card.dimension, entities[ei2]) + '" title="' + escapeHtml(entities[ei2]) + ': ' + eVal.toLocaleString('de-DE') + '"></div>';
}
}
html += '</div>';
}
html += '</div>';
html += '<div class="timeline-x-labels">';
var n = data.buckets.length;
var step = Math.max(1, Math.floor(n / 6));
for (var xi = 0; xi < n; xi += step) {
html += '<span>' + escapeHtml(data.buckets[xi].label) + '</span>';
}
html += '</div>';
html += '</div></div>';
return html;
}
</script>
<script>
function openExportModal() {
if (!AppState.rawEntries.length) {
showToast('Keine Daten zum Export vorhanden');
return;
}
const overlay = document.getElementById('export-modal-overlay');
if (overlay) overlay.classList.add('visible');
toggleExportOptions();
}
function closeExportModal(event) {
if (event && event.target !== event.currentTarget) return;
const overlay = document.getElementById('export-modal-overlay');
if (overlay) overlay.classList.remove('visible');
}
function toggleExportOptions() {
const format = document.querySelector('input[name="export-format"]:checked').value;
const jsonOpts = document.getElementById('export-json-options');
if (jsonOpts) jsonOpts.style.display = format === 'json' ? '' : 'none';
}
function classifyEntry(e) {
const isBot = isBotUserAgent(e.userAgent);
const botName = isBot ? identifyBot(e.userAgent) : '';
return {
resourceType: classifyResourceType(e.path),
isBot,
botCategory: botName ? classifyBot(botName) : '',
aiPurpose: botName ? (getAiPurpose(botName) || '') : '',
isAttack: !!e.isAttack
};
}
function executeExport() {
const filenameInput = document.getElementById('export-filename');
const baseName = (filenameInput.value || 'logalytrix-export').trim();
const format = document.querySelector('input[name="export-format"]:checked').value;
const filteredEntries = getFilteredEntries();
if (!filteredEntries.length) {
showToast('Keine Daten im aktuellen Filter');
closeExportModal();
return;
}
let blob, filename;
if (format === 'json') {
const includeAggregates = document.getElementById('export-include-aggregates').checked;
const result = {
meta: {
generatedAt: new Date().toISOString(),
totalEntries: filteredEntries.length,
filters: AppState.filters
},
entries: filteredEntries.map(e => {
const cls = classifyEntry(e);
return {
ip: e.ip,
timestamp: e.timestamp.toISOString(),
method: e.method,
path: e.path,
status: e.status,
bytes: e.bytes,
referrer: e.referrer || '',
userAgent: e.userAgent,
resourceType: cls.resourceType,
bot: cls.isBot,
botCategory: cls.botCategory,
aiPurpose: cls.aiPurpose,
attack: cls.isAttack
};
})
};
if (includeAggregates) {
const filteredSessions = buildSessions(filteredEntries);
result.meta.totalSessions = filteredSessions.length;
result.aggregates = buildAggregates(filteredEntries, filteredSessions);
}
blob = new Blob([JSON.stringify(result, null, 2)], { type: 'application/json' });
filename = baseName + '.json';
} else {
const header = ['Zeitstempel', 'IP', 'Methode', 'Pfad', 'Status', 'Bytes', 'Referrer', 'User-Agent', 'Ressourcentyp', 'Bot', 'Bot-Kategorie', 'KI-Zweck', 'Angriff'].join('\t');
const rows = filteredEntries.map(e => {
const cls = classifyEntry(e);
return [
e.timestamp.toISOString().replace('T', ' ').slice(0, 19),
e.ip,
e.method,
e.path,
e.status,
e.bytes,
e.referrer || '',
e.userAgent,
cls.resourceType,
cls.isBot ? 'Ja' : 'Nein',
cls.botCategory,
cls.aiPurpose,
cls.isAttack ? 'Ja' : 'Nein'
].join('\t');
});
blob = new Blob([header + '\n' + rows.join('\n')], { type: 'text/tab-separated-values' });
filename = baseName + '.tsv';
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
closeExportModal();
showToast(`Export: ${filename} (${filteredEntries.length.toLocaleString('de-DE')} Einträge)`);
}
</script>
<script>
var LOGALYTRIX_MODE = '__MODE__';
var AppState = {
rawEntries: [],
sessions: [],
aggregates: null,
filters: {
dateFrom: null,
dateTo: null,
statusGroup: "",
resourceType: "",
botFilter: "",
device: "",
botCategory: "",
path: ""
},
darkMode: null,
patterns: [],
cleanupActive: true,
attributionModel: 'last',
ownHost: '',
truncated: false,
timelineCards: [],
heuristicRules: null,
heuristicBotVisitors: new Map()
};
function nextTick() {
return new Promise(resolve => setTimeout(resolve, 0));
}
document.addEventListener("DOMContentLoaded", () => {
initDarkMode();
initUI();
initApp();
});
function initDarkMode() {
const stored = localStorage.getItem("logalytrix-dark-mode");
if (stored !== null) {
AppState.darkMode = stored === "true";
} else {
AppState.darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
}
applyDarkMode();
}
function toggleDarkMode() {
AppState.darkMode = !AppState.darkMode;
applyDarkMode();
localStorage.setItem("logalytrix-dark-mode", AppState.darkMode);
}
function applyDarkMode() {
const root = document.documentElement;
const icon = document.getElementById("dark-mode-icon");
if (AppState.darkMode) {
root.classList.add("dark");
if (icon) icon.textContent = "☀️";
} else {
root.classList.remove("dark");
if (icon) icon.textContent = "🌙";
}
}
function toggleMobileMenu(forceState) {
const sidebar = document.getElementById("sidebar");
const overlay = document.getElementById("mobile-menu-overlay");
if (!sidebar || !overlay) return;
const shouldOpen = typeof forceState === "boolean" ? forceState : !sidebar.classList.contains("open");
if (shouldOpen) {
sidebar.classList.add("open");
overlay.classList.add("visible");
} else {
sidebar.classList.remove("open");
overlay.classList.remove("visible");
}
}
async function initApp() {
let savedView = localStorage.getItem('logalytrix-active-view');
if (savedView === 'paths') savedView = 'entry-pages';
if (savedView) switchView(savedView);
let t0 = performance.now();
const stored = await Storage.loadAll();
if (stored && stored.rawEntries && stored.rawEntries.length > 0) {
showProgress('Daten werden geladen\u2026');
await nextTick();
const loadTime = performance.now() - t0;
t0 = performance.now();
AppState.rawEntries = stored.rawEntries.map(reviveParsedEntry);
AppState.sessions = stored.sessions.map(reviveSession);
AppState.aggregates = stored.aggregates;
AppState.filters = stored.filters || AppState.filters;
AppState.patterns = stored.patterns || [];
AppState.cleanupActive = stored.cleanupActive !== false;
AppState.attributionModel = stored.attributionModel || 'last';
AppState.ownHost = stored.ownHost || '';
AppState.truncated = stored.truncated || false;
AppState.timelineCards = stored.timelineCards || [];
AppState.heuristicRules = stored.heuristicRules || null;
AppState.heuristicBotVisitors = stored.heuristicBotVisitors
? new Map(stored.heuristicBotVisitors) : new Map();
if (!AppState.heuristicRules) {
AppState.heuristicRules = JSON.parse(JSON.stringify(HEURISTIC_DEFAULTS));
}
const hydrateTime = performance.now() - t0;
showProgress('Darstellung wird aktualisiert\u2026');
await nextTick();
t0 = performance.now();
updateFilterUIFromState();
updateDataStatus();
renderCurrentView();
const renderTime = performance.now() - t0;
hideProgress();
console.log(`[Perf:Init] ${AppState.rawEntries.length.toLocaleString('de-DE')} Einträge | DB-Load: ${(loadTime/1000).toFixed(1)}s | Hydration: ${(hydrateTime/1000).toFixed(1)}s | Render: ${(renderTime/1000).toFixed(1)}s | Gesamt: ${((loadTime+hydrateTime+renderTime)/1000).toFixed(1)}s`);
}
}
function reviveParsedEntry(e) {
const entry = { ...e, timestamp: new Date(e.timestamp) };
if (entry.isAttack === undefined) entry.isAttack = isAttackRequest(entry);
if (entry.isBot === undefined) entry.isBot = isBotUserAgent(entry.userAgent);
return entry;
}
function reviveSession(s) {
return {
...s,
start: new Date(s.start),
end: new Date(s.end)
};
}
function updateFilterUIFromState() {
const { dateFrom, dateTo, statusGroup, resourceType, botFilter } = AppState.filters;
const fFrom = document.getElementById("filter-date-from");
const fTo = document.getElementById("filter-date-to");
const fStatus = document.getElementById("filter-status");
const fResType = document.getElementById("filter-resource-type");
const fBots = document.getElementById("filter-bots");
if (dateFrom) fFrom.value = dateFrom;
if (dateTo) fTo.value = dateTo;
fStatus.value = statusGroup || "";
fResType.value = resourceType || "";
fBots.value = botFilter || "";
document.getElementById("filter-device").value = AppState.filters.device || "";
document.getElementById("filter-browser").value = AppState.filters.browser || "";
document.getElementById("filter-os").value = AppState.filters.os || "";
document.getElementById("filter-method").value = AppState.filters.method || "";
const pathEl = document.getElementById("filter-path");
if (pathEl) pathEl.value = AppState.filters.path || "";
const botCatEl = document.getElementById('filter-bot-category');
if (botCatEl && AppState.filters.botCategory) {
botCatEl.value = AppState.filters.botCategory;
}
syncDateRangePreset();
}
function updateDataStatus(serverTotal) {
const el = document.getElementById("data-status");
if (!AppState.rawEntries.length) {
el.textContent = "Keine Daten geladen";
return;
}
const range = getTimestampRange(AppState.rawEntries);
el.innerHTML = `${AppState.rawEntries.length.toLocaleString('de-DE')} Einträge<br>${toLocalDateStr(range.min)}${toLocalDateStr(range.max)}`;
}
const MAX_LINE_LENGTH = 10000;
const BYTES_PER_ENTRY = 75;
const DEFAULT_MAX_LINES = 2000000;
const STORAGE_TARGET_RATIO = 0.5;
let maxLines = DEFAULT_MAX_LINES;
async function estimateMaxLines() {
if (navigator.storage && navigator.storage.estimate) {
try {
const est = await navigator.storage.estimate();
const available = (est.quota || 0) - (est.usage || 0);
const dynamicMax = Math.floor(available * STORAGE_TARGET_RATIO / BYTES_PER_ENTRY);
maxLines = Math.max(DEFAULT_MAX_LINES, dynamicMax);
console.log('[Storage] Quota: ' + Math.round((est.quota || 0) / 1048576) + ' MB, Belegt: ' + Math.round((est.usage || 0) / 1048576) + ' MB, Frei: ' + Math.round(available / 1048576) + ' MB → Max-Einträge: ' + maxLines.toLocaleString('de-DE'));
} catch (_) {
maxLines = DEFAULT_MAX_LINES;
}
}
return maxLines;
}
async function decompressGzipStream(buffer) {
const ds = new DecompressionStream('gzip');
const reader = new Blob([buffer]).stream().pipeThrough(ds).getReader();
const decoder = new TextDecoder();
const chunks = [];
let hasMore = false;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(decoder.decode(value, { stream: true }));
}
} catch (e) {
hasMore = chunks.length > 0;
if (!hasMore) throw e;
}
chunks.push(decoder.decode());
return { text: chunks.join(''), hasMore };
}
async function readFileText(file) {
if (file.name.endsWith('.gz')) {
const buf = await file.arrayBuffer();
const bytes = new Uint8Array(buf);
const parts = [];
let offset = 0;
while (offset < bytes.length) {
const { text, hasMore } = await decompressGzipStream(bytes.subarray(offset));
if (text) parts.push(text);
if (!hasMore) break;
let next = -1;
for (let i = offset + 1; i < bytes.length - 2; i++) {
if (bytes[i] === 0x1f && bytes[i + 1] === 0x8b && bytes[i + 2] === 0x08) {
next = i;
break;
}
}
if (next === -1) break;
offset = next;
}
if (parts.length === 0) throw new Error('Keine gzip-Daten dekomprimierbar');
return parts.join('');
}
return await file.text();
}
let _importScopeResolve = null;
function showImportScopeDialog() {
const activeCleanup = AppState.patterns.filter(p => p.type === 'cleanup' && p.active);
const cleanupCheckbox = document.getElementById('import-scope-cleanup');
const cleanupLabel = document.getElementById('import-scope-cleanup-label');
const cleanupCount = document.getElementById('import-scope-cleanup-count');
document.getElementById('import-scope-browser').checked = true;
document.getElementById('import-scope-bots').checked = true;
document.getElementById('import-scope-attacks').checked = true;
document.getElementById('import-scope-3xx').checked = true;
document.getElementById('import-scope-4xx').checked = true;
document.getElementById('import-scope-5xx').checked = true;
document.getElementById('import-scope-images').checked = true;
document.getElementById('import-scope-css').checked = true;
document.getElementById('import-scope-js').checked = true;
document.getElementById('import-scope-fonts').checked = true;
const hasGoals = AppState.patterns.some(p => p.type === 'conversion');
const attrOnlyGroup = document.getElementById('import-scope-attribution-only-group');
const attrOnlyCheckbox = document.getElementById('import-scope-attribution-only');
attrOnlyGroup.style.display = hasGoals ? '' : 'none';
attrOnlyCheckbox.checked = false;
toggleAttributionOnly();
if (activeCleanup.length > 0) {
cleanupCheckbox.checked = true;
cleanupCheckbox.disabled = false;
cleanupLabel.classList.remove('disabled');
cleanupCount.textContent = '(' + activeCleanup.length + ' Muster)';
} else {
cleanupCheckbox.checked = false;
cleanupCheckbox.disabled = true;
cleanupLabel.classList.add('disabled');
cleanupCount.textContent = '';
}
const hint = document.getElementById('import-scope-hint');
hint.textContent = 'Maximales Zeilenlimit: ' + maxLines.toLocaleString('de-DE') + '. Nicht benötigte Kategorien abwählen, um mehr relevante Daten in das Limit zu bekommen.';
const overlay = document.getElementById('import-scope-overlay');
overlay.classList.add('visible');
return new Promise(resolve => {
_importScopeResolve = resolve;
});
}
function confirmImportScope() {
const overlay = document.getElementById('import-scope-overlay');
overlay.classList.remove('visible');
if (_importScopeResolve) {
const attributionOnly = document.getElementById('import-scope-attribution-only').checked;
_importScopeResolve({
attributionOnly: attributionOnly,
includeBrowser: attributionOnly ? true : document.getElementById('import-scope-browser').checked,
includeBots: attributionOnly ? false : document.getElementById('import-scope-bots').checked,
includeAttacks: attributionOnly ? false : document.getElementById('import-scope-attacks').checked,
include3xx: attributionOnly ? true : document.getElementById('import-scope-3xx').checked,
include4xx: attributionOnly ? true : document.getElementById('import-scope-4xx').checked,
include5xx: attributionOnly ? true : document.getElementById('import-scope-5xx').checked,
includeImages: attributionOnly ? true : document.getElementById('import-scope-images').checked,
includeCSS: attributionOnly ? true : document.getElementById('import-scope-css').checked,
includeJS: attributionOnly ? true : document.getElementById('import-scope-js').checked,
includeFonts: attributionOnly ? true : document.getElementById('import-scope-fonts').checked,
applyCleanup: attributionOnly ? false : document.getElementById('import-scope-cleanup').checked
});
_importScopeResolve = null;
}
}
function cancelImportScope(event) {
if (event && event.target !== event.currentTarget) return;
const overlay = document.getElementById('import-scope-overlay');
overlay.classList.remove('visible');
if (_importScopeResolve) {
_importScopeResolve(null);
_importScopeResolve = null;
}
}
function toggleAttributionOnly() {
const checked = document.getElementById('import-scope-attribution-only').checked;
for (const id of ['import-scope-sources-group', 'import-scope-status-group', 'import-scope-resources-group', 'import-scope-cleanup-group']) {
const el = document.getElementById(id);
if (el) el.style.display = checked ? 'none' : '';
}
}
async function handleLogsLoaded(files) {
const scope = await showImportScopeDialog();
if (!scope) return; // user cancelled
const activeCleanup = scope.applyCleanup
? AppState.patterns.filter(p => p.type === 'cleanup' && p.active)
: [];
showProgress('Speicher wird gepr\u00fcft\u2026');
await nextTick();
await estimateMaxLines();
showProgress('Dateien werden gelesen\u2026');
await nextTick();
const perf = {};
let t0 = performance.now();
const allEntries = [];
let skippedLines = 0;
let truncated = false;
const compiledCleanup = activeCleanup.length > 0 ? compilePatterns(activeCleanup) : [];
const needScopeFilter = !scope.includeAttacks || !scope.includeBots || !scope.includeBrowser;
const needStatusFilter = !scope.include3xx || !scope.include4xx || !scope.include5xx;
const needTypeFilter = !scope.includeImages || !scope.includeCSS || !scope.includeJS || !scope.includeFonts;
const excludedTypes = new Set();
if (!scope.includeImages) excludedTypes.add('Bild');
if (!scope.includeCSS) excludedTypes.add('CSS');
if (!scope.includeJS) excludedTypes.add('JavaScript');
if (!scope.includeFonts) excludedTypes.add('Schrift');
const attrConvPatterns = scope.attributionOnly
? compilePatterns(AppState.patterns.filter(p => p.type === 'conversion'))
: [];
const attrOwnHost = scope.attributionOnly
? (AppState.ownHost || '').replace(/^www\./, '').toLowerCase()
: '';
const failedFiles = [];
let processedLines = 0;
const PROGRESS_INTERVAL = 50000;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (truncated) break;
showImportProgress(i + 1, files.length, allEntries.length, skippedLines, (i / files.length) * 100);
await new Promise(r => requestAnimationFrame(r));
let text;
try {
text = await readFileText(file);
} catch (e) {
console.warn(`Datei übersprungen (${file.name}):`, e);
failedFiles.push(file.name);
continue;
}
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
for (let j = 0; j < lines.length; j++) {
const line = lines[j];
processedLines++;
if (allEntries.length >= maxLines) {
truncated = true;
break;
}
if (line.length > MAX_LINE_LENGTH) {
skippedLines++;
continue;
}
const parsed = parseJsonLogLine(line) || parseApacheCombinedLog(line);
if (parsed) {
parsed.isAttack = isAttackRequest(parsed);
parsed.isBot = isBotUserAgent(parsed.userAgent);
if (needScopeFilter) {
if (!scope.includeAttacks && parsed.isAttack) {
skippedLines++;
continue;
}
if (!scope.includeBots && parsed.isBot && !parsed.isAttack) {
skippedLines++;
continue;
}
if (!scope.includeBrowser && !parsed.isBot && !parsed.isAttack) {
skippedLines++;
continue;
}
}
if (needStatusFilter) {
const sc = parsed.status;
if (!scope.include3xx && sc >= 300 && sc < 400) { skippedLines++; continue; }
if (!scope.include4xx && sc >= 400 && sc < 500) { skippedLines++; continue; }
if (!scope.include5xx && sc >= 500) { skippedLines++; continue; }
}
if (needTypeFilter && excludedTypes.has(classifyResourceType(parsed.path))) {
skippedLines++;
continue;
}
if (compiledCleanup.length > 0 && compiledCleanup.some(c => c.test(parsed.path))) {
skippedLines++;
continue;
}
if (scope.attributionOnly) {
const isRelevant = isExternalReferrer(parsed.referrer, attrOwnHost)
|| hasUtmParams(parsed.path)
|| hasClickId(parsed.path)
|| (attrConvPatterns.length > 0 && attrConvPatterns.some(c => c.test(parsed.path)));
if (!isRelevant) {
skippedLines++;
continue;
}
}
allEntries.push(parsed);
}
if (processedLines % PROGRESS_INTERVAL === 0) {
const pct = (i / files.length + (j / lines.length) / files.length) * 100;
showImportProgress(i + 1, files.length, allEntries.length, skippedLines, pct);
await new Promise(r => requestAnimationFrame(r));
}
}
}
perf.parsing = performance.now() - t0;
showProgress('Sessions werden berechnet\u2026');
await nextTick();
t0 = performance.now();
AppState.rawEntries = allEntries;
AppState.ownHost = '';
AppState.truncated = truncated;
AppState.sessions = buildSessions(allEntries);
perf.sessions = performance.now() - t0;
showProgress('Aggregationen (Eintr\u00e4ge)\u2026');
await nextTick();
t0 = performance.now();
const aggMaps = buildAggregatesCore(allEntries, AppState.sessions);
showProgress('Aggregationen (Seiten, Pfade, Quellen)\u2026');
await nextTick();
AppState.aggregates = finalizeAggregates(aggMaps, allEntries, AppState.sessions);
perf.aggregations = performance.now() - t0;
showProgress('Daten werden gespeichert\u2026');
await nextTick();
invalidateFilterCache();
t0 = performance.now();
await Storage.saveAll(AppState);
perf.storage = performance.now() - t0;
if (AppState.heuristicRules) {
var hasActiveHeuristic = Object.keys(AppState.heuristicRules).some(function(k) { return AppState.heuristicRules[k].active; });
if (hasActiveHeuristic) {
showProgress('Heuristische Bot-Erkennung\u2026');
await nextTick();
AppState.heuristicBotVisitors = runHeuristicAnalysis(allEntries, AppState.heuristicRules);
invalidateFilterCache();
await Storage.saveAll(AppState);
}
}
showProgress('Darstellung wird aktualisiert\u2026');
await nextTick();
t0 = performance.now();
updateDataStatus();
renderCurrentView();
perf.render = performance.now() - t0;
perf.total = perf.parsing + perf.sessions + perf.aggregations + perf.storage + perf.render;
console.log(`[Perf] ${allEntries.length.toLocaleString('de-DE')} Einträge | Parsing: ${(perf.parsing/1000).toFixed(1)}s | Sessions: ${(perf.sessions/1000).toFixed(1)}s | Aggregationen: ${(perf.aggregations/1000).toFixed(1)}s | Storage: ${(perf.storage/1000).toFixed(1)}s | Render: ${(perf.render/1000).toFixed(1)}s | Gesamt: ${(perf.total/1000).toFixed(1)}s`);
hideProgress();
let msg = `${allEntries.length.toLocaleString('de-DE')} Log-Einträge geladen`;
if (truncated) {
msg += ` (Limit: ${maxLines.toLocaleString('de-DE')} Zeilen)`;
}
if (skippedLines > 0) {
msg += ` (${skippedLines} Zeilen übersprungen)`;
}
if (failedFiles.length > 0) {
msg += ` — ${failedFiles.length} Datei(en) fehlgeschlagen: ${failedFiles.join(', ')}`;
}
showToast(msg);
}
function applyDateRangePreset(value) {
if (value === 'custom') {
var bar = document.getElementById('filter-bar');
if (bar && bar.classList.contains('collapsed')) toggleFilterBar();
return;
}
var days = parseInt(value);
var yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
var from, to;
if (days === 1) {
from = to = yesterday.toISOString().slice(0, 10);
} else {
to = yesterday.toISOString().slice(0, 10);
from = new Date(yesterday);
from.setDate(from.getDate() - (days - 1));
from = from.toISOString().slice(0, 10);
}
document.getElementById('filter-date-from').value = from;
document.getElementById('filter-date-to').value = to;
applyFiltersFromUI();
var presetEl = document.getElementById('date-range-preset');
if (presetEl) presetEl.blur();
}
function syncDateRangePreset() {
var el = document.getElementById('date-range-preset');
if (!el) return;
var f = AppState.filters.dateFrom || '';
var t = AppState.filters.dateTo || '';
if (!f && !t) { el.value = 'custom'; return; }
var yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
var yStr = yesterday.toISOString().slice(0, 10);
if (t !== yStr) { el.value = 'custom'; return; }
var presets = [1, 7, 14, 28, 60, 90];
for (var i = 0; i < presets.length; i++) {
var d = presets[i];
var expectedFrom;
if (d === 1) {
expectedFrom = yStr;
} else {
var ff = new Date(yesterday);
ff.setDate(ff.getDate() - (d - 1));
expectedFrom = ff.toISOString().slice(0, 10);
}
if (f === expectedFrom) { el.value = String(d); return; }
}
el.value = 'custom';
}
function applyFiltersFromUI() {
const fFrom = document.getElementById("filter-date-from").value || null;
const fTo = document.getElementById("filter-date-to").value || null;
const fStatus = document.getElementById("filter-status").value || "";
const fResType = document.getElementById("filter-resource-type").value || "";
const fBots = document.getElementById("filter-bots").value || "";
const fDevice = document.getElementById("filter-device").value || "";
const fBotCategory = document.getElementById("filter-bot-category") ? document.getElementById("filter-bot-category").value || "" : "";
const fBrowser = document.getElementById("filter-browser").value || "";
const fOS = document.getElementById("filter-os").value || "";
const fMethod = document.getElementById("filter-method").value || "";
const fPath = (document.getElementById("filter-path") && document.getElementById("filter-path").value.trim()) || "";
AppState.filters = {
dateFrom: fFrom,
dateTo: fTo,
statusGroup: fStatus,
resourceType: fResType,
botFilter: fBots,
device: fDevice,
botCategory: fBotCategory,
browser: fBrowser,
os: fOS,
method: fMethod,
path: fPath
};
invalidateFilterCache();
Storage.saveAll(AppState);
renderWithProgress();
}
let _filteredCache = null;
function getFilterCacheKey() {
const f = AppState.filters;
const cleanupKey = AppState.cleanupActive
? AppState.patterns.filter(p => p.type === 'cleanup' && p.active).map(p => p.pattern + ':' + p.matchMode).join(',')
: '';
const conversionKey = AppState.patterns.filter(p => p.type === 'conversion').map(p => p.goalIndex + ':' + p.pattern + ':' + p.matchMode + ':' + p.countMode).join(',') + ':' + (AppState.attributionModel || 'last');
var heuristicKey = AppState.heuristicBotVisitors ? AppState.heuristicBotVisitors.size : 0;
const pathKey = f.path ? (f.path + '~' + (typeof searchMode !== 'undefined' ? searchMode : 'contains')) : '';
return (f.dateFrom || '') + '|' + (f.dateTo || '') + '|' + f.statusGroup + '|' + f.resourceType + '|' + f.botFilter + '|' + (f.device || '') + '|' + (f.browser || '') + '|' + (f.os || '') + '|' + (f.method || '') + '|' + AppState.rawEntries.length + '|' + cleanupKey + '|' + conversionKey + '|' + (AppState.ownHost || '') + '|h:' + heuristicKey + '|p:' + pathKey;
}
function invalidateFilterCache() {
_filteredCache = null;
invalidateNavOverviewCache();
}
function getFilteredData() {
const key = getFilterCacheKey();
if (_filteredCache && _filteredCache.key === key) return _filteredCache;
const filteredEntries = getFilteredEntries();
const filteredSessions = buildSessions(filteredEntries);
const filteredAggregates = buildAggregates(filteredEntries, filteredSessions);
_filteredCache = { key, entries: filteredEntries, sessions: filteredSessions, aggregates: filteredAggregates };
return _filteredCache;
}
function getFilteredEntries() {
let entries = AppState.rawEntries;
const { dateFrom, dateTo, statusGroup, resourceType, botFilter } = AppState.filters;
if (dateFrom) {
const fromDate = new Date(dateFrom);
entries = entries.filter(e => e.timestamp >= fromDate);
}
if (dateTo) {
const toDate = new Date(dateTo);
toDate.setHours(23,59,59,999);
entries = entries.filter(e => e.timestamp <= toDate);
}
if (statusGroup) {
entries = entries.filter(e => {
const s = e.status;
if (statusGroup === "2xx") return s >= 200 && s < 300;
if (statusGroup === "3xx") return s >= 300 && s < 400;
if (statusGroup === "4xx") return s >= 400 && s < 500;
if (statusGroup === "5xx") return s >= 500 && s < 600;
return true;
});
}
if (resourceType) {
entries = entries.filter(e => classifyResourceType(e.path) === resourceType);
}
if (botFilter) {
entries = entries.filter(e => {
if (botFilter === "only-human") return !isEffectiveBot(e) && !e.isAttack;
if (botFilter === "only-bot") return isEffectiveBot(e);
if (botFilter === "only-attack") return !!e.isAttack;
return true;
});
}
const deviceFilter = AppState.filters.device;
const browserFilter = AppState.filters.browser;
const osFilter = AppState.filters.os;
if (deviceFilter || browserFilter || osFilter) {
entries = entries.filter(e => {
const ua = classifyUserAgent(e.userAgent);
if (deviceFilter && ua.device !== deviceFilter) return false;
if (browserFilter && ua.browser !== browserFilter) return false;
if (osFilter && ua.os !== osFilter) return false;
return true;
});
}
if (AppState.filters.botCategory) {
const catFilter = AppState.filters.botCategory;
if (catFilter === 'heuristic') {
entries = entries.filter(e => {
return !e.isBot && AppState.heuristicBotVisitors && AppState.heuristicBotVisitors.has(e.ip + '|' + e.userAgent);
});
} else if (catFilter.startsWith('heuristic-')) {
var heuristicRuleId = catFilter.replace('heuristic-', '');
entries = entries.filter(e => {
if (!AppState.heuristicBotVisitors) return false;
return AppState.heuristicBotVisitors.get(e.ip + '|' + e.userAgent) === heuristicRuleId;
});
} else if (catFilter === 'ai-training' || catFilter === 'ai-grounding') {
const purpose = catFilter.replace('ai-', '');
entries = entries.filter(e => {
const botName = getEffectiveBotName(e);
if (!botName) return false;
return classifyBot(botName) === 'ai' && getAiPurpose(botName) === purpose;
});
} else {
entries = entries.filter(e => {
const botName = getEffectiveBotName(e);
if (!botName) return false;
return classifyBot(botName) === catFilter;
});
}
}
if (AppState.filters.method) {
const methodFilter = AppState.filters.method;
entries = entries.filter(e => e.method === methodFilter);
}
if (AppState.filters.path) {
const pathTerm = AppState.filters.path;
const mode = typeof searchMode !== 'undefined' ? searchMode : 'contains';
entries = entries.filter(e => matchPattern(e.path, pathTerm, mode));
}
if (AppState.cleanupActive) {
const cleanupPatterns = AppState.patterns.filter(p => p.type === 'cleanup' && p.active);
if (cleanupPatterns.length > 0) {
const compiled = compilePatterns(cleanupPatterns);
entries = entries.filter(e => !compiled.some(c => c.test(e.path)));
}
}
return entries;
}
let _renderPending = false;
let _renderQueued = false;
async function renderWithProgress() {
if (_renderPending) { _renderQueued = true; return; }
_renderPending = true;
const key = getFilterCacheKey();
if (_filteredCache && _filteredCache.key === key) {
showProgress('Ansicht wird aktualisiert\u2026');
await nextTick();
renderCurrentView();
hideProgress();
_renderPending = false;
if (_renderQueued) { _renderQueued = false; renderWithProgress(); }
return;
}
showProgress('Eintr\u00e4ge werden gefiltert\u2026');
await nextTick();
const filteredEntries = getFilteredEntries();
showProgress('Sessions werden berechnet\u2026');
await nextTick();
const filteredSessions = buildSessions(filteredEntries);
showProgress('Aggregationen (Eintr\u00e4ge)\u2026');
await nextTick();
const aggMaps = buildAggregatesCore(filteredEntries, filteredSessions);
showProgress('Aggregationen (Seiten, Pfade, Quellen)\u2026');
await nextTick();
const filteredAggregates = finalizeAggregates(aggMaps, filteredEntries, filteredSessions);
_filteredCache = { key, entries: filteredEntries, sessions: filteredSessions, aggregates: filteredAggregates };
showProgress('Ansicht wird aktualisiert\u2026');
await nextTick();
renderCurrentView();
hideProgress();
_renderPending = false;
if (_renderQueued) { _renderQueued = false; renderWithProgress(); }
}
function renderCurrentView() {
const activeNav = document.querySelector(".nav-item.active");
const view = activeNav ? activeNav.dataset.view : "raw";
const { entries: filteredEntries, sessions: filteredSessions, aggregates: filteredAggregates } = getFilteredData();
updateSummaryBadge(filteredEntries, filteredSessions);
updateFilterIndicator();
if (view === "raw") {
renderRawTable(filteredEntries);
} else if (view === "overview") {
renderOverview(filteredAggregates, filteredEntries);
} else if (view === "top-pages") {
renderTopPages(filteredAggregates);
} else if (view === "entry-pages") {
renderEntryPages(filteredAggregates);
} else if (view === "exit-pages") {
renderExitPages(filteredAggregates);
} else if (view === "transitions") {
renderTransitions(filteredAggregates);
} else if (view === "sources") {
renderSources(filteredAggregates);
} else if (view === "directories") {
renderDirectories(filteredAggregates, filteredEntries);
} else if (view === "status-codes") {
renderStatusCodes(filteredAggregates);
} else if (view === "bots") {
renderBots(filteredAggregates);
} else if (view === "bot-time") {
renderBotTime(filteredAggregates);
} else if (view === "parameters") {
renderParameters(filteredAggregates);
} else if (view === "bot-detection") {
renderHeuristicRules();
} else if (view === "cleanup") {
renderCleanup();
} else if (view === "conversions") {
renderConversions();
} else if (view === "resource-types") {
renderResourceTypes(filteredAggregates);
} else if (view === "browsers") {
renderBrowsers(filteredAggregates);
} else if (view === "conversions-report") {
renderConversionsReport(filteredAggregates);
} else if (view === "hotlinking") {
renderHotlinking(filteredAggregates);
} else if (view === "ip-anomalies") {
renderIpAnomalies(filteredAggregates);
} else if (view === "campaign-quality") {
renderCampaignQuality(filteredAggregates);
} else if (view === "crawl-budget") {
renderCrawlBudget(filteredAggregates, filteredEntries);
} else if (view === "session-analyse") {
renderSessionAnalyse(filteredSessions);
} else if (view === "conversion-paths") {
renderConversionPaths(filteredSessions);
} else if (view === "nav-overview") {
renderNavOverview(filteredSessions);
} else if (view === "timeline") {
renderTimeline(filteredEntries);
}
}
function updateSummaryBadge(entries, sessions) {
const el = document.getElementById("summary-badge");
if (!entries.length) {
el.innerHTML = "";
return;
}
const uniqueUsers = new Set(sessions.map(s => s.ip + "|" + s.userAgent)).size;
const range = getTimestampRange(entries);
let text = `Einträge: ${entries.length.toLocaleString('de-DE')} · Sessions: ${sessions.length.toLocaleString('de-DE')} · Nutzer: ${uniqueUsers.toLocaleString('de-DE')}`;
if (range) {
const fmt = d => d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
text += ` · Zeitraum: ${fmt(range.min)}${fmt(range.max)}`;
}
let html = text;
if (AppState.truncated) {
html += ' <span class="truncation-warning">Import unvollständig (Limit: ' + maxLines.toLocaleString('de-DE') + ' Zeilen)</span>';
}
el.innerHTML = html;
}
</script>
</body></html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment