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

@@ -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;

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;')

View File

@@ -37,6 +37,10 @@ var accountSettings = template.Must(template.ParseFiles(
"frontend/htmx/contents/dash/base.html",
"frontend/htmx/contents/dash/account_settings.html"))
var activity = template.Must(template.ParseFiles(
"frontend/htmx/contents/dash/base.html",
"frontend/htmx/contents/dash/activity.html"))
var home = template.Must(template.ParseFiles("frontend/htmx/home.html"))
func Home(w http.ResponseWriter, r *http.Request) {
@@ -156,6 +160,17 @@ func AccountSettings(w http.ResponseWriter, r *http.Request) {
return
}
}
func Activity(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := activity.ExecuteTemplate(w, "base.html", struct {
Title string
}{
Title: "Activity Log",
})
if err != nil {
return
}
}
var minifier *minify.M

View File

@@ -0,0 +1,49 @@
{{ define "content" }}
<div class="page-header activity-header">
<div>
<h1>Activity Log</h1>
<p>Recent account, security, and inventory actions for your account.</p>
</div>
<button type="button" class="btn btn-secondary activity-refresh" onclick="loadActivityLog(true)">Refresh</button>
</div>
<div class="card activity-controls-card">
<div class="activity-control-panel">
<div class="activity-control-copy">
<span class="activity-eyebrow">Display</span>
<h2>Entries per page</h2>
<p>Choose how many log entries should be loaded at once.</p>
</div>
<div class="activity-entry-menu" role="group" aria-label="Entries per page">
<button type="button" class="activity-entry-option" data-limit="25" aria-pressed="false" onclick="setActivityLimit(25)">25</button>
<button type="button" class="activity-entry-option active" data-limit="50" aria-pressed="true" onclick="setActivityLimit(50)">50</button>
<button type="button" class="activity-entry-option" data-limit="100" aria-pressed="false" onclick="setActivityLimit(100)">100</button>
</div>
</div>
<div class="activity-note">
<strong>Privacy:</strong> Sensitive request bodies are never stored. The log keeps request metadata, action type, status, IP address, and user agent.
</div>
</div>
<div class="table-container activity-table-container">
<table>
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>Target</th>
<th>Status</th>
<th>IP</th>
<th>Client</th>
</tr>
</thead>
<tbody id="activity-log-body">
<tr><td colspan="6" class="table-loader">Loading activity...</td></tr>
</tbody>
</table>
</div>
<div class="activity-load-more-wrap">
<button type="button" id="activity-load-more" class="btn btn-secondary activity-load-more" onclick="loadMoreActivity()">Load more</button>
</div>
{{ end }}