implement activity logging feature with UI for viewing logs
This commit is contained in:
@ -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' }));
|
||||
|
||||
Reference in New Issue
Block a user