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:
@@ -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 & USAGE (CR-019) -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Activity & 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 —
|
||||||
|
|||||||
@@ -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 can’t 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(){
|
||||||
|
|||||||
Reference in New Issue
Block a user