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
Production moved from on-prem VM 249 (10.22.68.249) to OVH VPS (57.128.200.27, inpi-vps-waw01). Updated ALL documentation, slash commands, memory files, architecture docs, and deploy procedures. Added |local_time Jinja filter (UTC→Europe/Warsaw) and converted 155 .strftime() calls across 71 templates so timestamps display in Polish timezone regardless of server timezone. Also includes: created_by_id tracking, abort import fix, ICS calendar fix for missing end times, Pros Poland data cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
484 lines
25 KiB
HTML
484 lines
25 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}{{ user.name or user.email }} - Analityka{% endblock %}
|
||
|
||
{% block head_extra %}
|
||
<script src="{{ url_for('static', filename='js/vendor/chart.min.js') }}"></script>
|
||
{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.profile-container { max-width: 1400px; margin: 0 auto; padding: var(--spacing-lg); }
|
||
|
||
/* Back link */
|
||
.back-link { display: inline-flex; align-items: center; gap: var(--spacing-xs); color: var(--text-secondary); text-decoration: none; font-size: var(--font-size-sm); margin-bottom: var(--spacing-lg); }
|
||
.back-link:hover { color: var(--primary); }
|
||
|
||
/* Profile header */
|
||
.profile-header { background: white; padding: var(--spacing-xl); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); margin-bottom: var(--spacing-xl); display: flex; gap: var(--spacing-xl); align-items: flex-start; flex-wrap: wrap; }
|
||
.profile-info { flex: 1; min-width: 250px; }
|
||
.profile-name { font-size: var(--font-size-2xl); font-weight: 700; margin-bottom: var(--spacing-xs); }
|
||
.profile-email { color: var(--text-secondary); font-size: var(--font-size-sm); margin-bottom: var(--spacing-md); }
|
||
.profile-meta { display: flex; gap: var(--spacing-lg); flex-wrap: wrap; font-size: var(--font-size-sm); color: var(--text-secondary); }
|
||
.profile-meta-item { display: flex; flex-direction: column; gap: 2px; }
|
||
.profile-meta-label { font-size: var(--font-size-xs); text-transform: uppercase; letter-spacing: 0.5px; }
|
||
.profile-meta-value { font-weight: 600; color: var(--text-primary); }
|
||
|
||
/* Gauges row */
|
||
.gauges-row { display: flex; gap: var(--spacing-xl); }
|
||
.gauge-wrapper { text-align: center; }
|
||
.gauge { position: relative; width: 120px; height: 60px; overflow: hidden; }
|
||
.gauge-bg { position: absolute; width: 120px; height: 120px; border-radius: 50%; border: 10px solid var(--border); border-bottom-color: transparent; border-left-color: transparent; transform: rotate(225deg); }
|
||
.gauge-fill { position: absolute; width: 120px; height: 120px; border-radius: 50%; border: 10px solid; border-bottom-color: transparent; border-left-color: transparent; transition: transform 0.8s ease-out; }
|
||
.gauge-fill.low { border-top-color: #22c55e; border-right-color: #22c55e; }
|
||
.gauge-fill.medium { border-top-color: #f59e0b; border-right-color: #f59e0b; }
|
||
.gauge-fill.high { border-top-color: #ef4444; border-right-color: #ef4444; }
|
||
.gauge-value { position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); font-size: var(--font-size-xl); font-weight: 700; }
|
||
.gauge-label { font-size: var(--font-size-xs); color: var(--text-secondary); 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); }
|
||
|
||
/* Two columns */
|
||
.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-xl); }
|
||
@media (max-width: 1024px) { .two-columns { grid-template-columns: 1fr; } }
|
||
|
||
/* Three columns */
|
||
.three-columns { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--spacing-xl); }
|
||
@media (max-width: 1024px) { .three-columns { grid-template-columns: 1fr; } }
|
||
|
||
/* Timeline */
|
||
.timeline { position: relative; padding-left: var(--spacing-xl); }
|
||
.timeline::before { content: ''; position: absolute; left: 12px; top: 0; bottom: 0; width: 2px; background: var(--border); }
|
||
.timeline-item { position: relative; padding-bottom: var(--spacing-md); display: flex; gap: var(--spacing-md); align-items: flex-start; }
|
||
.timeline-dot { width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-left: -30px; z-index: 1; font-size: 10px; }
|
||
.timeline-dot.login { background: #dbeafe; color: #2563eb; }
|
||
.timeline-dot.pageview { background: #dcfce7; color: #16a34a; }
|
||
.timeline-dot.search { background: #fef3c7; color: #d97706; }
|
||
.timeline-dot.conversion { background: #ede9fe; color: #7c3aed; }
|
||
.timeline-dot.problem { background: #fee2e2; color: #dc2626; }
|
||
.timeline-dot.email { background: #fef3c7; color: #d97706; }
|
||
.timeline-dot.info { background: #f3f4f6; color: #6b7280; }
|
||
.timeline-body { flex: 1; }
|
||
.timeline-desc { font-size: var(--font-size-sm); }
|
||
.timeline-desc.css-danger { color: #dc2626; font-weight: 600; }
|
||
.timeline-desc.css-warning { color: #d97706; }
|
||
.timeline-desc.css-success { color: #16a34a; }
|
||
.timeline-detail { font-size: var(--font-size-xs); color: var(--text-muted); margin-top: 1px; }
|
||
.timeline-time { font-size: var(--font-size-xs); color: var(--text-muted); }
|
||
|
||
/* Resolution status */
|
||
.resolution-card { padding: var(--spacing-lg); border-radius: var(--radius-lg); margin-bottom: var(--spacing-xl); }
|
||
.resolution-card.resolved { background: #f0fdf4; border: 1px solid #86efac; }
|
||
.resolution-card.pending { background: #fffbeb; border: 1px solid #fcd34d; }
|
||
.resolution-card.blocked { background: #fef2f2; border: 1px solid #fca5a5; }
|
||
.resolution-card.unresolved { background: #fef2f2; border: 1px solid #fca5a5; }
|
||
.resolution-card.unknown { background: #f9fafb; border: 1px solid #e5e7eb; }
|
||
.resolution-header { display: flex; align-items: center; gap: var(--spacing-md); margin-bottom: var(--spacing-md); }
|
||
.resolution-status { font-size: var(--font-size-lg); font-weight: 700; }
|
||
.resolution-card.resolved .resolution-status { color: #16a34a; }
|
||
.resolution-card.pending .resolution-status { color: #d97706; }
|
||
.resolution-card.blocked .resolution-status, .resolution-card.unresolved .resolution-status { color: #dc2626; }
|
||
.resolution-details { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--spacing-md); }
|
||
.resolution-detail-item { font-size: var(--font-size-sm); }
|
||
.resolution-detail-label { font-size: var(--font-size-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; }
|
||
.resolution-detail-value { font-weight: 600; margin-top: 2px; }
|
||
|
||
/* Bars */
|
||
.bar-row { display: flex; align-items: center; gap: var(--spacing-sm); margin-bottom: var(--spacing-sm); }
|
||
.bar-label { width: 200px; min-width: 200px; font-size: var(--font-size-xs); font-family: monospace; word-break: break-all; color: var(--text-secondary); }
|
||
.bar-track { flex: 1; height: 20px; background: var(--background); border-radius: var(--radius-sm); overflow: hidden; }
|
||
.bar-fill { height: 100%; border-radius: var(--radius-sm); background: var(--primary); }
|
||
.bar-value { font-size: var(--font-size-xs); font-weight: 600; min-width: 30px; text-align: right; }
|
||
|
||
/* Hourly bars */
|
||
.hourly-bars { display: flex; align-items: flex-end; gap: 2px; height: 60px; }
|
||
.hourly-bar { flex: 1; background: var(--primary); border-radius: 2px 2px 0 0; min-width: 8px; opacity: 0.7; position: relative; }
|
||
.hourly-bar:hover { opacity: 1; }
|
||
.hourly-labels { display: flex; gap: 2px; }
|
||
.hourly-label { flex: 1; text-align: center; font-size: 9px; color: var(--text-muted); min-width: 8px; }
|
||
|
||
/* 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); }
|
||
.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; }
|
||
|
||
/* Stats grid */
|
||
.stats-mini { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--spacing-md); margin-bottom: var(--spacing-lg); }
|
||
.stat-mini { text-align: center; padding: var(--spacing-md); background: var(--background); border-radius: var(--radius); }
|
||
.stat-mini-value { font-size: var(--font-size-xl); font-weight: 700; }
|
||
.stat-mini-label { font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 2px; }
|
||
|
||
/* Chart */
|
||
.chart-container { position: relative; height: 250px; }
|
||
|
||
/* Reset password button */
|
||
.btn-reset-pw { padding: 4px 10px; background: white; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--font-size-xs); cursor: pointer; white-space: nowrap; transition: var(--transition); vertical-align: middle; }
|
||
.btn-reset-pw:hover { background: var(--background); border-color: var(--primary); }
|
||
.btn-reset-pw.sent { background: #dcfce7; color: #166534; border-color: #86efac; cursor: default; }
|
||
.btn-reset-pw.error { background: #fef2f2; color: #991b1b; border-color: #fca5a5; }
|
||
|
||
@media (max-width: 768px) {
|
||
.profile-header { flex-direction: column; }
|
||
.gauges-row { flex-direction: row; gap: var(--spacing-md); }
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="profile-container">
|
||
|
||
<a href="{{ url_for('admin.user_insights', tab=ref_tab, period=ref_period) }}" class="back-link">
|
||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||
Powrót do Analityki
|
||
</a>
|
||
|
||
<!-- Profile Header -->
|
||
<div class="profile-header">
|
||
<div class="profile-info">
|
||
<div class="profile-name">{{ user.name or 'Bez nazwy' }}</div>
|
||
<div class="profile-email">
|
||
{{ user.email }}
|
||
<button class="btn-reset-pw" data-uid="{{ user.id }}" data-name="{{ user.name or user.email }}" data-email="{{ user.email }}" style="margin-left: 12px;">📧 Wyślij reset hasła</button>
|
||
</div>
|
||
<div class="profile-meta">
|
||
{% if user.company %}
|
||
<div class="profile-meta-item">
|
||
<span class="profile-meta-label">Firma</span>
|
||
<span class="profile-meta-value">{{ user.company.name }}</span>
|
||
</div>
|
||
{% endif %}
|
||
<div class="profile-meta-item">
|
||
<span class="profile-meta-label">Rola</span>
|
||
<span class="profile-meta-value">{{ user.role }}</span>
|
||
</div>
|
||
<div class="profile-meta-item">
|
||
<span class="profile-meta-label">Rejestracja</span>
|
||
<span class="profile-meta-value">{{ user.created_at|local_time('%d.%m.%Y') if user.created_at else 'N/A' }}</span>
|
||
</div>
|
||
<div class="profile-meta-item">
|
||
<span class="profile-meta-label">Ostatni login</span>
|
||
<span class="profile-meta-value">{{ user.last_login|local_time('%d.%m.%Y %H:%M') if user.last_login else 'Nigdy' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="gauges-row">
|
||
<!-- Engagement gauge -->
|
||
<div class="gauge-wrapper">
|
||
<div class="gauge">
|
||
<div class="gauge-bg"></div>
|
||
{% set eng_class = 'low' if engagement_score >= 50 else ('medium' if engagement_score >= 20 else 'high') %}
|
||
<div class="gauge-fill {{ eng_class }}" style="transform: rotate({{ 225 + (engagement_score / 100 * 180) }}deg);"></div>
|
||
<div class="gauge-value">{{ engagement_score }}</div>
|
||
</div>
|
||
<div class="gauge-label">Zaangażowanie</div>
|
||
</div>
|
||
|
||
<!-- Problem gauge -->
|
||
<div class="gauge-wrapper">
|
||
<div class="gauge">
|
||
<div class="gauge-bg"></div>
|
||
{% set prob_class = 'high' if problem_score >= 51 else ('medium' if problem_score >= 21 else 'low') %}
|
||
<div class="gauge-fill {{ prob_class }}" style="transform: rotate({{ 225 + (problem_score / 100 * 180) }}deg);"></div>
|
||
<div class="gauge-value">{{ problem_score }}</div>
|
||
</div>
|
||
<div class="gauge-label">Problemy</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quick stats -->
|
||
<div class="stats-mini">
|
||
<div class="stat-mini">
|
||
<div class="stat-mini-value">{{ avg_sessions_week }}</div>
|
||
<div class="stat-mini-label">Śr. sesji/tydzień</div>
|
||
</div>
|
||
<div class="stat-mini">
|
||
<div class="stat-mini-value">{{ (avg_session_duration / 60)|round(1) }}m</div>
|
||
<div class="stat-mini-label">Śr. czas sesji</div>
|
||
</div>
|
||
<div class="stat-mini">
|
||
<div class="stat-mini-value">{{ password_resets }}</div>
|
||
<div class="stat-mini-label">Resety hasła (30d)</div>
|
||
</div>
|
||
<div class="stat-mini">
|
||
<div class="stat-mini-value">{{ security_alerts_count }}</div>
|
||
<div class="stat-mini-label">Alerty (7d)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Resolution status -->
|
||
{% if resolution %}
|
||
<div class="resolution-card {{ resolution.status }}">
|
||
<div class="resolution-header">
|
||
{% if resolution.status == 'resolved' %}✅{% elif resolution.status == 'pending' %}⏳{% elif resolution.status == 'blocked' %}🔒{% elif resolution.status == 'unresolved' %}❌{% else %}❓{% endif %}
|
||
<div class="resolution-status">{{ resolution.status_label }}</div>
|
||
</div>
|
||
<div class="resolution-details">
|
||
{% if resolution.first_symptom %}
|
||
<div class="resolution-detail-item">
|
||
<div class="resolution-detail-label">Pierwszy objaw</div>
|
||
<div class="resolution-detail-value">{{ resolution.first_symptom|local_time('%d.%m.%Y %H:%M') }}</div>
|
||
</div>
|
||
{% endif %}
|
||
<div class="resolution-detail-item">
|
||
<div class="resolution-detail-label">Resety hasła wysłane</div>
|
||
<div class="resolution-detail-value">{{ resolution.resets_sent }}</div>
|
||
</div>
|
||
{% if resolution.last_reset %}
|
||
<div class="resolution-detail-item">
|
||
<div class="resolution-detail-label">Ostatni reset</div>
|
||
<div class="resolution-detail-value">{{ resolution.last_reset|local_time('%d.%m.%Y %H:%M') }}</div>
|
||
</div>
|
||
{% endif %}
|
||
<div class="resolution-detail-item">
|
||
<div class="resolution-detail-label">Zalogowano po resecie</div>
|
||
<div class="resolution-detail-value">{{ 'Tak' if resolution.login_after_reset else 'Nie' }}</div>
|
||
</div>
|
||
<div class="resolution-detail-item">
|
||
<div class="resolution-detail-label">Aktywny token</div>
|
||
<div class="resolution-detail-value">{{ 'Tak' if resolution.has_active_token else 'Nie' }}</div>
|
||
</div>
|
||
<div class="resolution-detail-item">
|
||
<div class="resolution-detail-label">Email powitalny</div>
|
||
<div class="resolution-detail-value">{{ 'Tak' if resolution.has_welcome_email else 'NIE WYSŁANO' }}</div>
|
||
</div>
|
||
{% if resolution.duration %}
|
||
<div class="resolution-detail-item">
|
||
<div class="resolution-detail-label">Czas do rozwiązania</div>
|
||
<div class="resolution-detail-value">{{ resolution.duration }}</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="two-columns">
|
||
<!-- Timeline -->
|
||
<div class="section-card">
|
||
<h2>Oś czasu aktywności</h2>
|
||
{% if timeline %}
|
||
{% if timeline|length >= 150 %}
|
||
<p style="color: var(--text-muted); text-align: center; font-size: 0.8rem; margin-bottom: var(--spacing-sm);">
|
||
Wyświetlono ostatnie 150 zdarzeń. Starsze zdarzenia są ukryte.
|
||
</p>
|
||
{% endif %}
|
||
<div class="timeline" style="max-height: 600px; overflow-y: auto;">
|
||
{% for event in timeline %}
|
||
<div class="timeline-item">
|
||
<div class="timeline-dot {{ event.type }}">
|
||
{% if event.icon == 'key' %}🔑{% elif event.icon == 'eye' %}👁{% elif event.icon == 'search' %}🔍{% elif event.icon == 'check' %}✓{% elif event.icon == 'alert' %}⚠{% elif event.icon == 'shield' %}🛡{% elif event.icon == 'x' %}✗{% elif event.icon == 'mail' %}📧{% elif event.icon == 'monitor' %}🖥{% elif event.icon == 'logout' %}🚪{% elif event.icon == 'user' %}👤{% elif event.icon == 'info' %}ℹ{% else %}•{% endif %}
|
||
</div>
|
||
<div class="timeline-body">
|
||
<div class="timeline-desc {% if event.css == 'danger' %}css-danger{% elif event.css == 'warning' %}css-warning{% elif event.css == 'success' %}css-success{% endif %}">{{ event.desc }}</div>
|
||
{% if event.detail %}<div class="timeline-detail">{{ event.detail }}</div>{% endif %}
|
||
<div class="timeline-time">{{ event.time|local_time('%d.%m.%Y %H:%M') }}</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<p style="color: var(--text-muted); text-align: center;">Brak zdarzeń.</p>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- Behavioral stats -->
|
||
<div>
|
||
<!-- Favorite pages -->
|
||
<div class="section-card">
|
||
<h2>Ulubione strony (30d)</h2>
|
||
{% for p in fav_pages %}
|
||
<div class="bar-row">
|
||
<div class="bar-label">{{ p.path }}</div>
|
||
<div class="bar-track">
|
||
<div class="bar-fill" style="width: {{ p.bar_pct }}%;"></div>
|
||
</div>
|
||
<div class="bar-value">{{ p.count }}</div>
|
||
</div>
|
||
{% endfor %}
|
||
{% if not fav_pages %}
|
||
<p style="color: var(--text-muted); text-align: center;">Brak danych.</p>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- Hourly pattern -->
|
||
<div class="section-card">
|
||
<h2>Wzorzec godzinowy (30d)</h2>
|
||
<div class="hourly-bars">
|
||
{% set max_h = hourly_bars|map(attribute='count')|max if hourly_bars|map(attribute='count')|max > 0 else 1 %}
|
||
{% for h in hourly_bars %}
|
||
<div class="hourly-bar" style="height: {{ (h.count / max_h * 55 + 5)|int }}px;" title="{{ h.hour }}:00 — {{ h.count }}"></div>
|
||
{% endfor %}
|
||
</div>
|
||
<div class="hourly-labels">
|
||
{% for h in hourly_bars %}
|
||
<div class="hourly-label">{% if h.hour % 3 == 0 %}{{ h.hour }}{% endif %}</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Devices & Browsers -->
|
||
<div class="section-card">
|
||
<h2>Urządzenia i przeglądarki</h2>
|
||
<div style="display: flex; gap: var(--spacing-xl); flex-wrap: wrap;">
|
||
<div style="flex: 1; min-width: 120px;">
|
||
<strong style="font-size: var(--font-size-xs); color: var(--text-secondary);">URZĄDZENIA</strong>
|
||
{% for d in devices %}
|
||
<div style="margin-top: var(--spacing-sm); font-size: var(--font-size-sm);">
|
||
{{ d.type|capitalize }}: <strong>{{ d.count }}</strong>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
<div style="flex: 1; min-width: 120px;">
|
||
<strong style="font-size: var(--font-size-xs); color: var(--text-secondary);">PRZEGLĄDARKI</strong>
|
||
{% for b in browsers %}
|
||
<div style="margin-top: var(--spacing-sm); font-size: var(--font-size-sm);">
|
||
{{ b.name }}: <strong>{{ b.count }}</strong>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Problem history -->
|
||
<div class="section-card">
|
||
<h2>Historia problemów</h2>
|
||
<div class="two-columns">
|
||
<div>
|
||
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin-bottom: var(--spacing-md);">Błędy JS</h3>
|
||
{% if js_errors %}
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr><th>Wiadomość</th><th>Strona</th><th>Data</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for e in js_errors %}
|
||
<tr>
|
||
<td style="max-width: 300px; word-break: break-all; font-size: var(--font-size-xs);">{{ e.message[:100] }}</td>
|
||
<td style="font-size: var(--font-size-xs);">{{ e.url[:50] if e.url else 'N/A' }}</td>
|
||
<td style="white-space: nowrap;">{{ e.occurred_at|local_time('%d.%m %H:%M') }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% else %}
|
||
<p style="color: var(--text-muted);">Brak błędów JS.</p>
|
||
{% endif %}
|
||
</div>
|
||
<div>
|
||
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin-bottom: var(--spacing-md);">Wolne strony (> 3s)</h3>
|
||
{% if slow_pages %}
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr><th>Strona</th><th>Czas</th><th>Data</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for p in slow_pages %}
|
||
<tr>
|
||
<td style="font-family: monospace; font-size: var(--font-size-xs);">{{ p.path }}</td>
|
||
<td style="color: var(--error); font-weight: 600;">{{ p.load_time_ms }}ms</td>
|
||
<td style="white-space: nowrap;">{{ p.viewed_at|local_time('%d.%m %H:%M') }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% else %}
|
||
<p style="color: var(--text-muted);">Brak wolnych stron.</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Engagement trend chart -->
|
||
<div class="section-card">
|
||
<h2>Trend zaangażowania (30 dni)</h2>
|
||
<div class="chart-container">
|
||
<canvas id="trendChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Search queries -->
|
||
{% if search_queries %}
|
||
<div class="section-card">
|
||
<h2>Ostatnie wyszukiwania</h2>
|
||
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-sm);">
|
||
{% for s in search_queries %}
|
||
<span style="padding: 4px 12px; background: var(--background); border-radius: var(--radius); font-size: var(--font-size-sm);">
|
||
"{{ s.query }}" <span style="color: var(--text-muted); font-size: var(--font-size-xs);">{{ s.searched_at|local_time('%d.%m') }}</span>
|
||
</span>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
</div>
|
||
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
const trendData = {{ trend_data|tojson|safe }};
|
||
const trendCtx = document.getElementById('trendChart').getContext('2d');
|
||
new Chart(trendCtx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: trendData.labels,
|
||
datasets: [{
|
||
label: 'Dzienny score',
|
||
data: trendData.scores,
|
||
borderColor: '#6366f1',
|
||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||
fill: true,
|
||
tension: 0.4,
|
||
pointRadius: 2
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: { legend: { display: false } },
|
||
scales: {
|
||
x: { grid: { display: false } },
|
||
y: { beginAtZero: true, suggestedMax: Math.max(30, ...trendData.scores) + 5 }
|
||
}
|
||
}
|
||
});
|
||
|
||
// Password reset button
|
||
document.querySelectorAll('.btn-reset-pw').forEach(function(btn) {
|
||
btn.addEventListener('click', async function() {
|
||
const uid = this.dataset.uid;
|
||
const name = this.dataset.name;
|
||
const email = this.dataset.email;
|
||
if (!confirm('Wysłać reset hasła do ' + name + ' (' + email + ')?')) return;
|
||
|
||
this.disabled = true;
|
||
this.textContent = '⏳...';
|
||
try {
|
||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||
const resp = await fetch('/admin/analytics/send-reset/' + uid, {
|
||
method: 'POST',
|
||
headers: {'X-CSRFToken': csrfToken}
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
this.textContent = '✓ Wysłano';
|
||
this.classList.add('sent');
|
||
} else {
|
||
this.textContent = '✗ ' + (data.error || 'Błąd');
|
||
this.classList.add('error');
|
||
this.disabled = false;
|
||
}
|
||
} catch (e) {
|
||
this.textContent = '✗ Błąd';
|
||
this.classList.add('error');
|
||
this.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
{% endblock %}
|