feat: added activity log

This commit is contained in:
2026-06-10 14:17:33 +02:00
parent 96f1a40266
commit 0442e4f699
16 changed files with 1027 additions and 579 deletions

View File

@@ -40,6 +40,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (document.getElementById('locations-table-body')) loadLocations();
if (document.getElementById('projects-table-body')) loadProjects();
if (document.getElementById('account-settings-content')) loadAccountSettings();
if (document.getElementById('activity-log-body')) loadActivityLog(true);
setupPasswordVisibilityToggles();
loadProfile();
@@ -1026,6 +1027,99 @@ async function disablePasskeys(event) {
}
}
// ---- ACTIVITY LOG ----
let activityOffset = 0;
let activityLimit = 50;
async function loadActivityLog(reset = true) {
const tbody = document.getElementById('activity-log-body');
if (!tbody) return;
activityLimit = getSelectedActivityLimit();
if (reset) {
activityOffset = 0;
tbody.innerHTML = '<tr><td colspan="6" class="table-loader">Loading activity...</td></tr>';
}
try {
const data = await apiRequest(`/api/activity?limit=${activityLimit}&offset=${activityOffset}`);
const entries = data.activity || [];
if (reset) tbody.innerHTML = '';
if (entries.length === 0 && activityOffset === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="table-loader">No activity recorded yet.</td></tr>';
} else {
tbody.insertAdjacentHTML('beforeend', entries.map(renderActivityEntry).join(''));
}
activityOffset += entries.length;
const loadMore = document.getElementById('activity-load-more');
if (loadMore) loadMore.style.display = entries.length === activityLimit ? 'inline-flex' : 'none';
} catch (err) {
tbody.innerHTML = `<tr><td colspan="6" class="table-loader" style="color:#fca5a5;">${escapeHTML(err.message || 'Failed to load activity log.')}</td></tr>`;
}
}
function getSelectedActivityLimit() {
const activeButton = document.querySelector('.activity-entry-option.active');
if (!activeButton) return activityLimit || 50;
return parseInt(activeButton.dataset.limit, 10) || 50;
}
function setActivityLimit(limit) {
activityLimit = limit;
document.querySelectorAll('.activity-entry-option').forEach((button) => {
const isActive = parseInt(button.dataset.limit, 10) === limit;
button.classList.toggle('active', isActive);
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
loadActivityLog(true);
}
function loadMoreActivity() {
loadActivityLog(false);
}
function renderActivityEntry(entry) {
const timestamp = entry.created_at ? new Date(entry.created_at * 1000).toLocaleString() : 'unknown';
const statusClass = entry.success ? 'badge success' : 'badge';
const statusLabel = entry.success ? 'Success' : 'Failed';
const entity = [entry.entity_type, entry.entity_id ? `#${entry.entity_id}` : ''].filter(Boolean).join(' ') || 'account';
const client = entry.user_agent ? summarizeUserAgent(entry.user_agent) : 'unknown';
const path = entry.path ? `<div style="color:var(--text-muted); font-size:0.8rem; margin-top:0.2rem;">${escapeHTML(entry.method || '')} ${escapeHTML(entry.path)}</div>` : '';
return `
<tr>
<td style="white-space:nowrap; color:var(--text-muted);">${escapeHTML(timestamp)}</td>
<td>
<div style="font-weight:600; color:var(--text);">${escapeHTML(formatActivityAction(entry.action))}</div>
${path}
</td>
<td>${escapeHTML(entity)}</td>
<td><span class="${statusClass}">${statusLabel} ${entry.status_code || ''}</span></td>
<td style="font-family:monospace; font-size:0.85rem;">${escapeHTML(entry.ip_address || 'unknown')}</td>
<td title="${escapeAttr(entry.user_agent || '')}" style="max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text-muted);">${escapeHTML(client)}</td>
</tr>
`;
}
function formatActivityAction(action) {
if (!action) return 'Unknown action';
return action
.replace(/^auth\./, 'Auth: ')
.replace(/^account\./, 'Account: ')
.replace(/^security\./, 'Security: ')
.replace(/^inventory\./, 'Inventory: ')
.replace(/[._]/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function summarizeUserAgent(userAgent) {
if (userAgent.length <= 80) return userAgent;
return `${userAgent.slice(0, 77)}...`;
}
function escapeHTML(value) {
return String(value)
.replace(/&/g, '&amp;')