feat: split problems into active vs resolved with collapsible resolved section
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

Users who had auth problems (failed logins, password resets, security
alerts) but have since logged in successfully are now shown in a
collapsed "Rozwiązane problemy" section. Active problems remain
prominently displayed at the top.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-22 11:08:41 +01:00
parent ebd1d3fcce
commit 774ce6fca6
2 changed files with 116 additions and 15 deletions

View File

@ -163,9 +163,31 @@ def _tab_problems(db, start_date, days):
PageView.load_time_ms > 3000
).group_by(PageView.user_id).all())
# Problem users — dict lookups instead of per-user queries
# Max timestamps for resolution detection
last_failed_map = dict(db.query(
AuditLog.user_email, func.max(AuditLog.created_at)
).filter(
AuditLog.action == 'login_failed',
AuditLog.created_at >= start_dt
).group_by(AuditLog.user_email).all())
last_reset_map = dict(db.query(
EmailLog.recipient_email, func.max(EmailLog.created_at)
).filter(
EmailLog.email_type == 'password_reset',
EmailLog.created_at >= start_30d
).group_by(EmailLog.recipient_email).all())
last_sec_alert_map = dict(db.query(
SecurityAlert.user_email, func.max(SecurityAlert.created_at)
).filter(
SecurityAlert.created_at >= start_dt
).group_by(SecurityAlert.user_email).all())
# Problem users — split into active vs resolved
users = db.query(User).filter(User.is_active == True).all()
problem_users = []
active_problems = []
resolved_problems = []
for user in users:
fl = failed_logins_map.get(user.email, 0)
@ -185,7 +207,26 @@ def _tab_problems(db, start_date, days):
)
if score > 0:
problem_users.append({
# Check if auth problems resolved by successful login after last incident
auth_times = []
if fl > 0 and user.email in last_failed_map:
auth_times.append(last_failed_map[user.email])
if pr_30d > 0 and user.email in last_reset_map:
auth_times.append(last_reset_map[user.email])
if sa_7d > 0 and user.email in last_sec_alert_map:
auth_times.append(last_sec_alert_map[user.email])
has_auth = fl > 0 or pr_30d > 0 or sa_7d > 0 or is_locked
last_auth_problem = max(auth_times) if auth_times else None
is_resolved = False
resolved_at = None
if has_auth and not is_locked and user.last_login and last_auth_problem:
if user.last_login > last_auth_problem:
is_resolved = True
resolved_at = user.last_login
problem_data = {
'user': user,
'score': score,
'failed_logins': fl,
@ -195,9 +236,16 @@ def _tab_problems(db, start_date, days):
'security_alerts': sa_7d,
'is_locked': is_locked,
'last_login': user.last_login,
})
'resolved_at': resolved_at,
}
problem_users.sort(key=lambda x: x['score'], reverse=True)
if is_resolved:
resolved_problems.append(problem_data)
else:
active_problems.append(problem_data)
active_problems.sort(key=lambda x: x['score'], reverse=True)
resolved_problems.sort(key=lambda x: x['score'], reverse=True)
# Proactive alerts
alerts = []
@ -414,7 +462,8 @@ def _tab_problems(db, start_date, days):
'js_errors': js_errors_total,
'security_alerts': security_alerts_total,
'never_logged_in': never_logged_count,
'problem_users': problem_users[:50],
'active_problems': active_problems[:50],
'resolved_problems': resolved_problems[:50],
'alerts': alerts,
'remediation': remediation,
}
@ -1491,12 +1540,14 @@ def user_insights_export():
if export_type == 'problems':
data = _tab_problems(db, start_date, days)
writer.writerow(['Użytkownik', 'Email', 'Problem Score', 'Nieudane logowania',
'Resety hasła', 'Błędy JS', 'Wolne strony', 'Ostatni login'])
for p in data['problem_users']:
'Resety hasła', 'Błędy JS', 'Wolne strony', 'Ostatni login', 'Status'])
all_problems = data['active_problems'] + data['resolved_problems']
for p in all_problems:
writer.writerow([
p['user'].name, p['user'].email, p['score'],
p['failed_logins'], p['password_resets'], p['js_errors'],
p['slow_pages'], p['last_login'] or 'Nigdy'
p['slow_pages'], p['last_login'] or 'Nigdy',
'Rozwiązany' if p['resolved_at'] else 'Aktywny'
])
elif export_type == 'engagement':

View File

@ -135,6 +135,10 @@
.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); }
/* Resolved section toggle */
details[open] summary span:first-child { transform: rotate(90deg); }
details summary::-webkit-details-marker { display: none; }
/* Responsive */
@media (max-width: 768px) {
.insights-header { flex-direction: column; align-items: flex-start; }
@ -306,14 +310,15 @@
</div>
{% endif %}
<!-- Active problems -->
<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 }}
<h2>Aktywne problemy
<span class="badge {% if data.active_problems|length > 10 %}badge-critical{% elif data.active_problems|length > 0 %}badge-high{% else %}badge-ok{% endif %}">
{{ data.active_problems|length }}
</span>
</h2>
{% if data.problem_users %}
{% if data.active_problems %}
<div class="table-scroll">
<table class="data-table">
<thead>
@ -330,7 +335,7 @@
</tr>
</thead>
<tbody>
{% for p in data.problem_users %}
{% for p in data.active_problems %}
<tr>
<td>
<div class="user-cell">
@ -357,11 +362,56 @@
</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>
<p>Brak aktywnych problemów w wybranym okresie.</p>
</div>
{% endif %}
</div>
<!-- Resolved problems (collapsible) -->
{% if data.resolved_problems %}
<div class="section-card" style="opacity: 0.8;">
<details>
<summary style="cursor: pointer; display: flex; align-items: center; gap: var(--spacing-sm); font-size: var(--font-size-lg); font-weight: 600; padding: var(--spacing-xs) 0; list-style: none;">
<span style="transition: transform 0.2s; display: inline-block;">&#9654;</span>
Rozwiązane problemy
<span class="badge badge-ok">{{ data.resolved_problems|length }}</span>
<span style="font-size: var(--font-size-xs); font-weight: 400; color: var(--text-muted);">— zalogowali się po problemach</span>
</summary>
<div class="table-scroll" style="margin-top: var(--spacing-md);">
<table class="data-table">
<thead>
<tr>
<th>Użytkownik</th>
<th>Było problemów</th>
<th>Nieudane logowania</th>
<th>Resety hasła</th>
<th>Alerty</th>
<th>Rozwiązano</th>
</tr>
</thead>
<tbody>
{% for p in data.resolved_problems %}
<tr>
<td>
<div class="user-cell">
<div class="user-avatar" style="background: var(--success);">{{ 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 badge-ok">{{ p.score }}</span></td>
<td>{{ p.failed_logins }}</td>
<td>{{ p.password_resets }}</td>
<td>{{ p.security_alerts }}</td>
<td><span class="badge badge-active">Zalogowano {{ p.resolved_at.strftime('%d.%m %H:%M') }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
</div>
{% endif %}
<!-- ============================================================ -->
<!-- TAB: ENGAGEMENT -->
<!-- ============================================================ -->