Files
meshcore-analyzer/internal/users/stats.go
T
efitenandClaude Opus 5.5 18a634c115 feat: admin dashboard with overview and audit log (user management part C) (#2138)
Part C of #2128: an admin area with an overview, the user table and a
global audit log. Off by default like the rest of user management: with
`userManagement` off nothing changes, and non-admins get no new UI.

This is the first of 4 stacked PRs (C, D, E, then a small export/backup
follow-up). Each later one contains this branch; review them in order.

## The situation

- An admin could manage users one by one in `#/admin/users`, but nothing
showed whether the instance needs attention: accounts stuck in
activation, bouncing mail, someone guessing a password, an MQTT source
that dropped.
- The audit log existed (`internal/users/audit.go`) but could only be
read per user, and logins were not recorded.

## What this PR adds

**Store (`internal/users`)**
- Schema v3: an index on `audit_log(at)`.
- `AuditList` with filters (action or group prefix like `user.login.*`,
user as actor or target, period) and keyset pagination; `PruneAudit`;
`Stats` for the user figures.

**Server**
- `GET /api/admin/stats` (typed struct): accounts by status, admins,
registrations and active users over 7 and 30 days, logins and failed
logins in 24 hours, mail by final status, and the attention items
computed server-side.
- `GET /api/admin/audit`: filtered, newest first, `next` cursor.
- `GET /api/admin/users` gains `bouncing=1`.
- Logins are audited as `user.login` and `user.login.failed` (reason
`wrong_password`, `pending` or `disabled`). An unknown address writes no
row. The writes are asynchronous, so the login response does not wait on
`users.db`. Login rows are pruned after 90 days.

**Frontend**
- `#/admin?tab=overview|users|audit`: `admin.js` (tab shell),
`admin-overview.js` ("Needs attention", Users card, System card from the
existing health, MQTT and observer endpoints), `admin-audit.js` (filters
in the URL, "Load more"). `admin-users.js` becomes the Users tab; the
old `#/admin/users` link rewrites to it.
- `/api/healthz` is read on open and on Refresh only, not on the
60-second timer, because it walks every packet under a read lock.

Spec:
[`docs/specs/2026-10-07-admin-dashboard-design.md`](https://github.com/efiten/CoreScope/blob/feat/admin-dashboard/docs/specs/2026-10-07-admin-dashboard-design.md).

## Verification

- `internal/users` and `cmd/server`: `go vet` and `go test` pass
locally, 26 new Go tests. One upstream test,
`TestSaveGeoFilterPreservesFileMode`, also fails on Windows on plain
`master` (file modes) and is unrelated.
- `sh test-all.sh` exits 0; the XSS gate in diff mode passes.
- User-management E2E with the `e2etest` build: 13 of 13 steps locally.
- Running on our staging and production instance since 7 October 2026.

## Not in this PR

- Restricting existing pages (perf, MQTT status) to logged-in users or
admins. The admin area adds no access mechanism of its own, so that
stays a later router rule plus endpoint check.
- Server-enforced customizer tab restrictions (#1508).
- The 24-hour, 5-attempt and 10-minute attention thresholds are
constants for now (AGENTS.md rule 8: customizer later).

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:38:30 +02:00

186 lines
6.1 KiB
Go

package users
import "time"
// Admin overview thresholds (docs/specs/2026-10-07-admin-dashboard-design.md).
// Constants in this version; they belong in config later (AGENTS.md rule 8).
const (
StuckPendingAfter = 24 * time.Hour // a pending account this old counts as stuck
GuessingWindow = 24 * time.Hour
GuessingThreshold = 5 // failed logins on one account inside GuessingWindow
statsPerDayDays = 30
statsGuessingLimit = 50
)
// DayCount is the count for one UTC day; Day is YYYY-MM-DD.
type DayCount struct {
Day string
Count int
}
// MailCounts buckets mails by their latest delivery event.
type MailCounts struct {
Delivered int // delivered, opened, clicked, unsubscribed
Bounced int // hard_bounce, soft_bounce, invalid_email
Blocked int
Spam int
Pending int // sent or deferred: no final event yet
Other int // error, or an event this version does not know
}
// GuessedAccount is an account with at least GuessingThreshold failed
// logins inside GuessingWindow.
type GuessedAccount struct {
UserID int64
DisplayName string // empty when the account no longer exists
Failed int
}
// Stats holds the figures of the admin overview's Users card and its
// attention rules.
type Stats struct {
Total, Active, Pending, Disabled int
Admins int // active admins
StuckPending int // pending for longer than StuckPendingAfter
Bouncing int
New7d, New30d int // user.register audit rows
NewPerDay []DayCount // statsPerDayDays entries, oldest first, the last is today (UTC)
Active7d, Active30d int // last login or session activity inside the window
Logins24h, FailedLogins24h int
Mail7d MailCounts
Guessing []GuessedAccount // most failures first, never nil
}
func secs(d time.Duration) int64 { return int64(d / time.Second) }
// Stats computes the overview figures at now: a handful of COUNT queries
// on users.db.
func (s *Store) Stats(now time.Time) (Stats, error) {
const day = 24 * time.Hour
t := unix(now)
var st Stats
if err := s.db.QueryRow(`SELECT COUNT(*),
COALESCE(SUM(status = 'active'), 0),
COALESCE(SUM(status = 'pending'), 0),
COALESCE(SUM(status = 'disabled'), 0),
COALESCE(SUM(role = 'admin' AND status = 'active'), 0),
COALESCE(SUM(status = 'pending' AND created_at < ?), 0),
COALESCE(SUM(email_bouncing != 0), 0)
FROM users`, t-secs(StuckPendingAfter)).
Scan(&st.Total, &st.Active, &st.Pending, &st.Disabled, &st.Admins, &st.StuckPending, &st.Bouncing); err != nil {
return Stats{}, err
}
if err := s.db.QueryRow(`SELECT COALESCE(SUM(at >= ?), 0), COUNT(*) FROM audit_log
WHERE action = 'user.register' AND at >= ? AND at <= ?`, t-secs(7*day), t-secs(30*day), t).
Scan(&st.New7d, &st.New30d); err != nil {
return Stats{}, err
}
perDay, err := s.registrationsPerDay(now)
if err != nil {
return Stats{}, err
}
st.NewPerDay = perDay
if err := s.db.QueryRow(`SELECT COALESCE(SUM(last >= ?), 0), COUNT(*) FROM (
SELECT MAX(COALESCE(u.last_login_at, 0),
COALESCE((SELECT MAX(last_seen_at) FROM sessions s WHERE s.user_id = u.id), 0)) AS last
FROM users u
) WHERE last >= ?`, t-secs(7*day), t-secs(30*day)).
Scan(&st.Active7d, &st.Active30d); err != nil {
return Stats{}, err
}
if err := s.db.QueryRow(`SELECT COALESCE(SUM(action = 'user.login'), 0), COALESCE(SUM(action = 'user.login.failed'), 0)
FROM audit_log WHERE action IN ('user.login', 'user.login.failed') AND at >= ?`, t-secs(day)).
Scan(&st.Logins24h, &st.FailedLogins24h); err != nil {
return Stats{}, err
}
if st.Mail7d, err = s.mailCounts(t - secs(7*day)); err != nil {
return Stats{}, err
}
if st.Guessing, err = s.guessedAccounts(t - secs(GuessingWindow)); err != nil {
return Stats{}, err
}
return st, nil
}
// registrationsPerDay counts user.register rows per UTC day for the last
// statsPerDayDays days, today included.
func (s *Store) registrationsPerDay(now time.Time) ([]DayCount, error) {
first := now.UTC().Truncate(24*time.Hour).AddDate(0, 0, -(statsPerDayDays - 1))
rows, err := s.db.Query(`SELECT (at - ?) / 86400, COUNT(*) FROM audit_log
WHERE action = 'user.register' AND at >= ? AND at <= ? GROUP BY 1`, unix(first), unix(first), unix(now))
if err != nil {
return nil, err
}
defer rows.Close()
counts := make([]int, statsPerDayDays)
for rows.Next() {
var d int64
var n int
if err := rows.Scan(&d, &n); err != nil {
return nil, err
}
counts[d] = n
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]DayCount, statsPerDayDays)
for i, n := range counts {
out[i] = DayCount{Day: first.AddDate(0, 0, i).Format("2006-01-02"), Count: n}
}
return out, nil
}
func (s *Store) mailCounts(since int64) (MailCounts, error) {
var mc MailCounts
rows, err := s.db.Query(`SELECT last_event, COUNT(*) FROM mail_log WHERE sent_at >= ? GROUP BY last_event`, since)
if err != nil {
return mc, err
}
defer rows.Close()
for rows.Next() {
var ev string
var n int
if err := rows.Scan(&ev, &n); err != nil {
return mc, err
}
switch ev {
case "delivered", "opened", "clicked", "unsubscribed":
mc.Delivered += n
case "hard_bounce", "soft_bounce", "invalid_email":
mc.Bounced += n
case "blocked":
mc.Blocked += n
case "spam":
mc.Spam += n
case "sent", "deferred":
mc.Pending += n
default:
mc.Other += n
}
}
return mc, rows.Err()
}
func (s *Store) guessedAccounts(since int64) ([]GuessedAccount, error) {
rows, err := s.db.Query(`SELECT a.target_user_id, COALESCE(u.display_name, ''), COUNT(*) AS n
FROM audit_log a LEFT JOIN users u ON u.id = a.target_user_id
WHERE a.action = 'user.login.failed' AND a.at >= ? AND a.target_user_id IS NOT NULL
GROUP BY a.target_user_id HAVING n >= ? ORDER BY n DESC, a.target_user_id LIMIT ?`,
since, GuessingThreshold, statsGuessingLimit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []GuessedAccount{}
for rows.Next() {
var g GuessedAccount
if err := rows.Scan(&g.UserID, &g.DisplayName, &g.Failed); err != nil {
return nil, err
}
out = append(out, g)
}
return out, rows.Err()
}