T11.5: admin console Activity & usage card (CR-019)

New card in admin.html/admin.js, above the old per-browser Usage logs
card (which T11.6 retires next). Filters (date range, project, user,
tool) drive GET /api/usage/summary; two export buttons call
GET /api/usage/export (raw / sanitized) and save the CSV via a Blob,
same download pattern wp-usage.js already uses.

Access: the card lives inside admin.html, already gated admin-only
client-side by gateByRole() (unchanged) - a non-admin never sees the
card. The underlying API is gated server-side by require_user_manager
regardless (admin or project_super_user on >=1 project), independent
of and stricter than the client-side gate, so a non-admin request is
refused even if someone reached the endpoint directly.

Accessibility (C1): every control is a real <input>/<select>/<button>,
keyboard-operable. Status/export banners use the existing '.banner' /
'*-banner' id convention, which console-util.js's MutationObserver
already turns into aria-live (role=status, or role=alert on a '.bad'
banner) - no new announcement plumbing needed. No new CSS: reuses
.toolbar/.banner/.card/.row/.kv/.users, so nothing here adds a second
token source (the token rule).

Verified so far:
  - node --check html/admin.js: no syntax errors
  - every id admin.js's new code references exists in admin.html
    (scripted diff against the full getElementById/id= sets)
  - live-server check: GET /api/usage/summary with the exact
    (possibly-empty) query string _activityFilters() builds returns
    the {active_users, per_user_last_active, by_tool, event_count}
    shape renderActivity() expects; project_id filter narrows
    correctly; GET /api/usage/export?sanitize=true returns
    text/csv with the expected header row
  - full smoke test + seed_demo.py still pass

NOT yet verified: rendering at 390px/1440px with before/after
screenshots (CLAUDE.md verification step). This sandbox has no
headless-capable browser (no chromium/msedge on PATH) and the
playwright/chromium download is blocked by this environment's
network allowlist, so tests/cdp.py's harness can't run here. Deferred
to T11.7, same as wave 10's browser checks — flagging rather than
skipping silently.
This commit is contained in:
2026-09-23 12:31:55 -07:00
parent 2de76d52e6
commit a6c3fbfe50
2 changed files with 136 additions and 4 deletions

View File

@@ -34,12 +34,12 @@
/* Every container admin.js paints a table into is a scrollport of its own, so a /* Every container admin.js paints a table into is a scrollport of its own, so a
sticky header always has something to stick to rather than sliding up behind sticky header always has something to stick to rather than sliding up behind
the app bar. Same rule as console.css's .tscroll. */ the app bar. Same rule as console.css's .tscroll. */
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{ #comments-admin, #audit-admin, #notif-box, #usage-admin, #activity-admin, #projects-table, #defmem-table{
overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; } overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside so /* If admin.js wraps its table in its own .tscroll, the outer box steps aside so
one table never ends up with two scrollbars. */ one table never ends up with two scrollbars. */
#comments-admin:has(.tscroll), #audit-admin:has(.tscroll), #notif-box:has(.tscroll), #comments-admin:has(.tscroll), #audit-admin:has(.tscroll), #notif-box:has(.tscroll),
#usage-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){ #usage-admin:has(.tscroll), #activity-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){
overflow:visible; max-height:none; } overflow:visible; max-height:none; }
/* Comment text and audit detail are the two columns you are actually here to /* Comment text and audit detail are the two columns you are actually here to
@@ -53,7 +53,7 @@
border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); } border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
@media (max-width:900px){ @media (max-width:900px){
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{ #comments-admin, #audit-admin, #notif-box, #usage-admin, #activity-admin, #projects-table, #defmem-table{
max-height:none; } max-height:none; }
} }
</style> </style>
@@ -175,7 +175,41 @@
<div id="audit-admin" class="note">Click refresh to load.</div> <div id="audit-admin" class="note">Click refresh to load.</div>
</div> </div>
<!-- USAGE LOGS --> <!-- ACTIVITY &amp; USAGE (CR-019) -->
<div class="card">
<h2>Activity &amp; usage</h2>
<div class="sub">Who is using the suite, which tools, and how often — recorded server-side on every
sign-in and page open, kept indefinitely. Filter below, or export a CSV: raw (real usernames) for
internal use, or sanitized (each user replaced with a stable, non-reversible id) for feeding into
Power BI or another external reporting tool without carrying real identities.</div>
<div class="toolbar">
<label for="act-from">From</label>
<input type="date" id="act-from" onchange="loadActivity()">
<label for="act-to">To</label>
<input type="date" id="act-to" onchange="loadActivity()">
<select id="act-project" onchange="loadActivity()"><option value="">All projects</option></select>
<input id="act-user" placeholder="Username…" oninput="loadActivity()">
<select id="act-tool" onchange="loadActivity()">
<option value="">All tools</option>
<option value="launcher">Launcher</option>
<option value="wizard">SOP wizard</option>
<option value="creator">Work package creator</option>
<option value="field_view">Field view</option>
<option value="admin">Admin console</option>
<option value="directory">User directory</option>
</select>
<button onclick="loadActivity()">Refresh</button>
</div>
<div id="activity-banner" class="banner" style="display:none"></div>
<div id="activity-admin" class="note">Loading…</div>
<div class="toolbar" style="margin-top:var(--s3)">
<button onclick="exportActivity(false)">Download CSV (raw)</button>
<button onclick="exportActivity(true)">Download CSV (sanitized)</button>
</div>
<div id="activity-export-banner" class="banner" style="display:none"></div>
</div>
<!-- USAGE LOGS (per-browser — retired once the report above is confirmed, T11.6) -->
<div class="card"> <div class="card">
<h2>Usage logs</h2> <h2>Usage logs</h2>
<div class="sub">Engagement recorded by both tools — the work package creator and the SOP wizard — <div class="sub">Engagement recorded by both tools — the work package creator and the SOP wizard —

View File

@@ -24,6 +24,7 @@ function reveal(){
loadNotifications(); loadNotifications();
loadComments(); loadComments();
loadAudit(); loadAudit();
loadActivity();
loadUsage(); loadUsage();
} }
function showDenied(){ function showDenied(){
@@ -189,6 +190,19 @@ async function loadProjects(){
banner.style.display='none'; banner.style.display='none';
_adminProjects = json; _adminProjects = json;
renderProjects(); renderProjects();
populateActivityProjectFilter();
}
// The activity project filter reuses the same project list the Projects card
// already fetched — no second /api/projects call just to fill a <select>.
function populateActivityProjectFilter(){
const sel = document.getElementById('act-project');
if(!sel) return;
const cur = sel.value;
sel.innerHTML = '<option value="">All projects</option>' +
_adminProjects.slice().sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')))
.map(p => '<option value="'+uesc(p.id)+'">'+uesc(p.name||p.number||p.id)+'</option>').join('');
sel.value = cur;
} }
function renderProjects(){ function renderProjects(){
@@ -465,6 +479,90 @@ function renderAudit(){
'</tr>').join('')+'</tbody></table>'; '</tr>').join('')+'</tbody></table>';
} }
// ── activity & usage (CR-019) ────────────────────────────────────────────────────
// Server-side, per-user activity — distinct from the "Activity log" card above
// (that's AuditLog: business mutations) and from the "Usage logs" card below
// (that's per-browser localStorage, D5/T7.10, on the way out per T11.6). This is
// /api/usage/summary and /api/usage/export: real rows, aggregated server-side,
// filterable by date/project/user/tool, and exportable raw or sanitized.
function _activityFilters(){
const q = new URLSearchParams();
const from = document.getElementById('act-from').value; if(from) q.set('from', from);
const to = document.getElementById('act-to').value; if(to) q.set('to', to);
const proj = document.getElementById('act-project').value; if(proj) q.set('project_id', proj);
const user = (document.getElementById('act-user').value||'').trim(); if(user) q.set('username', user);
const tool = document.getElementById('act-tool').value; if(tool) q.set('tool', tool);
return q;
}
async function loadActivity(){
const banner = document.getElementById('activity-banner');
const box = document.getElementById('activity-admin');
if(!box) return;
banner.style.display='none';
box.textContent = 'Loading…';
const { status, json } = await api('GET', '/api/usage/summary?'+_activityFilters().toString());
if(status===403){
banner.className='banner bad'; banner.style.display='';
banner.textContent = '✕ Your account cant see suite-wide activity here — this needs admin, or Project Super User on at least one project.';
box.innerHTML=''; return;
}
if(status!==200 || !json){
banner.className='banner bad'; banner.style.display='';
banner.textContent = '✕ Could not load activity ('+apiError(status, json, 'load failed')+').';
box.innerHTML=''; return;
}
renderActivity(json);
}
function renderActivity(sum){
const box = document.getElementById('activity-admin');
if(!sum.event_count){ box.innerHTML = '<div class="note">No activity matches this filter.</div>'; return; }
const fmt = s => s ? wpFormatDateTime(s) : '—';
const bucket = (label, obj, take) => {
const entries = Object.entries(obj).sort((a,b)=> b[0].localeCompare(a[0])).slice(0, take);
if(!entries.length) return '';
return '<table class="kv" style="margin-top:8px"><caption style="text-align:left;font-weight:600;margin-bottom:4px">'+
uesc(label)+'</caption>'+entries.map(([k,v]) => '<tr><th>'+uesc(k)+'</th><td>'+v+' active user'+(v===1?'':'s')+'</td></tr>').join('')+
'</table>';
};
const perTool = Object.entries(sum.by_tool||{});
const toolTable = perTool.length ? '<table class="users"><thead><tr><th>Tool</th><th>Events</th></tr></thead><tbody>'+
perTool.map(([t,n]) => '<tr><td>'+uesc(t||'(login)')+'</td><td>'+n+'</td></tr>').join('')+'</tbody></table>' :
'<div class="note">No per-tool data.</div>';
const perUser = Object.entries(sum.per_user_last_active||{}).sort((a,b)=> String(b[1]).localeCompare(String(a[1])));
const userTable = perUser.length ? '<table class="users"><thead><tr><th>User</th><th>Last active</th></tr></thead><tbody>'+
perUser.map(([u,t]) => '<tr><td><strong>'+uesc(u)+'</strong></td><td style="color:var(--muted)">'+fmt(t)+'</td></tr>').join('')+
'</tbody></table>' : '<div class="note">No per-user data.</div>';
box.innerHTML =
'<div class="note">'+sum.event_count+' event'+(sum.event_count===1?'':'s')+' matched.</div>'+
'<div class="row" style="gap:var(--s4);flex-wrap:wrap;align-items:flex-start">'+
'<div>'+bucket('Active users by day', sum.active_users.by_day, 30)+'</div>'+
'<div>'+bucket('Active users by week', sum.active_users.by_week, 12)+'</div>'+
'<div>'+bucket('Active users by month', sum.active_users.by_month, 12)+'</div>'+
'</div>'+
'<h2 style="margin-top:16px">By tool</h2>'+toolTable+
'<h2 style="margin-top:16px">Per-user last active</h2>'+userTable;
}
async function exportActivity(sanitize){
const banner = document.getElementById('activity-export-banner');
banner.className='banner'; banner.style.display=''; banner.textContent='Preparing export…';
const q = _activityFilters();
if(sanitize) q.set('sanitize', 'true');
const { status, json } = await api('GET', '/api/usage/export?'+q.toString());
if(status!==200 || typeof json !== 'string'){
banner.className='banner bad';
banner.textContent = '✕ Export failed ('+apiError(status, json, 'export failed')+').';
return;
}
const blob = new Blob([json], { type:'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'usage_export_'+(sanitize?'sanitized':'raw')+'_'+new Date().toISOString().slice(0,10)+'.csv';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
banner.className='banner ok';
banner.textContent = '✓ Downloaded the '+(sanitize?'sanitized':'raw')+' export.';
}
// ── notifications / email settings ────────────────────────────────────────────── // ── notifications / email settings ──────────────────────────────────────────────
let _settings = {}; let _settings = {};
async function loadSettings(){ async function loadSettings(){