|
<!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">▶</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">▶</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">▶</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 & 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">▶</span> Bots & 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">▶</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 —z. 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 → 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}')">×</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">→</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)">→</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">← 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 →</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); |
|
} |
|
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 >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>> <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 < <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> |