feat(activity-log): phase 6 - dashboard widget + settings panel + docs
- Dashboard 'Recent Activity' widget: latest 5 entries, live prepend, 'View all' -> Activity tab - Settings 'Activity Log' panel: retention (enabled/max_days/max_entries) GET/PUT, clear (confirm + auth-required toast), CSV/JSON export - audit-log vs ephemeral debug Log Viewer distinction note + cross-links - public helpers fetchRecentEntries/renderCompactEntry on activity-log.ts (reused, no dup markup) - README Activity Log section; i18n across en/ru/zh - review fixes: clear 401 surfaces toast; empty widget transitions on first live event
This commit is contained in:
@@ -624,3 +624,151 @@
|
||||
|
||||
.al-toolbar-advanced .al-field-group { min-width: 100%; }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
Dashboard "Recent Activity" widget (.dal-*)
|
||||
Compact, consistent with the precision-instrument language of the full tab.
|
||||
Rows are tighter than the full viewer — just sev icon + relative time + msg.
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* List container */
|
||||
.dal-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Compact entry row */
|
||||
.al-compact-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 52px 1fr;
|
||||
align-items: center;
|
||||
gap: 0 8px;
|
||||
padding: 5px 4px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
min-height: 28px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.al-compact-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.al-compact-row:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.al-compact-icon .icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.al-compact-time {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.al-compact-msg {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Severity color on the row itself (row inherits .al-sev-* from renderCompactEntry) */
|
||||
.al-compact-row.al-sev-error .al-compact-icon .icon { color: var(--danger-color); }
|
||||
.al-compact-row.al-sev-warning .al-compact-icon .icon { color: var(--warning-color); }
|
||||
.al-compact-row.al-sev-info .al-compact-icon .icon { color: var(--info-color); }
|
||||
|
||||
/* Empty state inside widget */
|
||||
.dal-empty {
|
||||
padding: 16px 8px;
|
||||
}
|
||||
.dal-empty p {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Loading state placeholder */
|
||||
.dal-loading {
|
||||
padding: 16px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Footer — "View all →" link */
|
||||
.dal-footer {
|
||||
padding: 6px 4px 2px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dal-view-all {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--primary-text-color);
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.dal-view-all:hover {
|
||||
opacity: 0.75;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
Settings panel helpers (ds-info-note, ds-inline-link)
|
||||
These are general enough to live here but scoped tightly enough to not
|
||||
bleed into the rest of the settings layout.
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ds-info-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
background: color-mix(in srgb, var(--info-color) 8%, var(--bg-secondary));
|
||||
border: 1px solid color-mix(in srgb, var(--info-color) 25%, var(--border-color));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-color);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ds-info-note .icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
color: var(--info-color);
|
||||
}
|
||||
|
||||
/* Inline text button that looks like a link (used in ds-info-note, hints) */
|
||||
.ds-inline-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
color: var(--primary-text-color);
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.ds-inline-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -275,6 +275,7 @@ import {
|
||||
loadDaylightTimezone, saveDaylightTimezone,
|
||||
requestNotifPermissionFromSettings, testNotifFromSettings,
|
||||
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
|
||||
loadActivityLogSettings, saveActivityLogSettings, activityLogSettingsExport, clearActivityLog,
|
||||
} from './features/settings.ts';
|
||||
import {
|
||||
loadUpdateStatus, initUpdateListener, checkForUpdates,
|
||||
@@ -759,6 +760,10 @@ Object.assign(window, {
|
||||
saveExternalUrl,
|
||||
revertExternalUrl,
|
||||
getBaseOrigin,
|
||||
loadActivityLogSettings,
|
||||
saveActivityLogSettings,
|
||||
activityLogSettingsExport,
|
||||
clearActivityLog,
|
||||
|
||||
// update
|
||||
checkForUpdates,
|
||||
|
||||
@@ -32,7 +32,7 @@ function _escapeAttr(text: string): string {
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
|
||||
interface ActivityEntry {
|
||||
export interface ActivityEntry {
|
||||
id: string;
|
||||
ts: string;
|
||||
category: string;
|
||||
@@ -678,6 +678,40 @@ export function activityLogNavigateToEntity(entityType: string, entityId: string
|
||||
navigateToCard(nav.tab, nav.subTab, null, nav.attr, entityId);
|
||||
}
|
||||
|
||||
// ─── Public helpers for Phase 6 (Dashboard widget + Settings export) ────────
|
||||
|
||||
/**
|
||||
* Fetch the N most-recent activity log entries without affecting the full-tab
|
||||
* state (separate request, no state mutations).
|
||||
*/
|
||||
export async function fetchRecentEntries(limit = 5): Promise<ActivityEntry[]> {
|
||||
try {
|
||||
const res = await fetchWithAuth(`/activity-log?limit=${limit}`);
|
||||
if (!res || !res.ok) return [];
|
||||
const page: ActivityPage = await res.json();
|
||||
// API returns oldest-first within page; reverse for newest-first.
|
||||
return [...page.entries].reverse();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a compact single-line entry row for the Dashboard widget.
|
||||
* Re-uses the severity icon / class helpers and escapeHtml from the full viewer
|
||||
* so the visual language is consistent.
|
||||
*/
|
||||
export function renderCompactEntry(entry: ActivityEntry): string {
|
||||
const relTime = formatRelativeTime(entry.ts);
|
||||
const sevIcon = _severityIcon(entry.severity);
|
||||
const sevClass = _severityClass(entry.severity);
|
||||
return `<div class="al-compact-row al-sev ${sevClass}" title="${_escapeAttr(entry.ts)}">
|
||||
<span class="al-compact-icon" aria-label="${_escapeAttr(t(`activity_log.severity.${entry.severity}`))}">${sevIcon}</span>
|
||||
<span class="al-compact-time tabular-nums">${escapeHtml(relTime)}</span>
|
||||
<span class="al-compact-msg">${escapeHtml(entry.message)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ─── Main loader (registered with tab-registry) ─────────────
|
||||
|
||||
export async function loadActivityLog(): Promise<void> {
|
||||
|
||||
@@ -21,6 +21,8 @@ import { renderDeviceIconSvg } from '../core/device-icons.ts';
|
||||
import { createFpsSparkline } from '../core/chart-utils.ts';
|
||||
import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts';
|
||||
import { mountCardModeToggle } from './card-modes.ts';
|
||||
import { ICON_ACTIVITY_LOG } from '../core/icons.ts';
|
||||
import { fetchRecentEntries, renderCompactEntry, ActivityEntry } from './activity-log.ts';
|
||||
|
||||
function _applyGlobalLayoutAttrs(): void {
|
||||
const c = document.getElementById('dashboard-content');
|
||||
@@ -573,6 +575,61 @@ function renderDashboardPlaylist(playlist: ScenePlaylist): string {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ─── Recent Activity widget (Dashboard) ─────────────────────
|
||||
|
||||
const RECENT_ACTIVITY_LIMIT = 5;
|
||||
let _recentActivityLiveListener: ((e: Event) => void) | null = null;
|
||||
|
||||
/** Fetch recent entries and populate the widget list container. */
|
||||
async function _loadRecentActivityWidget(): Promise<void> {
|
||||
const list = document.getElementById('dashboard-recent-activity-list');
|
||||
if (!list) return;
|
||||
|
||||
const entries = await fetchRecentEntries(RECENT_ACTIVITY_LIMIT);
|
||||
_renderRecentActivityList(list, entries);
|
||||
|
||||
// Start live listener (idempotent)
|
||||
_startRecentActivityLive();
|
||||
}
|
||||
|
||||
function _renderRecentActivityList(list: HTMLElement, entries: ActivityEntry[]): void {
|
||||
if (!entries || entries.length === 0) {
|
||||
list.className = '';
|
||||
list.innerHTML = `<div class="al-state al-empty dal-empty" role="status">
|
||||
<span class="al-state-icon" aria-hidden="true">${ICON_ACTIVITY_LOG}</span>
|
||||
<p>${escapeHtml(t('activity_log.empty_no_filters'))}</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
list.className = 'dal-list';
|
||||
list.innerHTML = entries.map(e => renderCompactEntry(e)).join('');
|
||||
}
|
||||
|
||||
function _startRecentActivityLive(): void {
|
||||
if (_recentActivityLiveListener) return;
|
||||
_recentActivityLiveListener = (e: Event) => {
|
||||
const ce = e as CustomEvent;
|
||||
const entry = ce.detail?.entry;
|
||||
if (!entry) return;
|
||||
const list = document.getElementById('dashboard-recent-activity-list');
|
||||
if (!list) return;
|
||||
if (!list.classList.contains('dal-list')) {
|
||||
// Transition from empty-state to list on the first live event
|
||||
_renderRecentActivityList(list, [entry]);
|
||||
return;
|
||||
}
|
||||
// Prepend the new row and cap at N
|
||||
list.insertAdjacentHTML('afterbegin', renderCompactEntry(entry));
|
||||
const rows = list.querySelectorAll('.al-compact-row');
|
||||
if (rows.length > RECENT_ACTIVITY_LIMIT) {
|
||||
for (let i = RECENT_ACTIVITY_LIMIT; i < rows.length; i++) {
|
||||
rows[i].remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('server:activity_logged', _recentActivityLiveListener);
|
||||
}
|
||||
|
||||
let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
/** Called from the transport-bar poll cycler (and any legacy callers
|
||||
* that might still reference `window.changeDashboardPollInterval`). */
|
||||
@@ -1014,6 +1071,23 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
// Recent Activity widget — always appended at the end of dynamic sections.
|
||||
// Uses a placeholder that _loadRecentActivityWidget() fills in async after render.
|
||||
dynamicHtml += `<div class="dashboard-section" data-section="recent-activity">
|
||||
${_sectionHeader('recent-activity', t('dashboard.section.recent_activity'), '')}
|
||||
${_sectionContent('recent-activity', `<div id="dashboard-recent-activity-list" class="dal-loading" aria-live="polite" aria-label="${escapeHtml(t('dashboard.section.recent_activity'))}">
|
||||
<div class="al-state al-loading">
|
||||
<div class="al-spinner"></div>
|
||||
<span>${escapeHtml(t('activity_log.loading'))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dal-footer">
|
||||
<button class="btn btn-ghost btn-sm dal-view-all" onclick="switchTab('activity_log')" aria-label="${escapeHtml(t('dashboard.recent_activity.view_all'))}">
|
||||
${escapeHtml(t('dashboard.recent_activity.view_all'))} →
|
||||
</button>
|
||||
</div>`)}
|
||||
</div>`;
|
||||
|
||||
// First load: build everything in one innerHTML to avoid flicker.
|
||||
// Poll-interval control was moved to the transport bar (it's global,
|
||||
// not dashboard-specific) — toolbar now keeps the tutorial help
|
||||
@@ -1062,6 +1136,9 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
_startUptimeTimer();
|
||||
startPerfPolling();
|
||||
|
||||
// Async-load the Recent Activity widget (non-blocking — never blocks the main render).
|
||||
_loadRecentActivityWidget().catch(() => { /* widget failure is non-fatal */ });
|
||||
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to load dashboard:', error);
|
||||
|
||||
@@ -146,6 +146,10 @@ export function switchSettingsTab(tabId: string): void {
|
||||
if (tabId === 'notifications') {
|
||||
initNotificationsPanel();
|
||||
}
|
||||
// Lazy-load activity log settings when switching to that tab
|
||||
if (tabId === 'activity_log') {
|
||||
loadActivityLogSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Log Viewer ────────────────────────────────────────────
|
||||
@@ -526,6 +530,7 @@ export function openSettingsModal(): void {
|
||||
loadLogLevel();
|
||||
loadShutdownAction();
|
||||
loadDaylightTimezone();
|
||||
loadActivityLogSettings();
|
||||
_seedRailFooter();
|
||||
// Refresh the update status so the rail badge ("update available" pill
|
||||
// on the Updates tab) is current when the modal opens — it would
|
||||
@@ -1200,3 +1205,110 @@ export function testNotifFromSettings(): void {
|
||||
fireTestNotification();
|
||||
}
|
||||
|
||||
// ─── Activity Log Settings ──────────────────────────────────
|
||||
|
||||
interface ActivityLogSettingsResponse {
|
||||
enabled: boolean;
|
||||
max_days: number;
|
||||
max_entries: number;
|
||||
}
|
||||
|
||||
/** Fetch and populate the Activity Log settings panel. */
|
||||
export async function loadActivityLogSettings(): Promise<void> {
|
||||
try {
|
||||
const res = await fetchWithAuth('/activity-log/settings');
|
||||
if (!res || !res.ok) return;
|
||||
const data: ActivityLogSettingsResponse = await res.json();
|
||||
|
||||
const enabledCb = document.getElementById('al-settings-enabled') as HTMLInputElement | null;
|
||||
const maxDaysIn = document.getElementById('al-settings-max-days') as HTMLInputElement | null;
|
||||
const maxEntriesIn = document.getElementById('al-settings-max-entries') as HTMLInputElement | null;
|
||||
|
||||
if (enabledCb) enabledCb.checked = data.enabled;
|
||||
if (maxDaysIn) maxDaysIn.value = String(data.max_days);
|
||||
if (maxEntriesIn) maxEntriesIn.value = String(data.max_entries);
|
||||
} catch (err) {
|
||||
// Non-fatal — panel just shows defaults
|
||||
console.error('Failed to load activity log settings:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate and save the Activity Log retention settings. */
|
||||
export async function saveActivityLogSettings(): Promise<void> {
|
||||
const enabledCb = document.getElementById('al-settings-enabled') as HTMLInputElement | null;
|
||||
const maxDaysIn = document.getElementById('al-settings-max-days') as HTMLInputElement | null;
|
||||
const maxEntriesIn = document.getElementById('al-settings-max-entries') as HTMLInputElement | null;
|
||||
|
||||
const enabled = enabledCb ? enabledCb.checked : true;
|
||||
const maxDays = parseInt(maxDaysIn?.value ?? '365', 10);
|
||||
const maxEntries = parseInt(maxEntriesIn?.value ?? '100000', 10);
|
||||
|
||||
// Client-side validation matching server bounds
|
||||
if (isNaN(maxDays) || maxDays < 0 || maxDays > 3650) {
|
||||
showToast(t('settings.activity_log.error.max_days_range'), 'error');
|
||||
return;
|
||||
}
|
||||
if (isNaN(maxEntries) || maxEntries < 0 || maxEntries > 10_000_000) {
|
||||
showToast(t('settings.activity_log.error.max_entries_range'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth('/activity-log/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled, max_days: maxDays, max_entries: maxEntries }),
|
||||
});
|
||||
if (!res || !res.ok) {
|
||||
const err = await res?.json().catch(() => ({}));
|
||||
throw new Error((err as any).detail || `HTTP ${res?.status}`);
|
||||
}
|
||||
showToast(t('settings.activity_log.saved'), 'success');
|
||||
} catch (err: any) {
|
||||
if (err?.isAuth) return;
|
||||
showToast(t('settings.activity_log.save_error') + ': ' + (err?.message || ''), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/** Export the full activity log as CSV or JSON via authed blob download. */
|
||||
export async function activityLogSettingsExport(format: 'csv' | 'json'): Promise<void> {
|
||||
try {
|
||||
showToast(t('activity_log.export.downloading'), 'info');
|
||||
const res = await fetchWithAuth(`/activity-log/export?format=${format}`);
|
||||
if (!res || !res.ok) throw new Error(`HTTP ${res?.status}`);
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const now = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const filename = `ledgrab-activity-${now}.${format}`;
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
|
||||
} catch (err: any) {
|
||||
if (err?.isAuth) return;
|
||||
showToast(t('activity_log.export.error'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/** Confirm and clear all activity log entries. */
|
||||
export async function clearActivityLog(): Promise<void> {
|
||||
const ok = await showConfirm(t('settings.activity_log.clear.confirm'));
|
||||
if (!ok) return;
|
||||
try {
|
||||
const res = await fetchWithAuth('/activity-log', { method: 'DELETE', handle401: false });
|
||||
if (!res || !res.ok) {
|
||||
if (res?.status === 401) {
|
||||
showToast(t('settings.activity_log.clear.auth_required'), 'error');
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP ${res?.status}`);
|
||||
}
|
||||
showToast(t('settings.activity_log.clear.success'), 'success');
|
||||
} catch (err: any) {
|
||||
if (err?.isAuth) return;
|
||||
showToast(t('settings.activity_log.clear.error') + ': ' + (err?.message || ''), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -460,6 +460,10 @@ startTargetOverlay: (...args: any[]) => any;
|
||||
// ─── Activity Log ───
|
||||
loadActivityLog: () => Promise<void>;
|
||||
activityLogToggleDetail: (entryId: string) => void;
|
||||
loadActivityLogSettings: () => Promise<void>;
|
||||
saveActivityLogSettings: () => Promise<void>;
|
||||
activityLogSettingsExport: (format: 'csv' | 'json') => Promise<void>;
|
||||
clearActivityLog: () => Promise<void>;
|
||||
activityLogToggleCat: (cat: string) => void;
|
||||
activityLogToggleSev: (sev: string) => void;
|
||||
activityLogOnSearch: (val: string) => void;
|
||||
|
||||
+3406
-3373
File diff suppressed because it is too large
Load Diff
+3088
-3055
File diff suppressed because it is too large
Load Diff
+3082
-3049
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,12 @@
|
||||
|
||||
<div class="settings-rail-group" data-i18n="settings.rail.group.system">System</div>
|
||||
|
||||
<button class="settings-rail-btn" data-settings-tab="activity_log" data-rail-ch="cyan" onclick="switchSettingsTab('activity_log')" role="tab" aria-label="Activity Log" data-i18n-aria-label="settings.tab.activity_log">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 3v5h5"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/></svg>
|
||||
<span class="settings-rail-label" data-i18n="settings.tab.activity_log">Activity Log</span>
|
||||
<span class="settings-rail-dot" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<button class="settings-rail-btn" data-settings-tab="updates" data-rail-ch="signal" onclick="switchSettingsTab('updates')" role="tab" aria-label="Updates" data-i18n-aria-label="settings.tab.updates">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
|
||||
<span class="settings-rail-label" data-i18n="settings.tab.updates">Updates</span>
|
||||
@@ -529,6 +535,129 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Activity Log tab ═══ -->
|
||||
<div id="settings-panel-activity_log" class="settings-panel">
|
||||
|
||||
<!-- Note distinguishing this persistent audit log from the ephemeral debug Log Viewer -->
|
||||
<div class="ds-info-note" role="note">
|
||||
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
<span data-i18n="settings.activity_log.distinction_note">This is the <strong>persistent audit log</strong> — structured records of every entity change, auth event, and system action. It is separate from the ephemeral <button class="ds-inline-link" onclick="closeSettingsModal(); openLogOverlay()" data-i18n="settings.activity_log.open_log_viewer">debug Log Viewer</button> (live server log tail, resets on disconnect).</span>
|
||||
</div>
|
||||
|
||||
<!-- Retention settings -->
|
||||
<section class="ds-section" data-ch="cyan">
|
||||
<div class="ds-section-header">
|
||||
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||
<span class="ds-section-title" data-i18n="settings.activity_log.section.retention">Retention</span>
|
||||
<span class="ds-section-index" aria-hidden="true">01</span>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
|
||||
<div class="ds-toggle-row">
|
||||
<div class="ds-toggle-text">
|
||||
<div class="ds-toggle-title" data-i18n="settings.activity_log.enabled.label">Enable activity logging</div>
|
||||
<div class="ds-toggle-sub" data-i18n="settings.activity_log.enabled.hint">When disabled, no new audit entries are recorded. Existing entries are preserved.</div>
|
||||
</div>
|
||||
<label class="settings-switch">
|
||||
<input type="checkbox" id="al-settings-enabled" checked>
|
||||
<span class="settings-switch-track" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="ds-pair-row">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="al-settings-max-days" data-i18n="settings.activity_log.max_days.label">Max age (days)</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.activity_log.max_days.hint">Entries older than this many days are pruned automatically. Set to 0 to disable age-based pruning (keep forever, up to the entry limit).</small>
|
||||
<input type="number" id="al-settings-max-days" min="0" max="3650" value="365" aria-describedby="al-settings-max-days-hint">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="al-settings-max-entries" data-i18n="settings.activity_log.max_entries.label">Max entries</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.activity_log.max_entries.hint">Maximum number of entries to keep. When this limit is reached, the oldest entries are pruned. Set to 0 for no limit.</small>
|
||||
<input type="number" id="al-settings-max-entries" min="0" max="10000000" value="100000" aria-describedby="al-settings-max-entries-hint">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inline-row inline-row--actions">
|
||||
<button class="btn btn-primary" onclick="saveActivityLogSettings()" style="flex:1">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
<span data-i18n="settings.activity_log.save">Save Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Export section -->
|
||||
<section class="ds-section" data-ch="amber">
|
||||
<div class="ds-section-header">
|
||||
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||
<span class="ds-section-title" data-i18n="settings.activity_log.section.export">Export</span>
|
||||
<span class="ds-section-index" aria-hidden="true">02</span>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
|
||||
<div class="ds-toggle-row">
|
||||
<div class="ds-toggle-text">
|
||||
<div class="ds-toggle-title" data-i18n="settings.activity_log.export_csv.label">Export as CSV</div>
|
||||
<div class="ds-toggle-sub" data-i18n="settings.activity_log.export.hint">Download the full audit log as a file. Large logs may take a moment to prepare.</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick="activityLogSettingsExport('csv')">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
|
||||
<span data-i18n="settings.activity_log.export_csv.button">CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ds-toggle-row">
|
||||
<div class="ds-toggle-text">
|
||||
<div class="ds-toggle-title" data-i18n="settings.activity_log.export_json.label">Export as JSON</div>
|
||||
<div class="ds-toggle-sub" data-i18n="settings.activity_log.export.hint">Download the full audit log as a file. Large logs may take a moment to prepare.</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick="activityLogSettingsExport('json')">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
|
||||
<span data-i18n="settings.activity_log.export_json.button">JSON</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<small class="input-hint" style="display:block; margin-top:6px">
|
||||
<span data-i18n="settings.activity_log.export.view_tab_hint">To export with filters applied, use the </span>
|
||||
<button class="ds-inline-link" onclick="closeSettingsModal(); switchTab('activity_log')" data-i18n="settings.activity_log.open_activity_tab">Activity tab</button>.
|
||||
</small>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Clear log section (destructive) -->
|
||||
<section class="ds-section" data-ch="coral">
|
||||
<div class="ds-section-header">
|
||||
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||
<span class="ds-section-title" data-i18n="settings.activity_log.section.clear">Clear Log</span>
|
||||
<span class="ds-section-meta" data-i18n="settings.section.destructive">DESTRUCTIVE</span>
|
||||
<span class="ds-section-index" aria-hidden="true">03</span>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
|
||||
<div class="ds-toggle-row ds-toggle-row--danger">
|
||||
<div class="ds-toggle-text">
|
||||
<div class="ds-toggle-title" data-i18n="settings.activity_log.clear.label">Clear all entries</div>
|
||||
<div class="ds-toggle-sub" data-i18n="settings.activity_log.clear.hint">Permanently delete all activity log entries. This action is audited — a single system entry records who cleared the log and when.</div>
|
||||
</div>
|
||||
<button class="btn btn-danger" onclick="clearActivityLog()">
|
||||
<svg class="icon" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||
<span data-i18n="settings.activity_log.clear.button">Clear Log</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ═══ About tab ═══ -->
|
||||
<div id="settings-panel-about" class="settings-panel">
|
||||
<div id="about-panel-content"></div>
|
||||
|
||||
Reference in New Issue
Block a user