Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
- _tab_problems: 750 queries → ~10 batch queries with GROUP BY - _tab_engagement: 2550 queries → ~12 batch queries, sparkline in 1 query - user_insights_profile: 60+ queries → batch trend (2 queries), bot filtering on all metrics - Stat cards exclude UNAFFILIATED, dormant excludes never-logged-in users - Engagement status: never-logged=dormant, login<=7d+score>=10=active, 8-30d=at_risk - Badge CSS: support both at-risk and at_risk class names - Problems table: added Alerts and Locked columns - Security alerts stat card in Problems tab - Back link preserves tab/period context - Trend chart Y-axis dynamic instead of hardcoded max:30 - Timeline truncation info when >= 150 events - Migration 080: composite indexes on audit_logs and email_logs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
750 lines
36 KiB
HTML
750 lines
36 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}User Insights - Admin{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.insights-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: var(--spacing-md); margin-bottom: var(--spacing-lg); }
|
|
.insights-header h1 { font-size: var(--font-size-2xl); font-weight: 700; }
|
|
.insights-controls { display: flex; gap: var(--spacing-sm); align-items: center; flex-wrap: wrap; }
|
|
|
|
/* Period tabs */
|
|
.period-tabs { display: flex; gap: var(--spacing-xs); background: white; padding: var(--spacing-xs); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
|
|
.period-tab { padding: var(--spacing-sm) var(--spacing-md); border: none; background: transparent; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--font-size-sm); color: var(--text-secondary); transition: var(--transition); text-decoration: none; }
|
|
.period-tab.active { background: var(--primary); color: white; font-weight: 500; }
|
|
.period-tab:hover:not(.active) { background: var(--background); }
|
|
|
|
/* Main tabs */
|
|
.tabs { display: flex; gap: var(--spacing-xs); margin-bottom: var(--spacing-lg); background: white; padding: var(--spacing-xs); border-radius: var(--radius); box-shadow: var(--shadow-sm); flex-wrap: wrap; }
|
|
.tab-link { padding: var(--spacing-sm) var(--spacing-md); border: none; background: transparent; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--font-size-sm); font-weight: 500; color: var(--text-secondary); transition: var(--transition); text-decoration: none; white-space: nowrap; }
|
|
.tab-link.active { background: var(--primary); color: white; }
|
|
.tab-link:hover:not(.active) { background: var(--background); }
|
|
|
|
/* Stat cards */
|
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--spacing-md); margin-bottom: var(--spacing-xl); }
|
|
.stat-card { background: white; padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
|
|
.stat-card.error { border-left: 4px solid var(--error); }
|
|
.stat-card.warning { border-left: 4px solid #f59e0b; }
|
|
.stat-card.info { border-left: 4px solid #3b82f6; }
|
|
.stat-card.success { border-left: 4px solid var(--success); }
|
|
.stat-value { font-size: var(--font-size-2xl); font-weight: 700; color: var(--text-primary); }
|
|
.stat-label { color: var(--text-secondary); font-size: var(--font-size-sm); margin-top: var(--spacing-xs); }
|
|
|
|
/* Section cards */
|
|
.section-card { background: white; padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); margin-bottom: var(--spacing-xl); }
|
|
.section-card h2 { font-size: var(--font-size-lg); font-weight: 600; margin-bottom: var(--spacing-lg); display: flex; align-items: center; gap: var(--spacing-sm); }
|
|
|
|
/* Data table */
|
|
.data-table { width: 100%; border-collapse: collapse; }
|
|
.data-table th { text-align: left; padding: var(--spacing-sm) var(--spacing-md); background: var(--background); font-weight: 600; font-size: var(--font-size-xs); color: var(--text-secondary); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.data-table td { padding: var(--spacing-sm) var(--spacing-md); border-bottom: 1px solid var(--border); font-size: var(--font-size-sm); }
|
|
.data-table tr:last-child td { border-bottom: none; }
|
|
.data-table tr:hover td { background: var(--background); }
|
|
|
|
/* User cell */
|
|
.user-cell { display: flex; align-items: center; gap: var(--spacing-sm); }
|
|
.user-cell a { color: var(--primary); text-decoration: none; font-weight: 500; }
|
|
.user-cell a:hover { text-decoration: underline; }
|
|
.user-avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--primary); color: white; display: flex; align-items: center; justify-content: center; font-size: var(--font-size-xs); font-weight: 600; flex-shrink: 0; }
|
|
|
|
/* Badges */
|
|
.badge { display: inline-block; padding: 2px 8px; border-radius: var(--radius); font-size: var(--font-size-xs); font-weight: 500; }
|
|
.badge-critical { background: #7f1d1d; color: white; }
|
|
.badge-high { background: #fee2e2; color: #991b1b; }
|
|
.badge-medium { background: #fef3c7; color: #92400e; }
|
|
.badge-low { background: #e0f2fe; color: #0369a1; }
|
|
.badge-ok { background: #dcfce7; color: #166534; }
|
|
.badge-active { background: #dcfce7; color: #166534; }
|
|
.badge-at-risk, .badge-at_risk { background: #fef3c7; color: #92400e; }
|
|
.badge-dormant { background: #fee2e2; color: #991b1b; }
|
|
|
|
/* Sparkline */
|
|
.sparkline { display: inline-flex; align-items: flex-end; gap: 2px; height: 24px; }
|
|
.sparkline-bar { width: 6px; background: var(--primary); border-radius: 1px; min-height: 2px; opacity: 0.7; }
|
|
|
|
/* WoW arrow */
|
|
.wow-up { color: #16a34a; font-weight: 600; }
|
|
.wow-down { color: #dc2626; font-weight: 600; }
|
|
.wow-flat { color: var(--text-muted); }
|
|
|
|
/* Horizontal bars */
|
|
.bar-container { height: 20px; background: var(--background); border-radius: var(--radius-sm); overflow: hidden; min-width: 100px; }
|
|
.bar-fill { height: 100%; border-radius: var(--radius-sm); transition: width 0.3s ease; }
|
|
.bar-fill.primary { background: var(--primary); }
|
|
.bar-fill.green { background: #22c55e; }
|
|
.bar-fill.blue { background: #3b82f6; }
|
|
|
|
/* Section heatmap grid */
|
|
.sections-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--spacing-md); margin-bottom: var(--spacing-xl); }
|
|
.section-tile { padding: var(--spacing-lg); border-radius: var(--radius); text-align: center; transition: transform 0.2s; }
|
|
.section-tile:hover { transform: translateY(-2px); }
|
|
.section-tile h3 { font-size: var(--font-size-sm); font-weight: 600; margin-bottom: var(--spacing-sm); }
|
|
.section-tile .metric { font-size: var(--font-size-xs); color: rgba(0,0,0,0.6); }
|
|
|
|
/* Hourly heatmap */
|
|
.heatmap-table { border-collapse: collapse; width: 100%; }
|
|
.heatmap-table th { padding: 4px 6px; font-size: var(--font-size-xs); color: var(--text-secondary); font-weight: 500; }
|
|
.heatmap-cell { width: 28px; height: 28px; border-radius: 4px; margin: 1px; }
|
|
.heatmap-table td { padding: 1px; text-align: center; }
|
|
.heatmap-label { text-align: right; padding-right: 8px !important; font-weight: 500; font-size: var(--font-size-xs); color: var(--text-secondary); min-width: 30px; }
|
|
|
|
/* Two columns layout */
|
|
.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-xl); }
|
|
@media (max-width: 1024px) { .two-columns { grid-template-columns: 1fr; } }
|
|
|
|
/* Path transitions */
|
|
.transition-row { display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm) 0; border-bottom: 1px solid var(--border); font-size: var(--font-size-sm); }
|
|
.transition-arrow { color: var(--text-muted); }
|
|
.transition-count { font-weight: 600; min-width: 40px; text-align: right; }
|
|
|
|
/* Session length bars */
|
|
.bar-chart-row { display: flex; align-items: center; gap: var(--spacing-md); margin-bottom: var(--spacing-sm); }
|
|
.bar-chart-label { width: 100px; min-width: 100px; font-size: var(--font-size-sm); color: var(--text-secondary); }
|
|
.bar-chart-bar { flex: 1; height: 28px; background: var(--background); border-radius: var(--radius-sm); overflow: hidden; }
|
|
.bar-chart-fill { height: 100%; background: var(--primary); border-radius: var(--radius-sm); display: flex; align-items: center; padding-left: var(--spacing-sm); }
|
|
.bar-chart-fill span { color: white; font-size: var(--font-size-xs); font-weight: 600; }
|
|
|
|
/* Overview charts */
|
|
.chart-container { position: relative; height: 300px; }
|
|
|
|
/* Filter buttons */
|
|
.filter-group { display: flex; gap: var(--spacing-xs); }
|
|
.filter-btn { padding: 4px 12px; border: 1px solid var(--border); background: white; border-radius: var(--radius-sm); font-size: var(--font-size-xs); cursor: pointer; text-decoration: none; color: var(--text-secondary); }
|
|
.filter-btn.active { background: var(--primary); color: white; border-color: var(--primary); }
|
|
|
|
/* Export button */
|
|
.btn-export { display: inline-flex; align-items: center; gap: var(--spacing-xs); padding: var(--spacing-sm) var(--spacing-md); background: white; border: 1px solid var(--border); border-radius: var(--radius); font-size: var(--font-size-sm); color: var(--text-secondary); text-decoration: none; cursor: pointer; }
|
|
.btn-export:hover { background: var(--background); }
|
|
|
|
/* Table scroll */
|
|
.table-scroll { max-height: 500px; overflow-y: auto; }
|
|
|
|
/* Alerts */
|
|
.alert-card { display: flex; align-items: flex-start; gap: var(--spacing-md); padding: var(--spacing-md) var(--spacing-lg); border-radius: var(--radius); margin-bottom: var(--spacing-sm); }
|
|
.alert-card.critical { background: #fef2f2; border-left: 4px solid #dc2626; }
|
|
.alert-card.high { background: #fffbeb; border-left: 4px solid #f59e0b; }
|
|
.alert-card.medium { background: #eff6ff; border-left: 4px solid #3b82f6; }
|
|
.alert-icon { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 14px; }
|
|
.alert-card.critical .alert-icon { background: #fee2e2; }
|
|
.alert-card.high .alert-icon { background: #fef3c7; }
|
|
.alert-card.medium .alert-icon { background: #dbeafe; }
|
|
.alert-body { flex: 1; }
|
|
.alert-message { font-size: var(--font-size-sm); font-weight: 600; color: var(--text-primary); }
|
|
.alert-detail { font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 2px; }
|
|
.alert-action { flex-shrink: 0; }
|
|
.alert-action a { padding: 4px 12px; background: white; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--font-size-xs); color: var(--primary); text-decoration: none; }
|
|
.alert-action a:hover { background: var(--background); }
|
|
|
|
/* Responsive */
|
|
@media (max-width: 768px) {
|
|
.insights-header { flex-direction: column; align-items: flex-start; }
|
|
.tabs { overflow-x: auto; flex-wrap: nowrap; }
|
|
.sections-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
|
|
.data-table { font-size: var(--font-size-xs); }
|
|
.data-table th, .data-table td { padding: var(--spacing-xs) var(--spacing-sm); }
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="admin-container" style="max-width: 1400px; margin: 0 auto; padding: var(--spacing-lg);">
|
|
|
|
<!-- Header -->
|
|
<div class="insights-header">
|
|
<h1>User Insights</h1>
|
|
<div class="insights-controls">
|
|
<div class="period-tabs">
|
|
<a href="{{ url_for('admin.user_insights', tab=tab, period='day') }}" class="period-tab {% if period == 'day' %}active{% endif %}">Dziś</a>
|
|
<a href="{{ url_for('admin.user_insights', tab=tab, period='week') }}" class="period-tab {% if period == 'week' %}active{% endif %}">7 dni</a>
|
|
<a href="{{ url_for('admin.user_insights', tab=tab, period='month') }}" class="period-tab {% if period == 'month' %}active{% endif %}">30 dni</a>
|
|
</div>
|
|
{% if tab in ['problems', 'engagement', 'pages'] %}
|
|
<a href="{{ url_for('admin.user_insights_export', type=tab, period=period) }}" class="btn-export">
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
|
|
CSV
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="tabs">
|
|
<a href="{{ url_for('admin.user_insights', tab='problems', period=period) }}" class="tab-link {% if tab == 'problems' %}active{% endif %}">Problemy</a>
|
|
<a href="{{ url_for('admin.user_insights', tab='engagement', period=period) }}" class="tab-link {% if tab == 'engagement' %}active{% endif %}">Zaangażowanie</a>
|
|
<a href="{{ url_for('admin.user_insights', tab='pages', period=period) }}" class="tab-link {% if tab == 'pages' %}active{% endif %}">Mapa stron</a>
|
|
<a href="{{ url_for('admin.user_insights', tab='paths', period=period) }}" class="tab-link {% if tab == 'paths' %}active{% endif %}">Ścieżki</a>
|
|
<a href="{{ url_for('admin.user_insights', tab='overview', period=period) }}" class="tab-link {% if tab == 'overview' %}active{% endif %}">Przegląd</a>
|
|
</div>
|
|
|
|
<!-- ============================================================ -->
|
|
<!-- TAB: PROBLEMS -->
|
|
<!-- ============================================================ -->
|
|
{% if tab == 'problems' %}
|
|
<div class="stats-grid">
|
|
<div class="stat-card error">
|
|
<div class="stat-value">{{ data.locked_accounts }}</div>
|
|
<div class="stat-label">Zablokowane konta</div>
|
|
</div>
|
|
<div class="stat-card warning">
|
|
<div class="stat-value">{{ data.failed_logins }}</div>
|
|
<div class="stat-label">Nieudane logowania</div>
|
|
</div>
|
|
<div class="stat-card info">
|
|
<div class="stat-value">{{ data.password_resets }}</div>
|
|
<div class="stat-label">Resety hasła</div>
|
|
</div>
|
|
<div class="stat-card warning">
|
|
<div class="stat-value">{{ data.js_errors }}</div>
|
|
<div class="stat-label">Błędy JS</div>
|
|
</div>
|
|
<div class="stat-card warning">
|
|
<div class="stat-value">{{ data.security_alerts }}</div>
|
|
<div class="stat-label">Alerty bezpieczeństwa</div>
|
|
</div>
|
|
<div class="stat-card error">
|
|
<div class="stat-value">{{ data.never_logged_in }}</div>
|
|
<div class="stat-label">Nigdy nie zalogowani</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Proactive alerts -->
|
|
{% if data.alerts %}
|
|
<div class="section-card">
|
|
<h2>Alerty proaktywne
|
|
<span class="badge badge-high">{{ data.alerts|length }}</span>
|
|
</h2>
|
|
{% for alert in data.alerts %}
|
|
<div class="alert-card {{ alert.priority }}">
|
|
<div class="alert-icon">
|
|
{% if alert.type == 'never_logged_in' %}👤{% elif alert.type == 'locked' %}🔒{% elif alert.type == 'reset_no_effect' %}📧{% elif alert.type == 'repeat_resets' %}🔄{% else %}⚠{% endif %}
|
|
</div>
|
|
<div class="alert-body">
|
|
<div class="alert-message">{{ alert.user.name or alert.user.email }}: {{ alert.message }}</div>
|
|
<div class="alert-detail">{{ alert.detail }}</div>
|
|
</div>
|
|
<div class="alert-action">
|
|
<a href="{{ url_for('admin.user_insights_profile', user_id=alert.user.id, ref_tab=tab, ref_period=period) }}">Szczegóły →</a>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="section-card">
|
|
<h2>Użytkownicy z problemami
|
|
<span class="badge {% if data.problem_users|length > 10 %}badge-high{% elif data.problem_users|length > 0 %}badge-medium{% else %}badge-ok{% endif %}">
|
|
{{ data.problem_users|length }}
|
|
</span>
|
|
</h2>
|
|
|
|
{% if data.problem_users %}
|
|
<div class="table-scroll">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Użytkownik</th>
|
|
<th>Problem Score</th>
|
|
<th>Nieudane logowania</th>
|
|
<th>Resety hasła</th>
|
|
<th>Alerty</th>
|
|
<th>Błędy JS</th>
|
|
<th>Wolne strony</th>
|
|
<th>🔒</th>
|
|
<th>Ostatni login</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for p in data.problem_users %}
|
|
<tr>
|
|
<td>
|
|
<div class="user-cell">
|
|
<div class="user-avatar">{{ p.user.name[0] if p.user.name else '?' }}</div>
|
|
<a href="{{ url_for('admin.user_insights_profile', user_id=p.user.id, ref_tab=tab, ref_period=period) }}">{{ p.user.name or p.user.email }}</a>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span class="badge {% if p.score >= 51 %}badge-critical{% elif p.score >= 21 %}badge-high{% elif p.score >= 1 %}badge-medium{% else %}badge-ok{% endif %}">
|
|
{{ p.score }}
|
|
</span>
|
|
</td>
|
|
<td>{{ p.failed_logins }}</td>
|
|
<td>{{ p.password_resets }}</td>
|
|
<td>{{ p.security_alerts }}</td>
|
|
<td>{{ p.js_errors }}</td>
|
|
<td>{{ p.slow_pages }}</td>
|
|
<td>{% if p.is_locked %}<span class="badge badge-critical">Tak</span>{% endif %}</td>
|
|
<td>{{ p.last_login.strftime('%d.%m %H:%M') if p.last_login else 'Nigdy' }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<div style="text-align: center; padding: var(--spacing-xl); color: var(--text-muted);">
|
|
<p>Brak użytkowników z problemami w wybranym okresie.</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- ============================================================ -->
|
|
<!-- TAB: ENGAGEMENT -->
|
|
<!-- ============================================================ -->
|
|
{% elif tab == 'engagement' %}
|
|
<div class="stats-grid">
|
|
<div class="stat-card success">
|
|
<div class="stat-value">{{ data.active_7d }}</div>
|
|
<div class="stat-label">Aktywni ({{ period }})</div>
|
|
</div>
|
|
<div class="stat-card warning">
|
|
<div class="stat-value">{{ data.at_risk }}</div>
|
|
<div class="stat-label">Zagrożeni (8-30d)</div>
|
|
</div>
|
|
<div class="stat-card error">
|
|
<div class="stat-value">{{ data.dormant }}</div>
|
|
<div class="stat-label">Uśpieni (30d+)</div>
|
|
</div>
|
|
<div class="stat-card info">
|
|
<div class="stat-value">{{ data.new_this_month }}</div>
|
|
<div class="stat-label">Nowi (ten miesiąc)</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-card">
|
|
<h2>Ranking zaangażowania</h2>
|
|
|
|
{% if data.engagement_list %}
|
|
<div class="table-scroll">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Użytkownik</th>
|
|
<th>Score</th>
|
|
<th>Sesje</th>
|
|
<th>Odsłony</th>
|
|
<th>Zmiana</th>
|
|
<th>Aktywność</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for e in data.engagement_list %}
|
|
<tr>
|
|
<td style="font-weight: 600; color: var(--text-muted);">{{ loop.index }}</td>
|
|
<td>
|
|
<div class="user-cell">
|
|
<div class="user-avatar">{{ e.user.name[0] if e.user.name else '?' }}</div>
|
|
<a href="{{ url_for('admin.user_insights_profile', user_id=e.user.id, ref_tab=tab, ref_period=period) }}">{{ e.user.name or e.user.email }}</a>
|
|
</div>
|
|
</td>
|
|
<td><strong>{{ e.score }}</strong></td>
|
|
<td>{{ e.sessions }}</td>
|
|
<td>{{ e.page_views }}</td>
|
|
<td>
|
|
{% if e.wow is not none %}
|
|
{% if e.wow > 0 %}
|
|
<span class="wow-up">▲ {{ e.wow }}%</span>
|
|
{% elif e.wow < 0 %}
|
|
<span class="wow-down">▼ {{ e.wow|abs }}%</span>
|
|
{% else %}
|
|
<span class="wow-flat">— 0%</span>
|
|
{% endif %}
|
|
{% else %}
|
|
<span class="wow-flat">N/A</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<div class="sparkline">
|
|
{% set max_spark = e.sparkline|max if e.sparkline|max > 0 else 1 %}
|
|
{% for val in e.sparkline %}
|
|
<div class="sparkline-bar" style="height: {{ (val / max_spark * 22 + 2)|int }}px;"></div>
|
|
{% endfor %}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span class="badge badge-{{ e.status }}">
|
|
{% if e.status == 'active' %}Aktywny{% elif e.status == 'at_risk' %}Zagrożony{% else %}Uśpiony{% endif %}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<div style="text-align: center; padding: var(--spacing-xl); color: var(--text-muted);">
|
|
<p>Brak danych o zaangażowaniu w wybranym okresie.</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- ============================================================ -->
|
|
<!-- TAB: PAGE MAP -->
|
|
<!-- ============================================================ -->
|
|
{% elif tab == 'pages' %}
|
|
|
|
<!-- Section heatmap -->
|
|
<div class="section-card">
|
|
<h2>Popularność sekcji</h2>
|
|
<div class="sections-grid">
|
|
{% for s in data.sections %}
|
|
<div class="section-tile" style="background: rgba(34, 197, 94, {{ s.intensity / 100 * 0.3 + 0.05 }});">
|
|
<h3>{{ s.name }}</h3>
|
|
<div class="stat-value" style="font-size: var(--font-size-lg);">{{ s.views }}</div>
|
|
<div class="metric">{{ s.unique_users }} unikalnych · {{ s.avg_time }}s śr.</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Top pages -->
|
|
<div class="section-card">
|
|
<h2>Top 50 stron</h2>
|
|
<div class="table-scroll">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Ścieżka</th>
|
|
<th style="width: 200px;">Odsłony</th>
|
|
<th>Unikalni</th>
|
|
<th>Śr. czas</th>
|
|
<th>Śr. scroll</th>
|
|
<th>Śr. ładowanie</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for p in data.top_pages %}
|
|
<tr>
|
|
<td style="max-width: 300px; word-break: break-all; font-family: monospace; font-size: var(--font-size-xs);">{{ p.path }}</td>
|
|
<td>
|
|
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
|
|
<div class="bar-container" style="flex: 1;">
|
|
<div class="bar-fill primary" style="width: {{ p.bar_pct }}%;"></div>
|
|
</div>
|
|
<span style="font-weight: 600; min-width: 40px; text-align: right;">{{ p.views }}</span>
|
|
</div>
|
|
</td>
|
|
<td>{{ p.unique_users }}</td>
|
|
<td>{{ p.avg_time }}s</td>
|
|
<td>{{ p.avg_scroll }}%</td>
|
|
<td>
|
|
<span {% if p.avg_load > 3000 %}style="color: var(--error); font-weight: 600;"{% elif p.avg_load > 1500 %}style="color: #d97706;"{% endif %}>
|
|
{{ p.avg_load }}ms
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ignored pages -->
|
|
{% if data.ignored_pages %}
|
|
<div class="section-card">
|
|
<h2>Nieużywane strony <span class="badge badge-low">< 5 odsłon/30d</span></h2>
|
|
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-sm);">
|
|
{% for p in data.ignored_pages %}
|
|
<span style="padding: 4px 10px; background: var(--background); border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-family: monospace; color: var(--text-muted);">
|
|
{{ p.path }} ({{ p.views }})
|
|
</span>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- ============================================================ -->
|
|
<!-- TAB: PATHS -->
|
|
<!-- ============================================================ -->
|
|
{% elif tab == 'paths' %}
|
|
|
|
<div class="two-columns">
|
|
<!-- Entry pages -->
|
|
<div class="section-card">
|
|
<h2>Strony wejściowe (top 10)</h2>
|
|
{% for p in data.entry_pages %}
|
|
<div class="bar-chart-row">
|
|
<div class="bar-chart-label" style="width: 200px; min-width: 200px; font-family: monospace; font-size: var(--font-size-xs); word-break: break-all;">{{ p.path }}</div>
|
|
<div class="bar-chart-bar">
|
|
<div class="bar-chart-fill" style="width: {{ p.bar_pct }}%; background: #22c55e;">
|
|
<span>{{ p.count }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<!-- Exit pages -->
|
|
<div class="section-card">
|
|
<h2>Strony wyjściowe (top 10)</h2>
|
|
{% for p in data.exit_pages %}
|
|
<div class="bar-chart-row">
|
|
<div class="bar-chart-label" style="width: 200px; min-width: 200px; font-family: monospace; font-size: var(--font-size-xs); word-break: break-all;">{{ p.path }}</div>
|
|
<div class="bar-chart-bar">
|
|
<div class="bar-chart-fill" style="width: {{ p.bar_pct }}%; background: #ef4444;">
|
|
<span>{{ p.count }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Top transitions -->
|
|
<div class="section-card">
|
|
<h2>Popularne przejścia (top 30)</h2>
|
|
<div class="table-scroll" style="max-height: 400px;">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Ze strony</th>
|
|
<th style="width: 40px;"></th>
|
|
<th>Na stronę</th>
|
|
<th style="width: 80px;">Liczba</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for t in data.transitions %}
|
|
<tr>
|
|
<td style="font-family: monospace; font-size: var(--font-size-xs);">{{ t.from }}</td>
|
|
<td class="transition-arrow" style="text-align: center;">→</td>
|
|
<td style="font-family: monospace; font-size: var(--font-size-xs);">{{ t.to }}</td>
|
|
<td style="font-weight: 600; text-align: right;">{{ t.count }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="two-columns">
|
|
<!-- Drop-off pages -->
|
|
<div class="section-card">
|
|
<h2>Strony z odpływem</h2>
|
|
<div class="table-scroll">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Ścieżka</th>
|
|
<th>Odsłony</th>
|
|
<th>Wyjścia</th>
|
|
<th>Exit rate</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for d in data.dropoff %}
|
|
<tr>
|
|
<td style="font-family: monospace; font-size: var(--font-size-xs);">{{ d.path }}</td>
|
|
<td>{{ d.views }}</td>
|
|
<td>{{ d.exits }}</td>
|
|
<td>
|
|
<span {% if d.exit_rate > 70 %}style="color: var(--error); font-weight: 600;"{% elif d.exit_rate > 50 %}style="color: #d97706;"{% endif %}>
|
|
{{ d.exit_rate }}%
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Session length distribution -->
|
|
<div class="section-card">
|
|
<h2>Rozkład długości sesji</h2>
|
|
{% for s in data.session_lengths %}
|
|
<div class="bar-chart-row">
|
|
<div class="bar-chart-label">{{ s.bucket }}</div>
|
|
<div class="bar-chart-bar">
|
|
<div class="bar-chart-fill" style="width: {{ s.bar_pct }}%;">
|
|
<span>{{ s.count }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ============================================================ -->
|
|
<!-- TAB: OVERVIEW -->
|
|
<!-- ============================================================ -->
|
|
{% elif tab == 'overview' %}
|
|
|
|
<!-- Filter -->
|
|
<div style="margin-bottom: var(--spacing-lg);">
|
|
<div class="filter-group">
|
|
<a href="{{ url_for('admin.user_insights', tab='overview', period=period, filter='all') }}" class="filter-btn {% if data.filter_type == 'all' %}active{% endif %}">Wszyscy</a>
|
|
<a href="{{ url_for('admin.user_insights', tab='overview', period=period, filter='logged') }}" class="filter-btn {% if data.filter_type == 'logged' %}active{% endif %}">Zalogowani</a>
|
|
<a href="{{ url_for('admin.user_insights', tab='overview', period=period, filter='anonymous') }}" class="filter-btn {% if data.filter_type == 'anonymous' %}active{% endif %}">Anonimowi</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sessions + Page Views chart -->
|
|
<div class="section-card">
|
|
<h2>Sesje i odsłony <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(zawsze ostatnie 30 dni)</small></h2>
|
|
<div class="chart-container">
|
|
<canvas id="sessionsChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="two-columns">
|
|
<!-- Logged vs Anonymous doughnut -->
|
|
<div class="section-card">
|
|
<h2>Zalogowani vs Anonimowi</h2>
|
|
<div class="chart-container" style="height: 250px;">
|
|
<canvas id="authChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Devices stacked bar -->
|
|
<div class="section-card">
|
|
<h2>Urządzenia (tygodniowo)</h2>
|
|
<div class="chart-container" style="height: 250px;">
|
|
<canvas id="devicesChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hourly heatmap -->
|
|
<div class="section-card">
|
|
<h2>Aktywność godzinowa (30 dni)</h2>
|
|
<div style="overflow-x: auto;">
|
|
<table class="heatmap-table">
|
|
<thead>
|
|
<tr>
|
|
<th></th>
|
|
{% for h in range(24) %}
|
|
<th>{{ h }}</th>
|
|
{% endfor %}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for row in data.heatmap %}
|
|
<tr>
|
|
<td class="heatmap-label">{{ row.name }}</td>
|
|
{% for cell in row.hours %}
|
|
<td title="{{ row.name }} {{ loop.index0 }}:00 — {{ cell.count }} sesji">
|
|
<div class="heatmap-cell" style="background: rgba(34, 197, 94, {{ cell.intensity / 100 * 0.8 + 0.05 }}); display: inline-block;"></div>
|
|
</td>
|
|
{% endfor %}
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{% endif %}
|
|
|
|
</div>
|
|
|
|
{% if tab == 'overview' %}
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
{% endif %}
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
{% if tab == 'overview' %}
|
|
// Sessions + Page Views line chart
|
|
const chartData = {{ data.chart_data|tojson|safe }};
|
|
const sessionsCtx = document.getElementById('sessionsChart').getContext('2d');
|
|
new Chart(sessionsCtx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: chartData.labels,
|
|
datasets: [{
|
|
label: 'Sesje',
|
|
data: chartData.sessions,
|
|
borderColor: '#6366f1',
|
|
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
|
fill: true,
|
|
tension: 0.4,
|
|
yAxisID: 'y'
|
|
}, {
|
|
label: 'Odsłony',
|
|
data: chartData.pageviews,
|
|
borderColor: '#22c55e',
|
|
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
|
fill: true,
|
|
tension: 0.4,
|
|
yAxisID: 'y1'
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: { mode: 'index', intersect: false },
|
|
plugins: { legend: { position: 'top' } },
|
|
scales: {
|
|
x: { grid: { display: false } },
|
|
y: { type: 'linear', display: true, position: 'left', beginAtZero: true, title: { display: true, text: 'Sesje' } },
|
|
y1: { type: 'linear', display: true, position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, title: { display: true, text: 'Odsłony' } }
|
|
}
|
|
}
|
|
});
|
|
|
|
// Auth doughnut
|
|
const authData = {{ data.logged_vs_anon|tojson|safe }};
|
|
const authCtx = document.getElementById('authChart').getContext('2d');
|
|
new Chart(authCtx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: ['Zalogowani', 'Anonimowi'],
|
|
datasets: [{
|
|
data: [authData.logged, authData.anonymous],
|
|
backgroundColor: ['#6366f1', '#d1d5db'],
|
|
borderWidth: 0
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { position: 'bottom' },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(ctx) {
|
|
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
|
|
const pct = total > 0 ? Math.round(ctx.raw / total * 100) : 0;
|
|
return ctx.label + ': ' + ctx.raw + ' (' + pct + '%)';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Devices stacked bar
|
|
const devData = {{ data.devices|tojson|safe }};
|
|
const devCtx = document.getElementById('devicesChart').getContext('2d');
|
|
new Chart(devCtx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: devData.labels,
|
|
datasets: [{
|
|
label: 'Desktop',
|
|
data: devData.desktop,
|
|
backgroundColor: '#6366f1'
|
|
}, {
|
|
label: 'Mobile',
|
|
data: devData.mobile,
|
|
backgroundColor: '#22c55e'
|
|
}, {
|
|
label: 'Tablet',
|
|
data: devData.tablet,
|
|
backgroundColor: '#f59e0b'
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { position: 'top' } },
|
|
scales: {
|
|
x: { stacked: true, grid: { display: false } },
|
|
y: { stacked: true, beginAtZero: true }
|
|
}
|
|
}
|
|
});
|
|
{% endif %}
|
|
{% endblock %}
|