implement activity logging feature with UI for viewing logs

This commit is contained in:
tom.hempel
2026-06-01 21:40:50 +02:00
parent 890232f58f
commit da394c5525
8 changed files with 1129 additions and 13 deletions

View File

@ -1,7 +1,13 @@
import { apiPost } from '../api.js';
import { apiGet, apiPost } from '../api.js';
import { getRole, pageHeaderHTML, showToast } from '../app.js';
import { navigate } from '../router.js';
const ACTIVITY_LABELS = {
app_sync: 'App sync',
app_change: 'App change',
web_change: 'Website change',
};
export function devPage() {
if (getRole() !== 'admin') {
navigate('#/');
@ -9,6 +15,7 @@ export function devPage() {
}
const app = document.getElementById('app');
const today = new Date().toISOString().slice(0, 10);
app.innerHTML = `
${pageHeaderHTML('Admin Settings')}
<div class="card" style="max-width:720px;margin-bottom:16px">
@ -51,6 +58,46 @@ export function devPage() {
</p>
<button type="button" class="btn btn-danger" id="devWipeBtn">Remove all data (keep admins)</button>
</div>
<div class="card activity-log-card" style="margin-top:16px">
<h2 style="margin:0 0 8px;font-size:1.1rem">API activity log</h2>
<p class="field-hint" style="margin:0 0 14px">
App syncs (<code>GET /app_questionnaires</code>) and data changes
(POST, PUT, PATCH, DELETE). Routine website page loads are not logged.
</p>
<div class="filter-bar" style="margin-bottom:12px">
<label style="font-size:.85rem;font-weight:500">Date</label>
<input type="date" id="activityLogDate" value="${today}">
<label style="font-size:.85rem;font-weight:500">Activity</label>
<select id="activityLogKind">
<option value="">All kinds</option>
<option value="app_sync">App sync</option>
<option value="app_change">App change</option>
<option value="web_change">Website change</option>
</select>
<button type="button" class="btn btn-sm" id="activityLogRefresh">Refresh</button>
</div>
<p id="activityLogMeta" class="field-hint" style="margin:0 0 10px"></p>
<div class="table-wrapper">
<table class="data-table activity-log-table">
<thead>
<tr>
<th>Time (UTC)</th>
<th>Kind</th>
<th>Subject</th>
<th>Method</th>
<th>Route</th>
<th>Status</th>
<th>Duration</th>
<th>Values</th>
<th>Performed by</th>
</tr>
</thead>
<tbody id="activityLogBody">
<tr><td colspan="9" style="text-align:center;color:var(--text-secondary);padding:20px">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
`;
let parsedFixture = null;
@ -163,6 +210,7 @@ export function devPage() {
});
wireAdminZipExport();
wireActivityLog();
document.getElementById('devImportBtn').addEventListener('click', async () => {
if (!parsedFixture) return;
@ -200,6 +248,155 @@ export function devPage() {
});
}
function wireActivityLog() {
const dateEl = document.getElementById('activityLogDate');
const kindEl = document.getElementById('activityLogKind');
document.getElementById('activityLogRefresh')?.addEventListener('click', loadActivityLog);
dateEl?.addEventListener('change', loadActivityLog);
kindEl?.addEventListener('change', loadActivityLog);
loadActivityLog();
}
async function loadActivityLog() {
const tbody = document.getElementById('activityLogBody');
const meta = document.getElementById('activityLogMeta');
if (!tbody) return;
const date = document.getElementById('activityLogDate')?.value || new Date().toISOString().slice(0, 10);
const activity = document.getElementById('activityLogKind')?.value || '';
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:20px">Loading…</td></tr>';
try {
const q = new URLSearchParams({ date, limit: '300' });
if (activity) q.set('activity', activity);
const data = await apiGet(`activity-log?${q}`);
if (!data.success) throw new Error(data.error || 'Failed to load activity log');
const entries = data.entries || [];
const files = (data.files || []).join(', ') || 'none';
const trunc = data.truncated ? ' (newest 300 shown)' : '';
if (meta) {
meta.textContent = entries.length
? `${entries.length} entries from ${files}${trunc}`
: `No entries for this date/filter (files: ${files})`;
}
if (!entries.length) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:var(--text-secondary);padding:24px">No activity recorded</td></tr>';
return;
}
tbody.innerHTML = entries.map((e) => {
const kind = e.activity || '';
const label = ACTIVITY_LABELS[kind] || kind || '—';
const badgeClass = kind ? `badge-activity-${kind}` : '';
const time = formatActivityTime(e.ts);
const status = e.status != null ? String(e.status) : '—';
const dur = e.duration_ms != null ? `${e.duration_ms} ms` : '—';
const subject = formatActivitySubject(e);
const performedBy = formatActivityPerformer(e);
const errPart = e.error
? `<div class="error-text" style="margin-top:6px;font-size:.8rem">${escHtml(e.error)}</div>`
: '';
return `<tr>
<td>${escHtml(time)}</td>
<td><span class="badge ${badgeClass}">${escHtml(label)}</span></td>
<td class="activity-log-subject">${subject}</td>
<td><code>${escHtml(e.method || '')}</code></td>
<td><code>${escHtml(e.route || '')}</code></td>
<td>${escHtml(status)}</td>
<td>${escHtml(dur)}</td>
<td class="activity-log-changes-cell">${renderActivityChanges(e.changes)}</td>
<td class="activity-log-actor">${performedBy}${errPart}</td>
</tr>`;
}).join('');
} catch (err) {
if (meta) meta.textContent = '';
tbody.innerHTML = `<tr><td colspan="9" class="error-text" style="padding:16px">${escHtml(err.message)}</td></tr>`;
}
}
function formatActivitySubject(entry) {
if (entry.target_username) {
return `<strong>${escHtml(entry.target_username)}</strong>`;
}
const changes = entry.changes || [];
const userChange = changes.find((c) => (c.field || '').toLowerCase() === 'username');
if (userChange) {
const name = userChange.display || formatActivityChangeValue(userChange.value);
return `<strong>${escHtml(name)}</strong>`;
}
const client = changes.find((c) => (c.field || '').toLowerCase().includes('clientcode'));
if (client) {
const code = client.display || formatActivityChangeValue(client.value);
return `client <strong>${escHtml(code)}</strong>`;
}
const qClient = changes.find((c) => (c.field || '') === 'query.clientCode');
if (qClient) {
return `client <strong>${escHtml(formatActivityChangeValue(qClient.value))}</strong>`;
}
return '<span style="color:var(--text-secondary)">—</span>';
}
function formatActivityPerformer(entry) {
if (entry.actor_username) {
const role = entry.role ? ` <span class="activity-log-role">(${escHtml(entry.role)})</span>` : '';
return `<strong>${escHtml(entry.actor_username)}</strong>${role}`;
}
if (entry.role) {
return escHtml(entry.role);
}
return '—';
}
function renderActivityChanges(changes) {
const list = Array.isArray(changes) ? changes : [];
if (!list.length) {
return '<span style="color:var(--text-secondary)">—</span>';
}
const rows = list.map((c) => {
const field = c.field ?? '';
const raw = formatActivityChangeValue(c.value);
const display = (c.display != null && String(c.display) !== '')
? String(c.display)
: raw;
const showRaw = display !== raw
&& !raw.startsWith('[REDACTED]')
&& !raw.startsWith('[ENCRYPTED');
return `<tr>
<th scope="row">${escHtml(field)}</th>
<td>
<strong>${escHtml(display)}</strong>
${showRaw ? `<div class="activity-change-raw">${escHtml(raw)}</div>` : ''}
</td>
</tr>`;
}).join('');
return `<table class="activity-changes-table"><tbody>${rows}</tbody></table>`;
}
function formatActivityChangeValue(value) {
if (value === null || value === undefined) return 'null';
if (typeof value === 'boolean') return value ? 'true' : 'false';
return String(value);
}
function formatActivityTime(iso) {
if (!iso) return '—';
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toISOString().replace('T', ' ').slice(0, 19);
} catch {
return iso;
}
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}
function filenameFromContentDisposition(res, fallback) {
const disp = res.headers.get('Content-Disposition');
if (!disp) return fallback;
@ -210,7 +407,10 @@ function filenameFromContentDisposition(res, fallback) {
async function downloadExportZip(url, defaultFilename) {
const token = localStorage.getItem('qdb_token');
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
headers: {
Authorization: `Bearer ${token}`,
'X-QDB-Client': 'web',
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Download failed' }));