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:
2026-06-09 21:05:40 +03:00
parent 9a0137fa4c
commit 6e1dd2111d
15 changed files with 10146 additions and 9496 deletions
@@ -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;
}
+5
View File
@@ -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'))} &rarr;
</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
View File
@@ -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;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
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>