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, '&')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
49
frontend/htmx/contents/dash/activity.html
Normal file
49
frontend/htmx/contents/dash/activity.html
Normal 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 }}
|
||||
Reference in New Issue
Block a user