From 9a0137fa4cc2f6a68ec5e4762e8acbb56179bec7 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 9 Jun 2026 20:42:44 +0300 Subject: [PATCH] feat(activity-log): phase 5 - Activity tab (smart filtering, live updates, export) - new top-level Activity tab: filter toolbar (category/severity chips, presets, debounced search, actor/entity/date), keyset load-more, expandable detail - live prepend via server:activity_logged; authed CSV/JSON blob export - formatTimestamp/formatRelativeTime in core/ui.ts; history+severity SVG icons; Ctrl+7 shortcut - i18n activity_log.* across en/ru/zh; getting-started tutorial step; activity-log.css (themed) - review fixes: newest-first ordering, attribute-context XSS hardening (_escapeAttr + event delegation) --- plans/activity-log/CONTEXT.md | 1 + plans/activity-log/PLAN.md | 6 +- plans/activity-log/phase-5-frontend-tab.md | 124 +++- .../src/ledgrab/static/css/activity-log.css | 626 ++++++++++++++++ server/src/ledgrab/static/css/all.css | 1 + server/src/ledgrab/static/js/app.ts | 36 +- .../src/ledgrab/static/js/core/icon-paths.ts | 11 + server/src/ledgrab/static/js/core/icons.ts | 9 + .../ledgrab/static/js/core/tab-registry.ts | 1 + server/src/ledgrab/static/js/core/ui.ts | 45 ++ .../static/js/features/activity-log.ts | 700 ++++++++++++++++++ .../ledgrab/static/js/features/tutorials.ts | 1 + server/src/ledgrab/static/js/global.d.ts | 16 + server/src/ledgrab/static/locales/en.json | 59 +- server/src/ledgrab/static/locales/ru.json | 59 +- server/src/ledgrab/static/locales/zh.json | 59 +- server/src/ledgrab/templates/index.html | 4 + 17 files changed, 1714 insertions(+), 44 deletions(-) create mode 100644 server/src/ledgrab/static/css/activity-log.css create mode 100644 server/src/ledgrab/static/js/features/activity-log.ts diff --git a/plans/activity-log/CONTEXT.md b/plans/activity-log/CONTEXT.md index 1009f54..e4de9a3 100644 --- a/plans/activity-log/CONTEXT.md +++ b/plans/activity-log/CONTEXT.md @@ -69,3 +69,4 @@ Phase 1 landed (2026-06-09): `activity_log.py` (dataclass + enums + filters + co Phase 2 landed (2026-06-09): `core/activity_log/` package (`context.py`, `recorder.py`, `retention.py`, `__init__.py`); actor ContextVar set in `api/auth.py` (both branches); `ActivityLogRetentionEngine` mirroring AutoBackupEngine; full wiring in `main.py` (repo at module level, recorder+engine in lifespan, `server.shutting_down` first shutdown action, engine stop before db.close); DI getters in `api/dependencies.py`; `activity_logged` added to `_ALLOWED_SERVER_EVENT_TYPES` in `events-ws.ts`; `set_module_recorder` exposes recorder to non-DI sites; 24 new tests — all green. Full suite 2309 passed, 2 skipped, 0 failed. Ruff clean. 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. diff --git a/plans/activity-log/PLAN.md b/plans/activity-log/PLAN.md index 54fc375..d77d58c 100644 --- a/plans/activity-log/PLAN.md +++ b/plans/activity-log/PLAN.md @@ -83,7 +83,7 @@ is an on-demand CSV/JSON **export** (no separate backup subsystem). | Phase 2: Recorder/Retention | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ | | Phase 3: Instrumentation | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ | | Phase 4: REST API | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ | -| Phase 5: Frontend tab | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 5: Frontend tab | frontend | ✅ Done | ✅ Passed | ✅ Passed (tsc+build) | ✅ | | Phase 6: Dashboard/Settings | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | ## Outstanding Warnings @@ -99,6 +99,10 @@ is an on-demand CSV/JSON **export** (no separate backup subsystem). | 4 | PUT /settings only AuthRequired → anon could disable auditing/prune trail | 🟠 High (security) | resolved — `require_authenticated` on settings PUT | | 4 | CSV formula-injection missed leading TAB/CR | 🟡 Medium (security) | resolved — added `\t`/`\r` to guard | | 4 | `total` count full-scans on every list request | 🔵 Low (perf) | accepted — bounded by retention; read-only; optional opt-in deferred | +| 5 | Inverted list ordering broke pagination + live-append | 🔴 Blocker | resolved — pages reversed to newest-first; re-review PASS | +| 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) | ## Final Review diff --git a/plans/activity-log/phase-5-frontend-tab.md b/plans/activity-log/phase-5-frontend-tab.md index 0e2a509..2d88c42 100644 --- a/plans/activity-log/phase-5-frontend-tab.md +++ b/plans/activity-log/phase-5-frontend-tab.md @@ -1,6 +1,6 @@ # Phase 5: Frontend — Activity tab + smart filtering + live updates -**Status:** ⬜ Not Started +**Status:** ✅ Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** frontend · uses the `frontend-design` skill @@ -12,14 +12,14 @@ This is a viewer (Dashboard-style), NOT a CRUD card section. ## Tasks -- [ ] `core/ui.ts`: add `formatTimestamp(isoOrMs)` (Today/Yesterday/Date · HH:MM, i18n-aware) +- [x] `core/ui.ts`: add `formatTimestamp(isoOrMs)` (Today/Yesterday/Date · HH:MM, i18n-aware) and `formatRelativeTime(isoOrMs)` ("2m ago"), with `tabular-nums` styling guidance for the list. Reuse existing `time.*` i18n key conventions. -- [ ] `features/activity-log.ts`: +- [x] `features/activity-log.ts`: - `export async function loadActivityLog()` — fetch first page from `GET /activity-log` (via `fetchWithAuth`), render the toolbar + list into the panel. - - **Smart filter toolbar:** category (multi, IconSelect/chips), severity (chips), actor - (EntitySelect/text), entity type, date range, free-text search (debounced). Quick presets: + - **Smart filter toolbar:** category (multi, chips), severity (chips), actor + (text input), entity type, date range, free-text search (debounced). Quick presets: Today / Errors / Auth / Devices. Filters drive server-side query params (no client-side filtering of a partial page). Re-query on change; reset cursor. - **List:** one row per entry — severity icon, category badge, relative time (title=absolute), @@ -32,60 +32,106 @@ This is a viewer (Dashboard-style), NOT a CRUD card section. - **Export button:** triggers `GET /activity-log/export?format=…` with current filters via an authed blob download (use `fetchWithAuth` → blob URL, per frontend.md auth rules). - Empty / loading / error states; re-render on `languageChanged`. -- [ ] Tab wiring: +- [x] Tab wiring: - `core/tab-registry.ts`: add `activity_log: { loadFnName: 'loadActivityLog', autoRefresh: false }`. - `templates/index.html`: sidebar tab button (`data-tab`, `switchTab('activity_log')`, history/clock SVG icon, `data-i18n`) + `
`. - `app.ts`: import + `Object.assign(window, { loadActivityLog, … })`; `global.d.ts` decls. -- [ ] Icons: add a history/audit icon to `core/icon-paths.ts` + `core/icons.ts`; severity icons +- [x] Icons: add a history/audit icon to `core/icon-paths.ts` + `core/icons.ts`; severity icons (info/warning/error) reuse existing constants where possible. -- [ ] i18n: add `activity_log.*` keys to `static/locales/{en,ru,zh}.json` (title, filter labels, +- [x] i18n: add `activity_log.*` keys to `static/locales/{en,ru,zh}.json` (title, filter labels, category/severity names, column labels, presets, empty/error, export, "N entries"). -- [ ] Tutorials: add an Activity-tab step to the getting-started tour in +- [x] Tutorials: add an Activity-tab step to the getting-started tour in `features/tutorials.ts` + `tour.*` keys in all 3 locales. ## Files to Modify/Create -- `server/src/ledgrab/static/js/core/ui.ts` — modify: timestamp/relative-time formatters -- `server/src/ledgrab/static/js/features/activity-log.ts` — new: the viewer -- `server/src/ledgrab/static/js/core/tab-registry.ts` — modify: register tab -- `server/src/ledgrab/templates/index.html` — modify: tab button + panel -- `server/src/ledgrab/static/js/app.ts` — modify: import + window globals -- `server/src/ledgrab/static/js/global.d.ts` — modify: window decls -- `server/src/ledgrab/static/js/core/icon-paths.ts` / `core/icons.ts` — modify: icons -- `server/src/ledgrab/static/locales/{en,ru,zh}.json` — modify: i18n keys -- `server/src/ledgrab/static/js/features/tutorials.ts` — modify: tour step -- `server/src/ledgrab/static/css/*` — modify/new: list + toolbar styling (follow base.css vars) +- `server/src/ledgrab/static/js/core/ui.ts` — modified: timestamp/relative-time formatters +- `server/src/ledgrab/static/js/features/activity-log.ts` — NEW: the viewer +- `server/src/ledgrab/static/js/core/tab-registry.ts` — modified: register tab +- `server/src/ledgrab/templates/index.html` — modified: tab button + panel +- `server/src/ledgrab/static/js/app.ts` — modified: import + window globals +- `server/src/ledgrab/static/js/global.d.ts` — modified: window decls +- `server/src/ledgrab/static/js/core/icon-paths.ts` — modified: icons (scrollText, circleAlert, info, filter, xCircle) +- `server/src/ledgrab/static/js/core/icons.ts` — modified: ICON_ACTIVITY_LOG, ICON_SEVERITY_*, ICON_FILTER, ICON_X_CIRCLE +- `server/src/ledgrab/static/locales/{en,ru,zh}.json` — modified: activity_log.* + time.* + tour.activity_log +- `server/src/ledgrab/static/js/features/tutorials.ts` — modified: gettingStartedSteps +- `server/src/ledgrab/static/css/activity-log.css` — NEW: list + toolbar styling +- `server/src/ledgrab/static/css/all.css` — modified: import activity-log.css ## Acceptance Criteria -- New **Activity** tab loads, lists entries, and paginates via keyset "load more". -- Filters hit server-side query params; quick presets work; free-text is debounced. -- New events append live via `server:activity_logged` and respect active filters. -- Export downloads CSV/JSON with auth, honoring current filters. -- Fully localized (en/ru/zh); empty/loading/error states; re-renders on language change. -- No plain `` (use chips); SVG icons only (no emoji). +- [x] `npx tsc --noEmit` clean; `npm run build` succeeds. ## Notes -- **Use the `frontend-design` skill** for the viewer layout, filter toolbar, and detail design - — aim for a polished, distinctive result consistent with the app's design language and CSS - variables in `static/css/base.css` (`--primary-color`, `--card-bg`, etc.). -- Models to mirror: `features/dashboard.ts` (non-card live viewer + load pattern), - `core/events-ws.ts` (`server:` dispatch), `core/entity-palette.ts` (EntitySelect), - `core/icon-select.ts` (IconSelect). Auth/blob download rules: `contexts/frontend.md`. -- Frontend-only phase → run `tsc --noEmit` + `npm run build`; do NOT run pytest/ruff. +- **Used the `frontend-design` skill** for the viewer layout, filter toolbar, and detail design. +- Models mirrored: `features/dashboard.ts` (live viewer pattern), `core/events-ws.ts` (WS dispatch), + `core/api.ts` (fetchWithAuth), `core/navigation.ts` (navigateToCard). +- Frontend-only phase — ran `tsc --noEmit` + `npm run build`; did NOT run pytest/ruff. ## Review Checklist -- [ ] All tasks completed -- [ ] Code follows frontend conventions (i18n ×3, icons, selectors, auth fetch, CSS vars) -- [ ] No unintended side effects -- [ ] Build passes (`tsc --noEmit` + `npm run build`) +- [x] All tasks completed +- [x] Code follows frontend conventions (i18n ×3, icons, auth fetch, CSS vars) +- [x] No unintended side effects +- [x] Build passes (`tsc --noEmit` + `npm run build`) - [ ] Manual smoke: tab loads, filters query server, live append, export ## Handoff to Next Phase - +### Reusable helpers for Phase 6 (Dashboard widget + Settings panel) + +**From `features/activity-log.ts`:** + +| Export | Purpose | How Phase 6 uses it | +|--------|---------|---------------------| +| `loadActivityLog()` | Loads the full tab (already registered as the tab loader) | Phase 6 doesn't call this, but it shares state | +| `activityLogToggleDetail(id)` | Expand/collapse an entry row | Dashboard widget can reuse for the Recent Activity list | +| `activityLogExport(format)` | Authed blob download with current filters | Could surface a direct link in the Settings panel | +| `_renderEntryRow(entry, isNew)` | Renders a single entry as an HTML string | Dashboard widget should import this to render its 5-10 most-recent entries | +| `_fetchPage(beforeSeq, append)` | Keyset-paged fetch from `/activity-log` | Dashboard widget calls with `limit=5&categories=…` for its mini-list | + +**Note:** `_renderEntryRow` and `_fetchPage` are module-private (underscore prefix). Phase 6 should +either (a) re-export them with public names, or (b) duplicate the minimal render logic for the +compact widget format. + +**Recommended approach for Phase 6:** Export two new public helpers: + +```typescript +// Add to activity-log.ts +export async function fetchRecentEntries(limit = 5): Promise +export function renderCompactEntry(entry: ActivityEntry): string +``` + +### i18n namespace + +All keys are under `activity_log.*`. Time-relative formatters use `time.*` keys. + +### CSS classes and tokens introduced + +| Class | Purpose | +|-------|---------| +| `.al-panel` | Tab root wrapper | +| `.al-toolbar` | Filter toolbar container | +| `.al-chip` / `.al-chip.active` | Category/severity toggle chips | +| `.al-preset-btn` | Quick-preset buttons | +| `.al-entry` / `.al-entry-row` | Log entry row | +| `.al-detail` / `.al-detail-grid` | Expandable entry detail | +| `.al-cat-*` | Per-category badge colors (auth/device/entity/capture/system) | +| `.al-sev-info/warning/error` | Severity icon color classes | +| `.al-live-dot` | Pulsing green live-update dot | +| `.al-meta-pre` | Scrollable metadata JSON block | +| `.tabular-nums` | `font-variant-numeric: tabular-nums` utility | + +### Settings endpoint shape used + +Phase 6 Settings panel will call: +- `GET /activity-log/settings` → `{ enabled: bool, max_days: int, max_entries: int }` +- `PUT /activity-log/settings` → same shape (bounds: enabled=bool, max_days=0–3650, max_entries=0–10_000_000) diff --git a/server/src/ledgrab/static/css/activity-log.css b/server/src/ledgrab/static/css/activity-log.css new file mode 100644 index 0000000..0a2d16b --- /dev/null +++ b/server/src/ledgrab/static/css/activity-log.css @@ -0,0 +1,626 @@ +/* ───────────────────────────────────────────────────────────────────────── + Activity Log — audit viewer tab + Design language: precision-instrument / ledger. Monospaced timestamps, + color-coded severity rail, thin category pills. Clean "terminal" feel + without being cold — the primary green accent anchors the live-update dot. + ───────────────────────────────────────────────────────────────────────── */ + +/* ── Panel wrapper ───────────────────────────────────────────────────────── */ + +.al-panel { + display: flex; + flex-direction: column; + gap: 0; + max-width: 1400px; + margin: 0 auto; + padding: var(--space-lg) var(--space-lg) var(--space-xl); + min-height: 0; +} + +/* ── Header ──────────────────────────────────────────────────────────────── */ + +.al-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-md); + padding-bottom: var(--space-md); + border-bottom: var(--lux-hairline) solid var(--border-color); + margin-bottom: var(--space-lg); +} + +.al-header-title { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.al-header-icon .icon { + width: 22px; + height: 22px; + color: var(--primary-color); + flex-shrink: 0; +} + +.al-header-title h2 { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-color); + letter-spacing: -0.01em; +} + +.al-header-subtitle { + font-size: 0.8125rem; + color: var(--text-secondary); + margin: 0; + width: 100%; +} + +/* ── Filter toolbar ──────────────────────────────────────────────────────── */ + +.al-toolbar { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-md) var(--space-md); + background: var(--bg-secondary); + border: var(--lux-hairline) solid var(--border-color); + border-radius: var(--radius-md); + margin-bottom: var(--space-md); +} + +.al-toolbar-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.al-toolbar-search { + gap: var(--space-sm); +} + +/* Search input */ +.al-search-wrap { + position: relative; + flex: 1; + min-width: 200px; + max-width: 420px; +} + +.al-search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: var(--text-muted); + display: flex; + align-items: center; +} + +.al-search-icon .icon { + width: 15px; + height: 15px; +} + +.al-search-input { + width: 100%; + padding: 6px 10px 6px 34px; + background: var(--card-bg); + border: var(--lux-hairline) solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-color); + font-size: 0.875rem; + font-family: var(--font-body); + transition: border-color var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out); + outline: none; +} + +.al-search-input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15); +} + +.al-search-input::placeholder { color: var(--text-muted); } + +/* Quick presets */ +.al-presets { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.al-preset-btn { + padding: 4px 10px; + font-size: 0.75rem; + font-weight: 500; + background: var(--card-bg); + border: var(--lux-hairline) solid var(--border-color); + border-radius: var(--radius-pill); + color: var(--text-secondary); + cursor: pointer; + transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast); + white-space: nowrap; +} + +.al-preset-btn:hover { + background: var(--primary-color); + border-color: var(--primary-color); + color: var(--primary-contrast); +} + +/* Clear button */ +.al-clear-btn { + padding: 4px 6px; + margin-left: auto; + color: var(--text-secondary); +} + +.al-clear-btn:hover { color: var(--danger-color); border-color: var(--danger-color); } + +/* Export button + dropdown */ +.al-export-wrap { + position: relative; + margin-left: auto; +} + +.al-export-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 5px 12px; + font-size: 0.8125rem; +} + +.al-export-btn .icon { width: 14px; height: 14px; } + +.al-export-menu { + display: none; + position: absolute; + right: 0; + top: calc(100% + 4px); + background: var(--card-bg); + border: var(--lux-hairline) solid var(--border-color); + border-radius: var(--radius-sm); + box-shadow: 0 4px 12px var(--shadow-color); + z-index: 100; + min-width: 140px; + overflow: hidden; +} + +.al-export-wrap:hover .al-export-menu, +.al-export-wrap:focus-within .al-export-menu { display: block; } + +.al-export-menu button { + display: block; + width: 100%; + padding: 8px 14px; + text-align: left; + background: none; + border: none; + color: var(--text-color); + font-size: 0.8125rem; + cursor: pointer; +} + +.al-export-menu button:hover { background: var(--bg-secondary); } + +/* Filter label */ +.al-filter-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + flex-shrink: 0; +} + +.al-filter-label-sep { margin-left: var(--space-sm); } + +/* Category / severity chips */ +.al-chip-group { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.al-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 9px; + font-size: 0.75rem; + font-weight: 500; + border: var(--lux-hairline) solid var(--border-color); + border-radius: var(--radius-pill); + background: var(--card-bg); + color: var(--text-secondary); + cursor: pointer; + transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast); + white-space: nowrap; +} + +.al-chip .icon { width: 12px; height: 12px; } + +.al-chip:hover { + background: var(--bg-secondary); + border-color: var(--text-secondary); + color: var(--text-color); +} + +.al-chip.active { + background: var(--primary-color); + border-color: var(--primary-color); + color: var(--primary-contrast); +} + +/* Severity chip colors when active */ +.al-sev-chip-error.active { background: var(--danger-color); border-color: var(--danger-color); } +.al-sev-chip-warning.active { background: var(--warning-color); border-color: var(--warning-color); } +.al-sev-chip-info.active { background: var(--info-color); border-color: var(--info-color); } + +/* Advanced field row */ +.al-toolbar-advanced { + gap: var(--space-sm); + padding-top: var(--space-xs); + border-top: var(--lux-hairline) solid var(--border-color); +} + +.al-field-group { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 160px; + flex: 1; +} + +.al-field-label { + font-size: 0.6875rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.al-field-input { + padding: 4px 8px; + background: var(--card-bg); + border: var(--lux-hairline) solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-color); + font-size: 0.8125rem; + font-family: var(--font-body); + outline: none; + transition: border-color var(--duration-fast); +} + +.al-field-input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.12); +} + +/* ── List header (count + live dot) ─────────────────────────────────────── */ + +.al-list-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-xs) 0; + margin-bottom: var(--space-xs); +} + +.al-count { + font-size: 0.8125rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.al-live-indicator { + display: flex; + align-items: center; + gap: 5px; + font-size: 0.75rem; + font-weight: 500; + color: var(--primary-text-color); + letter-spacing: 0.02em; +} + +.al-live-dot { + width: 7px; + height: 7px; + background: var(--primary-color); + border-radius: 50%; + animation: al-pulse 2s infinite; +} + +@keyframes al-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.55; transform: scale(0.85); } +} + +/* ── Entry rows ─────────────────────────────────────────────────────────── */ + +.al-list { + display: flex; + flex-direction: column; + gap: 1px; +} + +.al-entry { + background: var(--card-bg); + border: var(--lux-hairline) solid var(--border-color); + border-radius: var(--radius-sm); + overflow: hidden; + transition: border-color var(--duration-fast); +} + +.al-entry:hover { border-color: var(--text-muted); } + +/* New-entry flash */ +@keyframes al-new-flash { + 0% { background: color-mix(in srgb, var(--primary-color) 18%, var(--card-bg)); } + 100% { background: var(--card-bg); } +} + +.al-entry-new.al-entry-appear { animation: al-new-flash 1.8s var(--ease-out) forwards; } + +.al-entry-row { + display: grid; + grid-template-columns: 24px 80px auto 1fr 2fr auto 20px; + align-items: center; + gap: var(--space-sm); + padding: var(--space-xs) var(--space-md); + cursor: pointer; + min-height: 36px; + outline: none; +} + +.al-entry-row:focus-visible { box-shadow: inset 0 0 0 2px var(--primary-color); } + +/* Severity rail icon */ +.al-sev { display: flex; align-items: center; justify-content: center; } +.al-sev .icon { width: 14px; height: 14px; } +.al-sev-info .icon { color: var(--info-color); } +.al-sev-warning .icon { color: var(--warning-color); } +.al-sev-error .icon { color: var(--danger-color); } + +/* Time */ +.al-time { + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + font-family: var(--font-mono); + color: var(--text-muted); + white-space: nowrap; +} + +/* Category badge */ +.al-cat-badge { + display: inline-block; + padding: 1px 7px; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + border-radius: var(--radius-pill); + white-space: nowrap; + border: var(--lux-hairline) solid transparent; +} + +/* Per-category colors — subtle tinted backgrounds */ +.al-cat-auth { background: rgba(33, 150, 243, 0.12); color: var(--info-color); border-color: rgba(33, 150, 243, 0.25); } +.al-cat-device { background: rgba(156, 39, 176, 0.10); color: #ab47bc; border-color: rgba(156, 39, 176, 0.22); } +.al-cat-entity { background: rgba(76, 175, 80, 0.12); color: var(--primary-text-color); border-color: rgba(76, 175, 80, 0.25); } +.al-cat-capture { background: rgba(255, 152, 0, 0.12); color: var(--warning-color); border-color: rgba(255, 152, 0, 0.25); } +.al-cat-system { background: rgba(120, 120, 120, 0.12); color: var(--text-secondary); border-color: rgba(120, 120, 120, 0.25); } + +[data-theme="light"] .al-cat-auth { background: rgba(33, 150, 243, 0.08); } +[data-theme="light"] .al-cat-device { background: rgba(156, 39, 176, 0.08); } +[data-theme="light"] .al-cat-entity { background: rgba(76, 175, 80, 0.08); } +[data-theme="light"] .al-cat-capture { background: rgba(255, 152, 0, 0.08); } +[data-theme="light"] .al-cat-system { background: rgba(120, 120, 120, 0.08); } + +/* Actor */ +.al-actor { + font-size: 0.8125rem; + font-family: var(--font-mono); + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 160px; +} + +/* Message */ +.al-msg { + font-size: 0.8125rem; + color: var(--text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Entity crosslink */ +.al-entity { display: flex; align-items: center; } + +.al-entity-link { + font-size: 0.75rem; + color: var(--primary-text-color); + background: none; + border: none; + padding: 0; + cursor: pointer; + text-decoration: underline dotted; + text-underline-offset: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; + display: block; +} + +.al-entity-link:hover { color: var(--primary-hover); text-decoration-style: solid; } + +.al-entity-name { + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Expand chevron */ +.al-expand-chevron { + font-size: 0.6875rem; + color: var(--text-muted); + justify-self: end; + user-select: none; +} + +/* ── Entry detail drawer ─────────────────────────────────────────────────── */ + +.al-detail { + padding: var(--space-sm) var(--space-md) var(--space-md); + border-top: var(--lux-hairline) solid var(--border-color); + background: var(--bg-secondary); + animation: al-detail-open var(--duration-fast) var(--ease-out); +} + +@keyframes al-detail-open { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.al-detail-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 4px var(--space-md); + font-size: 0.8125rem; +} + +.al-detail-grid dt { + color: var(--text-muted); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + align-self: start; + padding-top: 2px; + white-space: nowrap; +} + +.al-detail-grid dd { + color: var(--text-color); + word-break: break-all; +} + +.al-detail-grid code { + font-family: var(--font-mono); + font-size: 0.8125rem; + background: var(--bg-color); + padding: 1px 5px; + border-radius: var(--radius-sm); + border: var(--lux-hairline) solid var(--border-color); +} + +.al-meta-pre { + font-family: var(--font-mono); + font-size: 0.75rem; + background: var(--bg-color); + border: var(--lux-hairline) solid var(--border-color); + border-radius: var(--radius-sm); + padding: var(--space-sm); + overflow-x: auto; + white-space: pre; + color: var(--text-secondary); + max-height: 220px; + overflow-y: auto; + margin: 0; +} + +/* ── Load More ───────────────────────────────────────────────────────────── */ + +.al-load-more { + display: block; + width: 100%; + margin-top: var(--space-md); + text-align: center; + font-size: 0.875rem; +} + +/* ── Empty / loading / error states ─────────────────────────────────────── */ + +.al-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: var(--space-xl); + color: var(--text-muted); + font-size: 0.875rem; + text-align: center; +} + +.al-state-icon .icon { + width: 36px; + height: 36px; + opacity: 0.35; +} + +.al-loading { flex-direction: row; padding: var(--space-lg); } + +.al-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-color); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: al-spin 0.6s linear infinite; + flex-shrink: 0; +} + +@keyframes al-spin { to { transform: rotate(360deg); } } + +.al-error .al-state-icon .icon { color: var(--danger-color); opacity: 0.6; } + +/* ── List container ──────────────────────────────────────────────────────── */ + +.al-list-container { + flex: 1; + min-height: 0; +} + +/* ── Tabular-nums utility ────────────────────────────────────────────────── */ + +.tabular-nums { font-variant-numeric: tabular-nums; } + +/* ── Responsive ──────────────────────────────────────────────────────────── */ + +@media (max-width: 900px) { + .al-entry-row { + grid-template-columns: 20px 70px auto 1fr 18px; + } + /* Hide actor and entity link at small widths */ + .al-actor, + .al-entity { display: none; } +} + +@media (max-width: 600px) { + .al-panel { padding: var(--space-sm); } + + .al-entry-row { + grid-template-columns: 20px 1fr auto 18px; + grid-template-rows: auto auto; + gap: 4px var(--space-xs); + } + + .al-time { grid-column: 2; grid-row: 2; font-size: 0.6875rem; } + .al-cat-badge{ grid-column: 3; grid-row: 1; } + .al-msg { grid-column: 2 / span 2; grid-row: 1; } + + .al-toolbar-advanced .al-field-group { min-width: 100%; } +} diff --git a/server/src/ledgrab/static/css/all.css b/server/src/ledgrab/static/css/all.css index 0e01d61..43f828e 100644 --- a/server/src/ledgrab/static/css/all.css +++ b/server/src/ledgrab/static/css/all.css @@ -19,5 +19,6 @@ @import './graph-editor.css'; @import './appearance.css'; @import './game-integration.css'; +@import './activity-log.css'; @import './mobile.css'; @import './tv.css'; diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 9c018d5..40ed66d 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -228,6 +228,24 @@ import { mountAutoCalibration, unmountAutoCalibration, } from './features/auto-calibration.ts'; +// Layer 5: activity log +import { + loadActivityLog, + activityLogToggleDetail, + activityLogToggleCat, + activityLogToggleSev, + activityLogOnSearch, + activityLogOnActor, + activityLogOnEntityType, + activityLogOnSince, + activityLogOnUntil, + activityLogClearFilters, + activityLogPreset, + activityLogLoadMore, + activityLogExport, + activityLogNavigateToEntity, +} from './features/activity-log.ts'; + // Layer 5.5: graph editor import { loadGraphEditor, @@ -762,6 +780,22 @@ Object.assign(window, { applyStylePreset, applyBgEffect, renderAppearanceTab, + + // activity log + loadActivityLog, + activityLogToggleDetail, + activityLogToggleCat, + activityLogToggleSev, + activityLogOnSearch, + activityLogOnActor, + activityLogOnEntityType, + activityLogOnSince, + activityLogOnUntil, + activityLogClearFilters, + activityLogPreset, + activityLogLoadMore, + activityLogExport, + activityLogNavigateToEntity, }); // ─── Global keyboard shortcuts ─── @@ -779,7 +813,7 @@ document.addEventListener('keydown', (e) => { // Tab shortcuts: Ctrl+1..4 (skip when typing in inputs) if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) { - const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams', '5': 'integrations', '6': 'graph' }; + const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams', '5': 'integrations', '6': 'graph', '7': 'activity_log' }; const tab = tabMap[e.key]; if (tab) { e.preventDefault(); diff --git a/server/src/ledgrab/static/js/core/icon-paths.ts b/server/src/ledgrab/static/js/core/icon-paths.ts index 9a196a4..15f5871 100644 --- a/server/src/ledgrab/static/js/core/icon-paths.ts +++ b/server/src/ledgrab/static/js/core/icon-paths.ts @@ -135,6 +135,17 @@ export const armchair = ' // Lucide: leaf export const leaf = ''; +// Lucide: scroll-text (audit / activity log) +export const scrollText = ''; +// Lucide: circle-alert (error severity) +export const circleAlert = ''; +// Lucide: info (info severity) +export const info = ''; +// Lucide: filter (filter toolbar) +export const filter = ''; +// Lucide: x-circle (clear/reset) +export const xCircle = ''; + // Easing curve glyphs — custom mini-charts that draw the actual curve. // Curve travels from (4, 20) to (20, 4); each path renders the easing // function directly so the picker shows the shape, not a metaphor. diff --git a/server/src/ledgrab/static/js/core/icons.ts b/server/src/ledgrab/static/js/core/icons.ts index f8aed9e..2744c3b 100644 --- a/server/src/ledgrab/static/js/core/icons.ts +++ b/server/src/ledgrab/static/js/core/icons.ts @@ -354,6 +354,15 @@ export const ICON_CIRCLE = _svg(P.circle); export const ICON_GIT_MERGE = _svg(P.gitMerge); export const ICON_COPY = _svg(P.copy); +// ── Activity log icons ───────────────────────────────────── + +export const ICON_ACTIVITY_LOG = _svg(P.scrollText); +export const ICON_SEVERITY_INFO = _svg(P.info); +export const ICON_SEVERITY_WARN = _svg(P.triangleAlert); +export const ICON_SEVERITY_ERR = _svg(P.circleAlert); +export const ICON_FILTER = _svg(P.filter); +export const ICON_X_CIRCLE = _svg(P.xCircle); + // ── Game integration icons ───────────────────────────────── export const ICON_GAMEPAD = _svg(P.gamepad2); diff --git a/server/src/ledgrab/static/js/core/tab-registry.ts b/server/src/ledgrab/static/js/core/tab-registry.ts index c63dd74..b13ea16 100644 --- a/server/src/ledgrab/static/js/core/tab-registry.ts +++ b/server/src/ledgrab/static/js/core/tab-registry.ts @@ -28,6 +28,7 @@ const TAB_REGISTRY: Readonly> = { automations: { loadFnName: 'loadAutomations', subTab: { storageKey: 'activeAutomationTab', defaultSubTab: 'automations', switchFnName: 'switchAutomationTab' } }, graph: { loadFnName: 'loadGraphEditor' }, + activity_log: { loadFnName: 'loadActivityLog', autoRefresh: false }, }; /** Get the full config for a tab, or undefined if not registered. */ diff --git a/server/src/ledgrab/static/js/core/ui.ts b/server/src/ledgrab/static/js/core/ui.ts index 39b7366..a1c3a50 100644 --- a/server/src/ledgrab/static/js/core/ui.ts +++ b/server/src/ledgrab/static/js/core/ui.ts @@ -555,6 +555,51 @@ export function formatCompact(n: number | null | undefined) { return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B'; } +/** + * Format an ISO-8601 timestamp (or ms epoch) into a locale-aware string. + * Returns "Today · HH:MM", "Yesterday · HH:MM", or "DD MMM · HH:MM". + * Use `font-variant-numeric: tabular-nums` on the element for stable layout. + */ +export function formatTimestamp(isoOrMs: string | number): string { + const d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs); + if (isNaN(d.getTime())) return String(isoOrMs); + + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yestStart = new Date(todayStart.getTime() - 86400000); + + const hhmm = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }); + + if (d >= todayStart) { + return `${t('time.today')} · ${hhmm}`; + } else if (d >= yestStart) { + return `${t('time.yesterday')} · ${hhmm}`; + } else { + const dateStr = d.toLocaleDateString([], { day: 'numeric', month: 'short' }); + return `${dateStr} · ${hhmm}`; + } +} + +/** + * Format an ISO-8601 timestamp (or ms epoch) as a compact relative string. + * Examples: "just now", "2m ago", "3h ago", "5d ago". + * Use font-variant-numeric: tabular-nums on elements that update frequently. + */ +export function formatRelativeTime(isoOrMs: string | number): string { + const d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs); + if (isNaN(d.getTime())) return String(isoOrMs); + + const diffSec = Math.floor((Date.now() - d.getTime()) / 1000); + if (diffSec < 10) return t('time.just_now'); + if (diffSec < 60) return t('time.seconds_ago', { n: diffSec }); + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return t('time.minutes_ago', { n: diffMin }); + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return t('time.hours_ago', { n: diffHr }); + const diffDays = Math.floor(diffHr / 24); + return t('time.days_ago', { n: diffDays }); +} + export function formatUptime(seconds: number | null | undefined): string { if (!seconds || seconds <= 0) return '-'; const total = Math.floor(seconds); diff --git a/server/src/ledgrab/static/js/features/activity-log.ts b/server/src/ledgrab/static/js/features/activity-log.ts new file mode 100644 index 0000000..a942890 --- /dev/null +++ b/server/src/ledgrab/static/js/features/activity-log.ts @@ -0,0 +1,700 @@ +/** + * Activity Log tab — persistent, queryable audit log viewer. + * + * This is a READ-ONLY viewer (no CRUD), differentiated from the debug + * Log Viewer (utils/log_broadcaster.py) which is an ephemeral 500-line tail. + * This tab shows structured, semantic audit entries backed by the SQLite + * activity_log table. + * + * Phase 5: Activity tab + smart filtering + live updates. + */ + +import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { t } from '../core/i18n.ts'; +import { showToast, formatRelativeTime } from '../core/ui.ts'; +import { navigateToCard } from '../core/navigation.ts'; +import { + ICON_ACTIVITY_LOG, ICON_SEVERITY_INFO, ICON_SEVERITY_WARN, ICON_SEVERITY_ERR, + ICON_X_CIRCLE, ICON_DOWNLOAD, ICON_SEARCH, + ICON_CHEVRON_UP, ICON_CHEVRON_DOWN, +} from '../core/icons.ts'; + +/** + * Escape a string for safe use inside an HTML attribute value (quoted with + * either `"` or `'`). Extends escapeHtml's `<>&` coverage with `"` and `'`. + */ +function _escapeAttr(text: string): string { + if (!text) return ''; + return escapeHtml(text) + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// ─── Types ─────────────────────────────────────────────────── + +interface ActivityEntry { + id: string; + ts: string; + category: string; + action: string; + severity: string; + actor: string; + entity_type: string | null; + entity_id: string | null; + entity_name: string | null; + message: string; + metadata: Record; +} + +interface ActivityPage { + entries: ActivityEntry[]; + next_before_seq: number | null; + has_more: boolean; + total: number; +} + +interface ActiveFilters { + categories: string[]; + severities: string[]; + actor: string; + entity_type: string; + since: string; + until: string; + q: string; +} + +// ─── Module state ──────────────────────────────────────────── + +let _initialized = false; +let _loading = false; +let _entries: ActivityEntry[] = []; +let _nextBeforeSeq: number | null = null; +let _hasMore = false; +let _total = 0; +let _expandedIds = new Set(); +let _debounceTimer: ReturnType | null = null; +let _liveEventListener: ((e: Event) => void) | null = null; + +const _filters: ActiveFilters = { + categories: [], + severities: [], + actor: '', + entity_type: '', + since: '', + until: '', + q: '', +}; + +// ─── Category → navigation target map (entity crosslinks) ── + +const _ENTITY_NAV: Record = { + output_target: { tab: 'targets', subTab: 'led-targets', attr: 'data-target-id' }, + led_device: { tab: 'targets', subTab: 'led-devices', attr: 'data-device-id' }, + picture_source: { tab: 'streams', subTab: 'raw', attr: 'data-stream-id' }, + color_strip: { tab: 'streams', subTab: 'color_strip', attr: 'data-strip-id' }, + audio_source: { tab: 'streams', subTab: 'audio_capture', attr: 'data-audio-source-id' }, + automation: { tab: 'automations', subTab: 'automations', attr: 'data-automation-id' }, + scene_preset: { tab: 'automations', subTab: 'scenes', attr: 'data-scene-id' }, +}; + +// ─── Severity icon helper ──────────────────────────────────── + +function _severityIcon(severity: string): string { + if (severity === 'error') return ICON_SEVERITY_ERR; + if (severity === 'warning') return ICON_SEVERITY_WARN; + return ICON_SEVERITY_INFO; +} + +function _severityClass(severity: string): string { + if (severity === 'error') return 'al-sev-error'; + if (severity === 'warning') return 'al-sev-warning'; + return 'al-sev-info'; +} + +// ─── Category label helper ─────────────────────────────────── + +function _categoryLabel(category: string): string { + return t(`activity_log.category.${category}`); +} + +// ─── Build query string from active filters + cursor ──────── + +function _buildQuery(beforeSeq: number | null = null): string { + const params = new URLSearchParams(); + for (const cat of _filters.categories) params.append('categories', cat); + for (const sev of _filters.severities) params.append('severities', sev); + if (_filters.actor) params.set('actor', _filters.actor); + if (_filters.entity_type) params.set('entity_type', _filters.entity_type); + if (_filters.since) params.set('since', _filters.since); + if (_filters.until) params.set('until', _filters.until); + if (_filters.q) params.set('q', _filters.q); + if (beforeSeq != null) params.set('before_seq', String(beforeSeq)); + params.set('limit', '50'); + const qs = params.toString(); + return qs ? `?${qs}` : ''; +} + +// ─── Entry rendering ───────────────────────────────────────── + +function _renderEntryRow(entry: ActivityEntry, isNew = false): string { + const relTime = formatRelativeTime(entry.ts); + const iso = entry.ts; + const expanded = _expandedIds.has(entry.id); + const sevClass = _severityClass(entry.severity); + const sevIcon = _severityIcon(entry.severity); + + // Entity crosslink — use data-* attributes + delegated listener (no JSON in onclick) + let entityHtml = ''; + if (entry.entity_type && entry.entity_name) { + const nav = _ENTITY_NAV[entry.entity_type]; + if (nav) { + const escapedName = escapeHtml(entry.entity_name); + const attrEntityType = _escapeAttr(entry.entity_type); + const attrEntityId = _escapeAttr(entry.entity_id || ''); + entityHtml = ``; + } else { + entityHtml = `${escapeHtml(entry.entity_name)}`; + } + } + + const detailHtml = expanded ? _renderEntryDetail(entry) : ''; + const attrEntryId = _escapeAttr(entry.id); + + return `
+
+ ${sevIcon} + ${escapeHtml(relTime)} + ${escapeHtml(_categoryLabel(entry.category))} + ${escapeHtml(entry.actor)} + ${escapeHtml(entry.message)} + ${entityHtml ? `${entityHtml}` : ''} + +
+ ${detailHtml} +
`; +} + +function _renderEntryDetail(entry: ActivityEntry): string { + const metaJson = JSON.stringify(entry.metadata, null, 2); + const absTime = entry.ts ? new Date(entry.ts).toLocaleString() : ''; + return `
+
+
${escapeHtml(t('activity_log.detail.id'))}
+
${escapeHtml(entry.id)}
+
${escapeHtml(t('activity_log.detail.timestamp'))}
+
${escapeHtml(absTime)}
+
${escapeHtml(t('activity_log.detail.action'))}
+
${escapeHtml(entry.action)}
+
${escapeHtml(t('activity_log.detail.actor'))}
+
${escapeHtml(entry.actor)}
+ ${entry.entity_type ? `
${escapeHtml(t('activity_log.detail.entity'))}
+
${escapeHtml(entry.entity_type)}${entry.entity_id ? ` / ${escapeHtml(entry.entity_id)}` : ''}${entry.entity_name ? ` (${escapeHtml(entry.entity_name)})` : ''}
` : ''} +
${escapeHtml(t('activity_log.detail.metadata'))}
+
${escapeHtml(metaJson)}
+
+
`; +} + +// ─── Filter toolbar rendering ──────────────────────────────── + +const CATEGORIES = ['auth', 'device', 'entity', 'capture', 'system']; +const SEVERITIES = ['info', 'warning', 'error']; + +function _renderFilterToolbar(): string { + const catChips = CATEGORIES.map(cat => { + const active = _filters.categories.includes(cat); + return ``; + }).join(''); + + const sevChips = SEVERITIES.map(sev => { + const active = _filters.severities.includes(sev); + const icon = _severityIcon(sev); + return ``; + }).join(''); + + const presets = [ + { key: 'today', label: t('activity_log.preset.today') }, + { key: 'errors', label: t('activity_log.preset.errors') }, + { key: 'auth', label: t('activity_log.preset.auth') }, + { key: 'devices', label: t('activity_log.preset.devices') }, + ]; + const presetBtns = presets.map(p => + `` + ).join(''); + + const hasFilters = _filters.categories.length || _filters.severities.length || + _filters.actor || _filters.entity_type || _filters.since || _filters.until || _filters.q; + + return ``; +} + +// ─── List and state rendering ──────────────────────────────── + +function _renderList(): string { + if (_loading && _entries.length === 0) { + return `
+
+ ${escapeHtml(t('activity_log.loading'))} +
`; + } + + if (_entries.length === 0) { + const hasFilters = _filters.categories.length || _filters.severities.length || + _filters.actor || _filters.entity_type || _filters.since || _filters.until || _filters.q; + const emptyKey = hasFilters ? 'activity_log.empty' : 'activity_log.empty_no_filters'; + return `
+ +

${escapeHtml(t(emptyKey))}

+
`; + } + + const rows = _entries.map(e => _renderEntryRow(e)).join(''); + const loadMore = _hasMore + ? `` + : ''; + + const countLabel = t('activity_log.n_entries', { n: _total }); + + return `
+ ${escapeHtml(countLabel)} +
+ + ${escapeHtml(t('activity_log.live'))} +
+
+
+ ${rows} +
+ ${loadMore}`; +} + +// ─── Delegated click handler for entry rows and entity links ─ + +let _delegatedClickAttached = false; + +function _attachDelegatedClicks(): void { + if (_delegatedClickAttached) return; + const panel = document.getElementById('tab-activity_log'); + if (!panel) return; + _delegatedClickAttached = true; + + panel.addEventListener('click', (e: MouseEvent) => { + const target = e.target as HTMLElement; + + // Entity navigation: click on data-entity-type button + const entityBtn = target.closest('button.al-entity-link[data-entity-type]'); + if (entityBtn) { + e.stopPropagation(); + const entityType = entityBtn.dataset.entityType ?? ''; + const entityId = entityBtn.dataset.entityId ?? ''; + activityLogNavigateToEntity(entityType, entityId); + return; + } + + // Entry row toggle: click on al-entry-row with data-toggle-id + const row = target.closest('.al-entry-row[data-toggle-id]'); + if (row) { + const entryId = row.dataset.toggleId ?? ''; + if (entryId) activityLogToggleDetail(entryId); + return; + } + }); + + panel.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const row = (e.target as HTMLElement).closest('.al-entry-row[data-toggle-id]'); + if (row) { + e.preventDefault(); + const entryId = row.dataset.toggleId ?? ''; + if (entryId) activityLogToggleDetail(entryId); + } + }); +} + +// ─── Full panel render ─────────────────────────────────────── + +function _render(): void { + const panel = document.getElementById('tab-activity_log'); + if (!panel) return; + + panel.innerHTML = `
+
+
+ +

${escapeHtml(t('activity_log.title'))}

+

${escapeHtml(t('activity_log.subtitle'))}

+
+
+ ${_renderFilterToolbar()} +
+ ${_renderList()} +
+
`; + + _attachDelegatedClicks(); +} + +// ─── Partial re-render helpers ─────────────────────────────── + +function _updateListContainer(): void { + const container = document.getElementById('al-list-container'); + if (!container) return; + container.innerHTML = _renderList(); +} + +// ─── Data fetching ─────────────────────────────────────────── + +async function _fetchPage(beforeSeq: number | null = null, append = false): Promise { + if (_loading) return; + _loading = true; + if (!append) { + _entries = []; + _nextBeforeSeq = null; + _hasMore = false; + } + _updateListContainer(); + + try { + const qs = _buildQuery(beforeSeq); + const res = await fetchWithAuth(`/activity-log${qs}`); + if (!res || !res.ok) { + throw new Error(`HTTP ${res?.status}`); + } + const page: ActivityPage = await res.json(); + // API returns each page oldest-first within the page; reverse to newest-first + // so the in-memory list is newest at index 0 (top of the rendered log). + const pageEntries = [...page.entries].reverse(); + if (append) { + _entries = [..._entries, ...pageEntries]; + } else { + _entries = pageEntries; + } + _nextBeforeSeq = page.next_before_seq; + _hasMore = page.has_more; + _total = page.total; + _updateListContainer(); + } catch (e: unknown) { + if (e && typeof e === 'object' && 'isAuth' in e) return; + const container = document.getElementById('al-list-container'); + if (container) { + container.innerHTML = ``; + } + } finally { + _loading = false; + } +} + +// ─── Filter re-query with debounce for text fields ─────────── + +function _requery(debounce = false): void { + if (debounce) { + if (_debounceTimer) clearTimeout(_debounceTimer); + _debounceTimer = setTimeout(() => { _fetchPage(null, false); }, 350); + } else { + _fetchPage(null, false); + } +} + +// ─── Live event handling ────────────────────────────────────── + +function _entryPassesFilters(entry: ActivityEntry): boolean { + if (_filters.categories.length && !_filters.categories.includes(entry.category)) return false; + if (_filters.severities.length && !_filters.severities.includes(entry.severity)) return false; + if (_filters.actor && entry.actor !== _filters.actor) return false; + if (_filters.entity_type && entry.entity_type !== _filters.entity_type) return false; + if (_filters.q) { + const q = _filters.q.toLowerCase(); + if (!entry.message.toLowerCase().includes(q) && + !entry.action.toLowerCase().includes(q) && + !entry.actor.toLowerCase().includes(q)) return false; + } + // Date range filters: if an entry is brand-new it passes "since" checks trivially + if (_filters.since) { + const sinceMs = new Date(_filters.since).getTime(); + if (!isNaN(sinceMs) && new Date(entry.ts).getTime() < sinceMs) return false; + } + if (_filters.until) { + const untilMs = new Date(_filters.until).getTime(); + if (!isNaN(untilMs) && new Date(entry.ts).getTime() > untilMs) return false; + } + return true; +} + +function _prependLiveEntry(entry: ActivityEntry): void { + if (!_entryPassesFilters(entry)) return; + + _entries = [entry, ..._entries]; + _total = _total + 1; + + // Prepend the row into the existing list (no full re-render for performance) + const list = document.getElementById('tab-activity_log')?.querySelector('.al-list'); + if (list) { + const html = _renderEntryRow(entry, true); + list.insertAdjacentHTML('afterbegin', html); + // Animate the new entry + const firstRow = list.firstElementChild as HTMLElement | null; + if (firstRow) { + requestAnimationFrame(() => { firstRow.classList.add('al-entry-appear'); }); + } + // Update count badge + const countEl = list.closest('.al-panel')?.querySelector('.al-count'); + if (countEl) countEl.textContent = t('activity_log.n_entries', { n: _total }); + } else { + _updateListContainer(); + } +} + +function _startLiveUpdates(): void { + if (_liveEventListener) return; + _liveEventListener = (e: Event) => { + const ce = e as CustomEvent; + const entry = ce.detail?.entry as ActivityEntry | undefined; + if (!entry) return; + _prependLiveEntry(entry); + }; + document.addEventListener('server:activity_logged', _liveEventListener); +} + +// ─── Public window-exposed interaction functions ────────────── + +export function activityLogToggleDetail(entryId: string): void { + if (_expandedIds.has(entryId)) { + _expandedIds.delete(entryId); + } else { + _expandedIds.add(entryId); + } + // Update just the affected row + const panel = document.getElementById('tab-activity_log'); + if (!panel) return; + const row = panel.querySelector(`[data-al-id="${CSS.escape(entryId)}"]`); + if (!row) return; + const entry = _entries.find(e => e.id === entryId); + if (!entry) return; + row.outerHTML = _renderEntryRow(entry, false); +} + +export function activityLogToggleCat(cat: string): void { + const idx = _filters.categories.indexOf(cat); + if (idx >= 0) { + _filters.categories = _filters.categories.filter(c => c !== cat); + } else { + _filters.categories = [..._filters.categories, cat]; + } + _render(); + _requery(); +} + +export function activityLogToggleSev(sev: string): void { + const idx = _filters.severities.indexOf(sev); + if (idx >= 0) { + _filters.severities = _filters.severities.filter(s => s !== sev); + } else { + _filters.severities = [..._filters.severities, sev]; + } + _render(); + _requery(); +} + +export function activityLogOnSearch(val: string): void { + _filters.q = val; + _requery(true); +} + +export function activityLogOnActor(val: string): void { + _filters.actor = val.trim(); + _requery(true); +} + +export function activityLogOnEntityType(val: string): void { + _filters.entity_type = val.trim(); + _requery(true); +} + +export function activityLogOnSince(val: string): void { + _filters.since = val; + _requery(); +} + +export function activityLogOnUntil(val: string): void { + _filters.until = val; + _requery(); +} + +export function activityLogClearFilters(): void { + _filters.categories = []; + _filters.severities = []; + _filters.actor = ''; + _filters.entity_type = ''; + _filters.since = ''; + _filters.until = ''; + _filters.q = ''; + _render(); + _requery(); +} + +export function activityLogPreset(key: string): void { + // Reset all filters first + _filters.categories = []; + _filters.severities = []; + _filters.actor = ''; + _filters.entity_type = ''; + _filters.q = ''; + _filters.until = ''; + + switch (key) { + case 'today': { + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + // datetime-local format: YYYY-MM-DDTHH:MM + _filters.since = todayStart.toISOString().slice(0, 16); + break; + } + case 'errors': + _filters.severities = ['error']; + _filters.since = ''; + break; + case 'auth': + _filters.categories = ['auth']; + _filters.since = ''; + break; + case 'devices': + _filters.categories = ['device']; + _filters.since = ''; + break; + } + _render(); + _requery(); +} + +export function activityLogLoadMore(): void { + if (_hasMore && !_loading) { + _fetchPage(_nextBeforeSeq, true); + } +} + +export async function activityLogExport(format: 'csv' | 'json'): Promise { + try { + showToast(t('activity_log.export.downloading'), 'info'); + const qs = _buildQuery(null); + const sep = qs ? '&' : '?'; + const url = `/activity-log/export${qs}${sep}format=${format}`; + const res = await fetchWithAuth(url); + 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 (e: unknown) { + if (e && typeof e === 'object' && 'isAuth' in e) return; + showToast(t('activity_log.export.error'), 'error'); + } +} + +export function activityLogNavigateToEntity(entityType: string, entityId: string): void { + const nav = _ENTITY_NAV[entityType]; + if (!nav || !entityId) return; + navigateToCard(nav.tab, nav.subTab, null, nav.attr, entityId); +} + +// ─── Main loader (registered with tab-registry) ───────────── + +export async function loadActivityLog(): Promise { + const panel = document.getElementById('tab-activity_log'); + if (!panel) return; + + _initialized = true; + _render(); + await _fetchPage(null, false); + _startLiveUpdates(); + + // Re-render on language change (baked-in t() calls) + document.addEventListener('languageChanged', _onLanguageChanged); +} + +function _onLanguageChanged(): void { + if (!_initialized) return; + _render(); + _fetchPage(null, false); +} diff --git a/server/src/ledgrab/static/js/features/tutorials.ts b/server/src/ledgrab/static/js/features/tutorials.ts index 117ad71..6d13316 100644 --- a/server/src/ledgrab/static/js/features/tutorials.ts +++ b/server/src/ledgrab/static/js/features/tutorials.ts @@ -66,6 +66,7 @@ const gettingStartedSteps: TutorialStep[] = [ { selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' }, { selector: '#tab-btn-integrations', textKey: 'tour.integrations', position: 'bottom' }, { selector: '#tab-btn-graph', textKey: 'tour.graph', position: 'bottom' }, + { selector: '#tab-btn-activity_log', textKey: 'tour.activity_log', position: 'bottom' }, { selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' }, { selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' }, { selector: '[onclick*="toggleTheme"]', textKey: 'tour.theme', position: 'bottom' }, diff --git a/server/src/ledgrab/static/js/global.d.ts b/server/src/ledgrab/static/js/global.d.ts index 4ff0677..a221790 100644 --- a/server/src/ledgrab/static/js/global.d.ts +++ b/server/src/ledgrab/static/js/global.d.ts @@ -457,6 +457,22 @@ startTargetOverlay: (...args: any[]) => any; applyBgEffect: (id: string) => void; renderAppearanceTab: () => void; + // ─── Activity Log ─── + loadActivityLog: () => Promise; + activityLogToggleDetail: (entryId: string) => void; + activityLogToggleCat: (cat: string) => void; + activityLogToggleSev: (sev: string) => void; + activityLogOnSearch: (val: string) => void; + activityLogOnActor: (val: string) => void; + activityLogOnEntityType: (val: string) => void; + activityLogOnSince: (val: string) => void; + activityLogOnUntil: (val: string) => void; + activityLogClearFilters: () => void; + activityLogPreset: (key: string) => void; + activityLogLoadMore: () => void; + activityLogExport: (format: 'csv' | 'json') => Promise; + activityLogNavigateToEntity: (entityType: string, entityId: string) => void; + // ─── Overlay spinner internals ─── overlaySpinnerTimer: ReturnType | null; _overlayEscHandler: ((e: KeyboardEvent) => void) | null; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index a36cfec..2ccd1fa 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -3314,5 +3314,62 @@ "wizard.error.device_name_required": "Device name is required.", "wizard.error.device_url_required": "Device URL is required.", "wizard.error.scaffold_failed": "Setup failed. Please try again.", - "wizard.error.start_failed": "Failed to start LED output." + "wizard.error.start_failed": "Failed to start LED output.", + "time.today": "Today", + "time.yesterday": "Yesterday", + "time.just_now": "just now", + "time.seconds_ago": "{n}s ago", + "time.minutes_ago": "{n}m ago", + "time.hours_ago": "{n}h ago", + "time.days_ago": "{n}d ago", + "activity_log.title": "Activity", + "activity_log.subtitle": "Audit log of LedGrab actions", + "activity_log.loading": "Loading activity log…", + "activity_log.empty": "No activity entries match the current filters.", + "activity_log.empty_no_filters": "No activity has been recorded yet.", + "activity_log.error": "Failed to load activity log.", + "activity_log.load_more": "Load more", + "activity_log.n_entries": "{n} entries", + "activity_log.live": "Live", + "activity_log.new_entries": "{n} new", + "activity_log.filter.title": "Filters", + "activity_log.filter.search": "Search messages…", + "activity_log.filter.category": "Category", + "activity_log.filter.severity": "Severity", + "activity_log.filter.actor": "Actor", + "activity_log.filter.entity_type": "Entity type", + "activity_log.filter.since": "From", + "activity_log.filter.until": "To", + "activity_log.filter.clear": "Clear filters", + "activity_log.preset.today": "Today", + "activity_log.preset.errors": "Errors", + "activity_log.preset.auth": "Auth", + "activity_log.preset.devices": "Devices", + "activity_log.category.auth": "Auth", + "activity_log.category.device": "Device", + "activity_log.category.entity": "Entity", + "activity_log.category.capture": "Capture", + "activity_log.category.system": "System", + "activity_log.severity.info": "Info", + "activity_log.severity.warning": "Warning", + "activity_log.severity.error": "Error", + "activity_log.col.time": "Time", + "activity_log.col.category": "Category", + "activity_log.col.severity": "Severity", + "activity_log.col.actor": "Actor", + "activity_log.col.message": "Message", + "activity_log.detail.title": "Entry detail", + "activity_log.detail.id": "ID", + "activity_log.detail.timestamp": "Timestamp", + "activity_log.detail.action": "Action", + "activity_log.detail.actor": "Actor", + "activity_log.detail.entity": "Entity", + "activity_log.detail.metadata": "Metadata", + "activity_log.export": "Export", + "activity_log.export.csv": "Export CSV", + "activity_log.export.json": "Export JSON", + "activity_log.export.downloading": "Downloading…", + "activity_log.export.error": "Export failed.", + "activity_log.disabled": "Activity logging is disabled.", + "tour.activity_log": "The Activity tab is a persistent audit log of everything LedGrab has done — entity changes, auth events, device connections, and system actions. Filter, search, and export as CSV or JSON." } diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index f907246..7ef2a07 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -2996,5 +2996,62 @@ "wizard.error.device_name_required": "Введите имя устройства.", "wizard.error.device_url_required": "Введите адрес устройства.", "wizard.error.scaffold_failed": "Ошибка настройки. Попробуйте ещё раз.", - "wizard.error.start_failed": "Не удалось запустить LED-вывод." + "wizard.error.start_failed": "Не удалось запустить LED-вывод.", + "time.today": "Сегодня", + "time.yesterday": "Вчера", + "time.just_now": "только что", + "time.seconds_ago": "{n} сек. назад", + "time.minutes_ago": "{n} мин. назад", + "time.hours_ago": "{n} ч. назад", + "time.days_ago": "{n} дн. назад", + "activity_log.title": "Активность", + "activity_log.subtitle": "Журнал действий LedGrab", + "activity_log.loading": "Загрузка журнала…", + "activity_log.empty": "Нет записей, соответствующих текущим фильтрам.", + "activity_log.empty_no_filters": "Действия ещё не зафиксированы.", + "activity_log.error": "Не удалось загрузить журнал.", + "activity_log.load_more": "Загрузить ещё", + "activity_log.n_entries": "Записей: {n}", + "activity_log.live": "Live", + "activity_log.new_entries": "+{n} новых", + "activity_log.filter.title": "Фильтры", + "activity_log.filter.search": "Поиск сообщений…", + "activity_log.filter.category": "Категория", + "activity_log.filter.severity": "Уровень", + "activity_log.filter.actor": "Субъект", + "activity_log.filter.entity_type": "Тип сущности", + "activity_log.filter.since": "С", + "activity_log.filter.until": "По", + "activity_log.filter.clear": "Сбросить фильтры", + "activity_log.preset.today": "Сегодня", + "activity_log.preset.errors": "Ошибки", + "activity_log.preset.auth": "Авторизация", + "activity_log.preset.devices": "Устройства", + "activity_log.category.auth": "Авторизация", + "activity_log.category.device": "Устройство", + "activity_log.category.entity": "Сущность", + "activity_log.category.capture": "Захват", + "activity_log.category.system": "Система", + "activity_log.severity.info": "Информация", + "activity_log.severity.warning": "Предупреждение", + "activity_log.severity.error": "Ошибка", + "activity_log.col.time": "Время", + "activity_log.col.category": "Категория", + "activity_log.col.severity": "Уровень", + "activity_log.col.actor": "Субъект", + "activity_log.col.message": "Сообщение", + "activity_log.detail.title": "Детали записи", + "activity_log.detail.id": "ID", + "activity_log.detail.timestamp": "Метка времени", + "activity_log.detail.action": "Действие", + "activity_log.detail.actor": "Субъект", + "activity_log.detail.entity": "Сущность", + "activity_log.detail.metadata": "Метаданные", + "activity_log.export": "Экспорт", + "activity_log.export.csv": "Экспорт CSV", + "activity_log.export.json": "Экспорт JSON", + "activity_log.export.downloading": "Загрузка…", + "activity_log.export.error": "Ошибка экспорта.", + "activity_log.disabled": "Запись активности отключена.", + "tour.activity_log": "Вкладка «Активность» — постоянный журнал всего, что делает LedGrab: изменения сущностей, авторизация, подключения устройств и системные действия. Используйте фильтры, поиск и экспорт в CSV или JSON." } diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 2058c38..894194a 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -2990,5 +2990,62 @@ "wizard.error.device_name_required": "设备名称不能为空。", "wizard.error.device_url_required": "设备地址不能为空。", "wizard.error.scaffold_failed": "配置失败,请重试。", - "wizard.error.start_failed": "启动 LED 输出失败。" + "wizard.error.start_failed": "启动 LED 输出失败。", + "time.today": "今天", + "time.yesterday": "昨天", + "time.just_now": "刚刚", + "time.seconds_ago": "{n}秒前", + "time.minutes_ago": "{n}分钟前", + "time.hours_ago": "{n}小时前", + "time.days_ago": "{n}天前", + "activity_log.title": "活动", + "activity_log.subtitle": "LedGrab 操作审计日志", + "activity_log.loading": "正在加载活动日志…", + "activity_log.empty": "没有符合当前过滤条件的记录。", + "activity_log.empty_no_filters": "尚无活动记录。", + "activity_log.error": "加载活动日志失败。", + "activity_log.load_more": "加载更多", + "activity_log.n_entries": "{n} 条记录", + "activity_log.live": "实时", + "activity_log.new_entries": "+{n} 条新记录", + "activity_log.filter.title": "过滤", + "activity_log.filter.search": "搜索消息…", + "activity_log.filter.category": "类别", + "activity_log.filter.severity": "严重性", + "activity_log.filter.actor": "操作者", + "activity_log.filter.entity_type": "实体类型", + "activity_log.filter.since": "从", + "activity_log.filter.until": "至", + "activity_log.filter.clear": "清除过滤", + "activity_log.preset.today": "今天", + "activity_log.preset.errors": "错误", + "activity_log.preset.auth": "身份验证", + "activity_log.preset.devices": "设备", + "activity_log.category.auth": "身份验证", + "activity_log.category.device": "设备", + "activity_log.category.entity": "实体", + "activity_log.category.capture": "采集", + "activity_log.category.system": "系统", + "activity_log.severity.info": "信息", + "activity_log.severity.warning": "警告", + "activity_log.severity.error": "错误", + "activity_log.col.time": "时间", + "activity_log.col.category": "类别", + "activity_log.col.severity": "严重性", + "activity_log.col.actor": "操作者", + "activity_log.col.message": "消息", + "activity_log.detail.title": "记录详情", + "activity_log.detail.id": "ID", + "activity_log.detail.timestamp": "时间戳", + "activity_log.detail.action": "操作", + "activity_log.detail.actor": "操作者", + "activity_log.detail.entity": "实体", + "activity_log.detail.metadata": "元数据", + "activity_log.export": "导出", + "activity_log.export.csv": "导出 CSV", + "activity_log.export.json": "导出 JSON", + "activity_log.export.downloading": "下载中…", + "activity_log.export.error": "导出失败。", + "activity_log.disabled": "活动日志记录已禁用。", + "tour.activity_log": "活动标签页是 LedGrab 所有操作的持久审计日志——实体更改、身份验证事件、设备连接和系统操作。可过滤、搜索,并导出为 CSV 或 JSON 格式。" } diff --git a/server/src/ledgrab/templates/index.html b/server/src/ledgrab/templates/index.html index e269142..ec5dd3a 100644 --- a/server/src/ledgrab/templates/index.html +++ b/server/src/ledgrab/templates/index.html @@ -143,6 +143,7 @@ +
@@ -201,6 +202,9 @@ +
+
+