feat: added activity log
This commit is contained in:
@@ -291,6 +291,152 @@ tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
|
||||
.activity-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.activity-header h1 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.activity-header p {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.activity-refresh,
|
||||
.activity-load-more {
|
||||
width: auto;
|
||||
padding: 0.6rem 1.2rem;
|
||||
}
|
||||
|
||||
.activity-controls-card {
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
padding: 1.35rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(260px, 1.25fr);
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.activity-control-panel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: rgba(17, 24, 39, 0.62);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.activity-control-copy h2 {
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
margin: 0.1rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.activity-control-copy p,
|
||||
.activity-note {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.activity-eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.activity-entry-menu {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.activity-entry-option {
|
||||
min-width: 3.2rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.activity-entry-option:hover,
|
||||
.activity-entry-option:focus-visible {
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.activity-entry-option.active {
|
||||
color: #ffffff;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 8px 18px rgba(59, 130, 246, 0.28);
|
||||
}
|
||||
|
||||
.activity-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(59, 130, 246, 0.22);
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.activity-note strong {
|
||||
color: #bfdbfe;
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.activity-load-more-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
#activity-load-more {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.activity-controls-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.activity-control-panel {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.activity-entry-menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.activity-entry-option {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#modal {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
|
||||
@@ -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, '&')
|
||||
|
||||
Reference in New Issue
Block a user