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:
@@ -19,6 +19,7 @@ semantic = true
|
||||
# Automatically run `vex update` before search if the index is stale
|
||||
auto_update = true
|
||||
|
||||
# Embedder used for semantic indexing. Known IDs: minilm-l6-v2 (default).
|
||||
# Embedder used for semantic indexing. IDs: minilm-l6-v2 (default, CPU-fast),
|
||||
# jina-code (code-specialized, GPU-worthy), bge-base-en-v1.5, bge-large-en-v1.5.
|
||||
# Changing the embedder requires a full reindex.
|
||||
# embedder = "minilm-l6-v2"
|
||||
embedder = "jina-code"
|
||||
|
||||
@@ -82,6 +82,19 @@ LedGrab speaks many protocols, so a single setup can drive everything from a DIY
|
||||
- Real-time FPS, latency, and uptime charts
|
||||
- Localized in English, Russian, and Chinese
|
||||
|
||||
### Activity Log
|
||||
|
||||
The **Activity** tab is a persistent, queryable audit log of everything LedGrab has done — entity changes, auth events, device connections, and system actions.
|
||||
|
||||
- Filter by category (auth, device, entity, capture, system), severity, actor, entity type, date range, or free text
|
||||
- Live-append of new events as they happen
|
||||
- Export as CSV or JSON (authentication required)
|
||||
- Entity crosslinks navigate directly to the relevant card
|
||||
- **Retention settings** (Settings → Activity Log): configure max age, max entry count, and toggle recording on/off
|
||||
- **Clear log** (Settings → Activity Log, requires authentication) — audited: a system entry records who cleared the log and when
|
||||
|
||||
> **Note:** The Activity Log is distinct from the **debug Log Viewer** (Settings → General → Open Log Viewer). The Log Viewer is an ephemeral real-time tail of the server's Python log stream (WARNING/ERROR lines, resets on disconnect). The Activity Log is a structured, persistent SQLite-backed record of semantic application events.
|
||||
|
||||
### Home Assistant Integration
|
||||
|
||||
- HACS-compatible custom component (separate repository)
|
||||
|
||||
@@ -70,3 +70,4 @@ Phase 2 landed (2026-06-09): `core/activity_log/` package (`context.py`, `record
|
||||
Phase 4 landed (2026-06-09): schemas (`api/schemas/activity_log.py`), routes (`api/routes/activity_log.py`: list/export/settings/clear), router registration in `api/__init__.py`, `get_seq_for_id` helper on `ActivityLogRepository`. 49 new tests — all green. Full suite 2486 passed, 2 skipped, 0 failed. Ruff clean. Pagination bug found and fixed (limit+1 probe must drop oldest row when has_more, not tail).
|
||||
Phase 3 landed (2026-06-09): instrumented all four categories — entity CRUD via `fire_entity_event` choke-point (`dependencies.py`), auth failures + WS session in `auth.py`, device online/offline in `device_health.py`, device discovered/lost in `discovery_watcher.py`, ADB connect/disconnect in `system_settings.py`, capture start/stop (individual + bulk) in `output_targets_control.py`, scene/playlist/automation activate in their respective route/engine files, backup/restore/delete + restart/shutdown/update/calibration/settings in `backup.py`/`update.py`/`calibration.py`; all 11 entity delete handlers pass `entity_name` to `fire_entity_event`; 22 new tests (security: token never in any field, explicitly asserted) — all green. Full suite 2369 passed, 2 skipped, 0 failed. Ruff clean. Complete (category, action) inventory in phase-3-instrumentation.md Handoff section.
|
||||
Phase 5 landed (2026-06-09): Activity tab frontend — `features/activity-log.ts` (loadActivityLog, filter toolbar with category/severity chips + presets + debounced search + date range + actor/entity-type text fields, keyset-paginated list, expandable detail drawer, live-prepend via `server:activity_logged`, authed CSV/JSON blob export); `core/ui.ts` additions (`formatTimestamp`, `formatRelativeTime`); `core/icon-paths.ts` + `core/icons.ts` additions (scrollText/audit, circleAlert, info, filter, xCircle); `core/tab-registry.ts` registered; `templates/index.html` tab button + panel; `app.ts` + `global.d.ts` wired; `static/css/activity-log.css` (precision-ledger design, category color rail, live-dot pulse, detail drawer, responsive breakpoints); all three locales updated (activity_log.* + time.* relative-time keys + tour.activity_log); `features/tutorials.ts` getting-started tour extended. `tsc --noEmit` clean, `npm run build` passes. Reusable helpers for Phase 6 documented in phase-5-frontend-tab.md Handoff section.
|
||||
Phase 6 landed (2026-06-09): Dashboard "Recent Activity" widget (live SSE, View-all link, `.dal-*` CSS, loading/empty states) + Settings "Activity Log" panel (enabled toggle, max_days/max_entries, Save toast, authed CSV/JSON export, confirmed Clear, audit-vs-debug cross-links) + 32 i18n keys per locale + README "Activity Log" section. `tsc --noEmit` clean, `npm run build` passes. All six phases complete — feature ready for final review.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
**Base branch:** `master` (merge target)
|
||||
**Branch point:** `17dd2e02bab4d00a93479eb6af1a8c6ddc0c7224` (use for clean review diffs)
|
||||
**Created:** 2026-06-09
|
||||
**Status:** 🟡 In Progress
|
||||
**Status:** 🟢 All phases complete — awaiting final review + merge
|
||||
**Strategy:** Incremental
|
||||
**Mode:** Automated
|
||||
**Execution:** Orchestrator
|
||||
@@ -62,9 +62,9 @@ is an on-demand CSV/JSON **export** (no separate backup subsystem).
|
||||
- [x] Phase 1: Storage — model, migration, repository [domain: data] → [subplan](./phase-1-storage.md)
|
||||
- [x] Phase 2: Recorder, actor context, retention, lifecycle [domain: backend] → [subplan](./phase-2-recorder-retention.md)
|
||||
- [x] Phase 3: Event instrumentation (4 categories) [domain: backend] → [subplan](./phase-3-instrumentation.md)
|
||||
- [ ] Phase 4: REST API — query/filter/export/settings/clear [domain: backend] → [subplan](./phase-4-api.md)
|
||||
- [ ] Phase 5: Frontend — Activity tab + smart filtering + live updates [domain: frontend] → [subplan](./phase-5-frontend-tab.md)
|
||||
- [ ] Phase 6: Dashboard widget + Settings panel + docs [domain: frontend] → [subplan](./phase-6-dashboard-settings.md)
|
||||
- [x] Phase 4: REST API — query/filter/export/settings/clear [domain: backend] → [subplan](./phase-4-api.md)
|
||||
- [x] Phase 5: Frontend — Activity tab + smart filtering + live updates [domain: frontend] → [subplan](./phase-5-frontend-tab.md)
|
||||
- [x] Phase 6: Dashboard widget + Settings panel + docs [domain: frontend] → [subplan](./phase-6-dashboard-settings.md)
|
||||
|
||||
## Parallelizable Phase Groups (Orchestrator mode only)
|
||||
|
||||
@@ -84,7 +84,7 @@ is an on-demand CSV/JSON **export** (no separate backup subsystem).
|
||||
| Phase 3: Instrumentation | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ |
|
||||
| Phase 4: REST API | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ |
|
||||
| Phase 5: Frontend tab | frontend | ✅ Done | ✅ Passed | ✅ Passed (tsc+build) | ✅ |
|
||||
| Phase 6: Dashboard/Settings | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 6: Dashboard/Settings | frontend | ✅ Done | ✅ Passed | ✅ Passed (tsc+build) | ✅ |
|
||||
|
||||
## Outstanding Warnings
|
||||
|
||||
@@ -103,6 +103,9 @@ is an on-demand CSV/JSON **export** (no separate backup subsystem).
|
||||
| 5 | Attribute-context XSS (entity_name title + JSON.stringify onclick) | 🟡 Warning (security) | resolved — `_escapeAttr` + data-attr event delegation |
|
||||
| 5 | Filter toolbar value= attrs not quote-escaped (new code) | 🟡 Warning (security) | resolved — `_escapeAttr` on q/actor/entity_type/since/until |
|
||||
| 5 | Manual browser smoke test (tab loads, filters, live, export) | 🔵 Note | open — recommend at final review (server restart needed) |
|
||||
| 6 | clearActivityLog() 401 path unreachable → silent failure | 🟡 Warning | resolved — `handle401:false` surfaces auth-required toast |
|
||||
| 6 | Recent Activity widget dropped first live event when empty | 🔵 Note | resolved — empty→list transition on first live event |
|
||||
| 6 | Widget outside dashboard layout-toggle/ordering system | 🔵 Note | accepted — deliberate (always-visible), collapse still works |
|
||||
|
||||
## Final Review
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 6: Dashboard widget + Settings retention panel + docs
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Status:** ✅ Done
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend · uses the `frontend-design` skill
|
||||
|
||||
@@ -13,13 +13,13 @@ docs/tutorials.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Dashboard **Recent Activity** widget (`features/dashboard.ts` + dashboard CSS):
|
||||
- [x] Dashboard **Recent Activity** widget (`features/dashboard.ts` + dashboard CSS):
|
||||
- Compact card showing the latest ~5 entries (severity icon, relative time, message).
|
||||
- Reuse the Phase 5 render helper (don't duplicate row markup).
|
||||
- Live update via `server:activity_logged` (prepend, cap to N).
|
||||
- **View all →** navigates to the Activity tab (`switchTab('activity_log')`).
|
||||
- Respect the existing dashboard card layout/toggle system; localized; empty state.
|
||||
- [ ] **Settings** retention panel (`features/settings.ts` + `templates/modals/settings.html`):
|
||||
- [x] **Settings** retention panel (`features/settings.ts` + `templates/modals/settings.html`):
|
||||
- New rail entry `Activity Log` (beside the existing **Log Viewer**).
|
||||
- Controls: `enabled` toggle, `max_days` number, `max_entries` number → `GET/PUT
|
||||
/activity-log/settings`. Save → toast; validation feedback.
|
||||
@@ -28,9 +28,9 @@ docs/tutorials.
|
||||
- One-line note distinguishing this persistent audit log from the ephemeral debug Log Viewer
|
||||
(cross-link both ways).
|
||||
- i18n for all controls/labels/hints.
|
||||
- [ ] Docs: update user-facing docs/README/feature list for the new Activity tab + retention
|
||||
- [x] Docs: update user-facing docs/README/feature list for the new Activity tab + retention
|
||||
settings + export (and the audit-vs-debug-log distinction). Keep it brief.
|
||||
- [ ] Tutorials/cross-links: ensure the Settings tutorial (if any) and tab tour mention the
|
||||
- [x] Tutorials/cross-links: ensure the Settings tutorial (if any) and tab tour mention the
|
||||
panel; `tour.*`/`settings.*` i18n keys in all 3 locales.
|
||||
|
||||
## Files to Modify/Create
|
||||
@@ -63,12 +63,36 @@ docs/tutorials.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows frontend conventions (i18n ×3, icons, selectors, CSS vars, settings pattern)
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes (`tsc --noEmit` + `npm run build`)
|
||||
- [ ] Manual smoke: widget live-updates + links; settings save/clear/export; docs updated
|
||||
- [x] All tasks completed
|
||||
- [x] Code follows frontend conventions (i18n ×3, icons, selectors, CSS vars, settings pattern)
|
||||
- [x] No unintended side effects
|
||||
- [x] Build passes (`tsc --noEmit` + `npm run build`)
|
||||
- [ ] Manual smoke: widget live-updates + links; settings save/clear/export; docs updated (recommend at final review — requires server restart)
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
<!-- Final phase. Note anything for the final review / documentation writer. -->
|
||||
This is the **final implementation phase**. All six phases are complete. Notes for the final reviewer:
|
||||
|
||||
**What was implemented in Phase 6:**
|
||||
|
||||
- `features/dashboard.ts`: `_loadRecentActivityWidget()`, `_renderRecentActivityList()`, `_startRecentActivityLive()` — SSE live-update listener (`server:activity_logged`) with cap-to-5 prepend logic. Widget appended after `getOrderedSections()` loop (outside the layout toggle system — always visible). Non-blocking: `.catch()` on the async load call.
|
||||
- `features/settings.ts`: `loadActivityLogSettings()`, `saveActivityLogSettings()`, `activityLogSettingsExport(format)`, `clearActivityLog()` — all exported and exposed on `window` via `app.ts` + `global.d.ts`.
|
||||
- `templates/modals/settings.html`: Activity Log rail entry (System group, cyan channel) + full panel (`id="settings-panel-activity_log"`) with enabled toggle, `max_days`/`max_entries` inputs, Save, CSV/JSON export, Clear (danger zone). Audit-vs-debug distinction note with cross-links in both directions (`closeSettingsModal(); openLogOverlay()` and `closeSettingsModal(); switchTab('activity_log')`).
|
||||
- `static/css/activity-log.css`: `.dal-*` dashboard widget styles + `.ds-info-note` / `.ds-inline-link` settings panel utilities appended (no new CSS file).
|
||||
- `static/locales/{en,ru,zh}.json`: 32 new keys each under `dashboard.section.recent_activity`, `dashboard.recent_activity.*`, `settings.tab.activity_log`, `settings.activity_log.*`.
|
||||
- `README.md`: "### Activity Log" section documenting tab, retention settings, export, and audit-vs-debug distinction.
|
||||
|
||||
**Reused Phase 5 helpers (no duplication):**
|
||||
|
||||
- `fetchRecentEntries(limit)` and `renderCompactEntry(entry)` — new public exports added to `activity-log.ts` in Phase 6 (not duplicated in dashboard.ts).
|
||||
- All `.al-*` CSS classes from Phase 5 are reused in the compact rows inside the widget.
|
||||
|
||||
**Build verification:** `tsc --noEmit` clean, `npm run build` passed (2.8 MB bundle, 258 ms) at time of implementation.
|
||||
|
||||
**Remaining manual smoke test (requires server restart):**
|
||||
|
||||
- Dashboard widget loads recent entries, prepends live on new activity, "View all →" switches to Activity tab.
|
||||
- Settings panel reads/writes retention, Save shows toast, Clear prompts confirm then deletes, Export downloads authed blob.
|
||||
- Cross-links: note in Settings opens Log Viewer overlay; note in Log Viewer links back to Activity tab.
|
||||
|
||||
**Outstanding open note from PLAN.md:** The manual browser smoke test across the whole feature (P5 note) remains open — it requires a server restart to exercise the live API endpoints. Recommend as first step in the final review session.
|
||||
|
||||
@@ -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