Files
meshcore-analyzer/public/admin-overview.js
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

219 lines
10 KiB
JavaScript

/* Overview tab of #/admin (docs/specs/2026-10-07-admin-dashboard-design.md):
* "Needs attention", the Users card (GET /api/admin/stats) and the System
* card (the public /api/health, /api/healthz, /api/mqtt/status and
* /api/observers). Each source renders on its own, so one that fails shows
* "Could not load" in its own block only. Refreshes on mount, on Refresh,
* and every 60 s while the browser tab is visible. Mounted by admin.js
* after its admin check. Every dynamic value goes through escapeHtml. */
(function () {
'use strict';
// Constants in this version; config or the customizer later (AGENTS.md
// rule 8). The 24-hour and 5-attempt rules are server-side
// (internal/users/stats.go).
var MQTT_STALE_MS = 10 * 60 * 1000;
var REFRESH_MS = 60 * 1000;
var SOURCES = [
['stats', '/api/admin/stats'], ['health', '/api/health'], ['healthz', '/api/healthz'],
['mqtt', '/api/mqtt/status'], ['observers', '/api/observers']
];
var state = { timer: null, seq: 0, healthz: null, busy: null };
function plural(n, one, many) { return n + ' ' + (n === 1 ? one : many); }
// Down: not connected, never delivered a message, or silent too long.
function mqttDown(src, nowMs) {
return !src.connected || !src.lastPacketUnix || nowMs - src.lastPacketUnix * 1000 > MQTT_STALE_MS;
}
function attentionItems(res, nowMs) {
var items = [];
var s = res.stats && res.stats.data;
if (s) {
if (s.stuckPending > 0) {
items.push({ text: plural(s.stuckPending, 'account', 'accounts') + ' pending for more than 24 hours', href: '#/admin?tab=users&status=pending' });
}
if (s.bouncing > 0) {
items.push({ text: plural(s.bouncing, 'address bounces', 'addresses bounce') + ' mail', href: '#/admin?tab=users&bouncing=1' });
}
(s.guessing || []).forEach(function (g) {
items.push({ text: plural(g.failed, 'failed login', 'failed logins') + ' in 24 hours for ' + (g.displayName || '#' + g.userId),
href: '#/admin?tab=audit&action=user.login.failed&user=' + g.userId });
});
}
var m = res.mqtt && res.mqtt.data;
if (m) {
(m.sources || []).forEach(function (src) {
if (!mqttDown(src, nowMs)) return;
items.push({ text: 'MQTT source ' + src.name + (src.connected ? ' has no message in the last 10 minutes' : ' is not connected'), href: '#/observers' });
});
}
return items;
}
function attentionHtml(items) {
if (!items.length) return '';
var html = '<section class="admin-card admin-attention" id="adminAttention" aria-labelledby="aoAttnH"><h3 id="aoAttnH">Needs attention</h3><ul>';
items.forEach(function (it) { html += '<li><a href="' + escapeHtml(it.href) + '">' + escapeHtml(it.text) + '</a></li>'; });
return html + '</ul></section>';
}
function failedHtml(what) {
return '<p class="account-msg err">Could not load ' + escapeHtml(what) +
'. <button type="button" class="account-btn account-btn-secondary" data-act="retry">Retry</button></p>';
}
function stat(key, label, value) {
return '<div class="admin-stat"><dt>' + escapeHtml(label) + '</dt><dd data-stat="' + escapeHtml(key) + '">' +
escapeHtml(value == null ? '-' : String(value)) + '</dd></div>';
}
function barsHtml(days) {
var max = 1, total = 0;
days.forEach(function (d) { var n = Number(d.count) || 0; total += n; if (n > max) max = n; });
var html = '<div class="admin-bars" role="img" aria-label="' +
escapeHtml('Registrations per day, last ' + days.length + ' days: ' + total + ' in total') + '">';
days.forEach(function (d) {
var n = Number(d.count) || 0;
html += '<span class="admin-bar" style="height:' + Math.round(n / max * 100) + '%" title="' + escapeHtml(d.day + ': ' + n) + '"></span>';
});
return html + '</div>';
}
function usersCardHtml(r) {
var head = '<section class="admin-card" id="aoUsers" aria-labelledby="aoUsersH"><h3 id="aoUsersH">Users</h3>';
if (!r || r.error) return head + failedHtml('user figures') + '</section>';
var s = r.data, m = s.mail7d || {};
return head + '<dl class="admin-stats">' +
stat('total', 'Accounts', s.total) + stat('active', 'Active', s.active) + stat('pending', 'Pending', s.pending) +
stat('disabled', 'Disabled', s.disabled) + stat('admins', 'Admins', s.admins) +
stat('new7d', 'New, 7 days', s.new7d) + stat('new30d', 'New, 30 days', s.new30d) +
stat('active7d', 'Active users, 7 days', s.active7d) + stat('active30d', 'Active users, 30 days', s.active30d) +
stat('logins24h', 'Logins, 24 hours', s.logins24h) + stat('failedLogins24h', 'Failed logins, 24 hours', s.failedLogins24h) +
'</dl>' + barsHtml(s.newPerDay || []) +
'<h4>Mail, last 7 days</h4><dl class="admin-stats">' +
stat('mailDelivered', 'Delivered', m.delivered) + stat('mailBounced', 'Bounced', m.bounced) +
stat('mailBlocked', 'Blocked', m.blocked) + stat('mailSpam', 'Spam', m.spam) +
stat('mailPending', 'Pending', m.pending) + stat('mailOther', 'Other', m.other) +
'</dl></section>';
}
function systemCardHtml(res, nowMs) {
var html = '<section class="admin-card" id="aoSystem" aria-labelledby="aoSysH"><h3 id="aoSysH">System</h3><h4>Server</h4>';
if (res.health.error) {
html += failedHtml('server health');
} else {
var h = res.health.data;
var ready = res.healthz.error ? 'unknown' : (res.healthz.data.ready ? 'ready' : 'warming up');
html += '<dl class="admin-stats">' + stat('version', 'Version', h.version || '-') + stat('commit', 'Commit', h.commit || '-') +
stat('uptime', 'Uptime', h.uptimeHuman || '-') + stat('ready', 'State', ready) + '</dl>';
}
html += '<h4>MQTT sources</h4>';
if (res.mqtt.error) {
html += failedHtml('MQTT status');
} else {
var srcs = res.mqtt.data.sources || [];
if (!srcs.length) html += '<p class="account-hint">No MQTT sources reported.</p>';
else {
html += '<ul class="admin-list">';
srcs.forEach(function (s) {
html += '<li>' + escapeHtml(s.name) + ': ' + (s.connected ? 'connected' : 'not connected') +
', last message ' + escapeHtml(window.MqttStatusPanel.fmtRelative(s.lastPacketUnix, nowMs)) + '</li>';
});
html += '</ul>';
}
}
html += '<h4>Observers</h4>';
if (res.observers.error) {
html += failedHtml('observers');
} else {
var c = window.ObserversSummary.computeCounts(res.observers.data.observers || []);
html += '<dl class="admin-stats">' + stat('observersOnline', 'Online', c.online) + stat('observersTotal', 'Total', c.total) + '</dl>';
}
return html + '<p class="account-links"><a href="#/perf">Perf page</a> <a href="#/observers">MQTT status panel</a></p></section>';
}
function render(res, nowMs) {
return attentionHtml(attentionItems(res, nowMs)) +
'<div class="admin-cards">' + usersCardHtml(res.stats) + systemCardHtml(res, nowMs) + '</div>';
}
// Resolves (never rejects) to {key: {data} | {error: true}} per source.
// skip names a source to leave out (the timer skips healthz): its
// entry is then the last result kept in state.
function fetchAll(request, skip) {
return Promise.all(SOURCES.map(function (src) {
if (src[0] === skip) return Promise.resolve(state.healthz || { error: true });
return request('GET', src[1]).then(function (r) {
// /api/healthz answers 503 {ready: false} while warming up: that is data.
if (r.ok || (src[0] === 'healthz' && r.status === 503 && r.data && r.data.ready === false)) return { data: r.data };
return { error: true };
}, function () { return { error: true }; });
})).then(function (list) {
var out = {};
SOURCES.forEach(function (src, i) { out[src[0]] = list[i]; });
return out;
});
}
// full: also read /api/healthz (open and Refresh); the timer passes false.
function refresh(full) {
var seq = ++state.seq;
return fetchAll(CSAuth.request, full ? null : 'healthz').then(function (res) {
if (full && seq === state.seq) state.healthz = res.healthz;
var box = document.getElementById('aoBody');
if (!box || seq !== state.seq) return;
var now = Date.now();
var html = render(res, now);
box.innerHTML = html;
document.getElementById('aoUpdated').textContent = 'Updated ' + new Date(now).toLocaleTimeString();
});
}
// Open, Refresh and Retry: one full refresh at a time (each one walks the
// packet store for /api/healthz). Clicks while it runs are ignored and the
// Refresh button stays disabled until it settles.
function fullRefresh() {
if (state.busy) return state.busy;
var btn = document.getElementById('aoRefresh');
if (btn) btn.disabled = true;
var p = refresh(true);
var settle = function () {
if (state.busy !== p) return;
state.busy = null;
var b = document.getElementById('aoRefresh');
if (b) b.disabled = false;
};
p.then(settle, settle);
state.busy = p;
return p;
}
function mount(container) {
container.innerHTML = '<div class="admin-overview"><div class="admin-toolbar">' +
'<button type="button" class="account-btn account-btn-secondary" id="aoRefresh">Refresh</button>' +
'<span class="account-hint" id="aoUpdated"></span></div><div id="aoBody"><p>Loading…</p></div></div>';
document.getElementById('aoRefresh').addEventListener('click', function () { fullRefresh(); });
document.getElementById('aoBody').addEventListener('click', function (e) {
if (e.target.closest('button[data-act="retry"]')) fullRefresh();
});
clearInterval(state.timer);
state.timer = setInterval(function () {
if (document.visibilityState === 'visible') refresh(false);
}, REFRESH_MS);
state.busy = null;
return fullRefresh();
}
function unmount() {
clearInterval(state.timer);
state.timer = null;
state.healthz = null;
state.busy = null;
state.seq++;
}
window.CSAdminOverview = { mount: mount, unmount: unmount,
_test: { mqttDown: mqttDown, attentionItems: attentionItems, attentionHtml: attentionHtml, usersCardHtml: usersCardHtml,
systemCardHtml: systemCardHtml, render: render, fetchAll: fetchAll } };
})();