Compare commits

...

13 Commits

Author SHA1 Message Date
alexei.dolgolyov b43f821046 Merge feature/activity-log: persistent activity/audit log
Adds a persistent, queryable activity/audit log surfaced in the WebUI:

- storage: dedicated indexed activity_log table + repository (keyset pagination, prune, streaming export); migration 002

- recorder + actor ContextVar + retention engine + lifecycle wiring; activity_logged realtime event

- instrumentation across auth / device / entity-CRUD / capture / system (best-effort, no secrets logged)

- REST API: list/filter, CSV/JSON export, retention settings, clear (auth-gated)

- WebUI: Activity tab (smart filtering, live updates, localized descriptions, export), Dashboard widget, Settings retention panel; en/ru/zh

Reviewed via independent per-phase + final + security agents; full suite 2563 passed.

Includes 17dd2e0 (review-findings fixes) that the feature branch was based on.
2026-06-10 15:31:12 +03:00
alexei.dolgolyov 077c99c7d1 fix(activity-log): no spinner flash on instant filtering
- re-query keeps current rows visible instead of clearing + showing the full 'Loading' spinner
- loading affordance is delayed ~180ms: instant responses show nothing; slow ones get a subtle dim (aria-busy)
- full spinner reserved for the genuine first load; append (load-more) shows no indicator
2026-06-10 15:30:48 +03:00
alexei.dolgolyov ae74cca132 fix(activity-log): UI polish - accessible export menu, i18n placeholders, zero-result spinner fix
- export button is now a click-toggle menu (aria-haspopup/expanded, role=menu, caret, Escape + click-outside close)
- filter placeholders moved to i18n (actor/entity_type .placeholder keys, en/ru/zh)
- _fetchPage clears _loading before render so a zero-result page doesn't spin forever
- toolbar/entry use elevated card surface (--lux-bg-1); light-theme device badge contrast; mobile message grid
2026-06-10 15:24:45 +03:00
alexei.dolgolyov 77284e8e7b fix(activity-log): dashboard section reconciliation + activity column alignment
- dashboard full re-render now reconciles sections (only replaces changed ones) instead of wholesale .dashboard-dynamic innerHTML swap -> editing an entity no longer jumps the whole dashboard
- Recent Activity widget live DOM + perf strip preserved across re-renders; widget skips re-fetch when already populated (no flash)
- sweep stray non-section nodes so empty->populated doesn't leave an orphan 'no targets' banner (review-caught regression)
- Activity list rows use a CSS grid (fixed badge/actor columns) so message column aligns consistently across rows
2026-06-10 12:28:13 +03:00
alexei.dolgolyov ff1ff06cb5 fix(activity-log): post-test polish - localize descriptions, dashboard widget, ticking time
- localize entry descriptions client-side via localizeMessage (activity_log.msg.* + entity_type.* templates x3 locales); server message kept as fallback/export/search
- remove redundant Activity header banner from tab
- Recent Activity widget is now a first-class dashboard section (Customize Dashboard show/hide/reorder; pre-existing layouts preserved)
- live activity event updates the widget surgically (no full dashboard rebuild); single listener with teardown
- relative-time labels tick via shared ensureRelativeTimeTicker (single 30s interval, visibility-aware)
2026-06-10 12:03:18 +03:00
alexei.dolgolyov 3dd1ac3f0d fix(activity-log): final-review fixes - crosslink keys + sanitize parity
- _ENTITY_NAV map keys corrected to match backend entity_type (device, color_strip_source, audio_source data-id) + scene_playlist crosslink added
- sanitize_display applied uniformly to owner-authored names at remaining record sites (dependencies entity_name, device_health, automation_engine, output_targets_control, scene_presets, scene_playlists)
2026-06-09 21:23:22 +03:00
alexei.dolgolyov 6e1dd2111d 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
2026-06-09 21:05:40 +03:00
alexei.dolgolyov 9a0137fa4c 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)
2026-06-09 20:42:44 +03:00
alexei.dolgolyov 4a0927521a feat(activity-log): phase 4 - REST API (list/export/settings/clear)
- GET /activity-log: filtered, keyset-paginated list (categories/severities/actor/entity/date/q)
- GET /activity-log/export: streaming CSV/JSON, chunked keyset (releases DB lock per batch), CSV formula-injection guard
- GET/PUT /activity-log/settings: retention config (PUT require_authenticated)
- DELETE /activity-log: clear (require_authenticated, self-audited)
- security: export DoS fix, settings-PUT auth gate, CSV \t/\r guard, metadata-as-JSON
- 122 API tests (auth posture, CSV injection, pagination integrity, filters, settings bounds, clear-audited)
2026-06-09 20:09:46 +03:00
alexei.dolgolyov 25c613c5cb feat(activity-log): phase 3 - event instrumentation (4 categories)
- entity CRUD via fire_entity_event choke point (name resolved/sanitized; deletes pass name explicitly)
- auth: failures + WS session establishment (no tokens logged); per-IP audit-record throttle
- device: online/offline (health), discovered/lost (zeroconf), ADB connect/disconnect
- capture/system: target start-stop, scenes, playlists, automations, backup/restore, update, restart, calibration, settings
- security hardening: sanitize_display strips control/NUL/ANSI/newlines from untrusted strings; malformed-IPv6 origin guard
- 129 instrumentation tests (incl. secret-leak, log-injection, throttle, best-effort) + autouse throttle-reset fixture
2026-06-09 19:20:57 +03:00
alexei.dolgolyov 726f39e2ba feat(activity-log): phase 2 - recorder, actor context, retention, lifecycle
- ActivityRecorder: thread-safe record() (inline on loop, call_soon_threadsafe off-loop), best-effort, fires activity_logged event
- current_actor ContextVar set in verify_api_key (both branches), default system
- ActivityLogRetentionEngine: prune loop (max_days+max_entries), settings persistence, rehydrates recorder.enabled on startup
- lifespan wiring: server.shutting_down recorded first on shutdown, retention stop before db.close
- events-ws.ts allowlist + parity; DI getters + module accessor; 62 new tests
2026-06-09 18:10:27 +03:00
alexei.dolgolyov 1ac4a0f66d feat(activity-log): phase 1 - storage model, migration, repository
- ActivityLogEntry dataclass + ActivityCategory/ActivitySeverity + ActivityLogFilters
- additive idempotent migration 002_add_activity_log (indexed activity_log table, seq keyset tiebreaker)
- ActivityLogRepository (record/query/count/prune/clear/iter_export), keyset pagination, parameterized SQL
- 102 unit + adversarial tests (SQL-injection, pagination, prune, codec, migration idempotency)
2026-06-09 17:40:37 +03:00
alexei.dolgolyov 1afe7d6fcc chore(activity-log): scaffold feature plan and phase subplans 2026-06-09 17:14:50 +03:00
73 changed files with 22572 additions and 9332 deletions
+3 -2
View File
@@ -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"
+13
View File
@@ -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)
+73
View File
@@ -0,0 +1,73 @@
# CONTEXT — Activity / Audit Log
Living scratchpad for the feature. The orchestrator updates this between phases. Tier-2
context (survives across phases; graduates to CLAUDE.md only if it's a lasting project truth).
## Config (from approval)
- **Mode:** Automated · **Execution:** Orchestrator · **Strategy:** Incremental
- **Base/merge target:** `master` · **Branch:** `feature/activity-log` · **Branch point:** `17dd2e0`
- Final merge ALWAYS requires user approval (even in Automated mode).
## Product decisions (locked)
- Placement: BOTH a top-level **Activity** tab AND a Dashboard **Recent Activity** widget,
plus a Settings retention panel.
- Scope: all four categories — entity CRUD, auth, device connect/disconnect, capture & system.
- Detail: **action metadata only** (no before/after diffs).
- Durability: **export on demand (CSV/JSON)** + existing whole-DB backup. **No** separate
backup subsystem.
- WebUI work uses the `frontend-design` skill (Phases 56).
## Key codebase facts (verified during planning)
- `fire_entity_event(entity_type, action, entity_id)` @ `api/dependencies.py:202` — central
hook, called by every entity route synchronously in-request; has `_deps` store access.
- `ProcessorManager.fire_event(dict)` / `subscribe_events()` @ `core/processing/processor_manager.py`
back `/api/v1/events/ws` (`api/routes/output_targets_control.py:206`). `fire_event` does
`put_nowait` (no `call_soon_threadsafe`) — fine for the existing consumer; recorder marshals.
- Frontend realtime: `core/events-ws.ts` re-dispatches `server:<type>`; `_ALLOWED_SERVER_EVENT_TYPES`
(line 39) is parity-checked by `tests/test_events_ws_parity.py`. Must add `activity_logged`.
- Actor: `request.state.auth_label` set in `api/auth.py` (129 authenticated, 83 anonymous).
- Storage: `Database` singleton (`storage/database.py`, single conn, RLock, WAL,
`synchronous=FULL`, `get_setting/set_setting`). `BaseSqliteStore` loads ALL rows to memory →
AVOID for the log. Migrations: `storage/data_migrations.py` (`ALL_MIGRATIONS`, idempotent).
- Backup/restore is **whole-DB** (`Database.backup_to`/`restore_from`, no STORE_MAP allowlist)
→ the new `activity_log` table is auto-covered. No `system.py` STORE_MAP edit needed.
- Background-engine pattern: `core/backup/auto_backup.py` (start/stop loop, `_prune`, settings).
- Thread-marshal precedent: `utils/log_broadcaster.py` (`ensure_loop` + `call_soon_threadsafe`).
- Device seams: `device_health_changed` (`core/processing/device_health.py`, on transition) +
`device_discovered`/`device_lost` (`core/devices/discovery_watcher.py`, **zeroconf thread**).
- **No** API-key create/rotate/revoke routes exist (only `GET /system/api-keys`) → those auth
events are DESCOPED.
- Existing **Log Viewer** (`utils/log_broadcaster.py`) = ephemeral debug-log tail; the audit
log is a different, persistent, structured feature. Differentiate; do not duplicate.
## Frozen contracts (fill as phases complete)
- ActivityLogEntry fields / dict shape: **frozen** — see phase-1-storage.md Handoff section. 11 fields: `id`, `ts`, `category`, `action`, `severity`, `actor`, `message`, `entity_type`, `entity_id`, `entity_name`, `metadata`. `seq` is DB-only (not on dataclass).
- ActivityLogFilters shape: **frozen** — 8 optional fields: `categories`, `severities`, `actor`, `entity_type`, `entity_id`, `since`, `until`, `message_like`. See phase-1-storage.md Handoff.
- recorder.record(...) signature + actor ContextVar import path: **frozen** — see phase-2-recorder-retention.md Handoff section. Signature: `record(category, action, *, severity="info", actor=None, entity_type=None, entity_id=None, entity_name=None, message, metadata=None, _bypass_enabled=False)`. ContextVar: `from ledgrab.core.activity_log.context import current_actor`. Module accessor: `from ledgrab.core.activity_log.recorder import get_module_recorder`. Event payload: `{"type": "activity_logged", "entry": {11-field dict with ts as ISO string, metadata as dict}}`. DI getters: `get_activity_recorder()`, `get_activity_log_repo()`, `get_activity_log_retention_engine()`.
- API endpoints + query params + page envelope + settings bounds: **frozen** — see phase-4-api.md Handoff section. Endpoints: `GET /api/v1/activity-log` (list, AuthRequired), `GET /api/v1/activity-log/export` (stream CSV/JSON, require_authenticated), `GET|PUT /api/v1/activity-log/settings` (AuthRequired), `DELETE /api/v1/activity-log` (clear, require_authenticated). Page envelope: `entries`, `next_before_seq`, `has_more`, `total`. Settings fields: `enabled` (bool), `max_days` (03650), `max_entries` (010_000_000). Export: `?format=csv|json`.
## Failed approaches / rejected designs
- Buffered async-writer subsystem (asyncio.Queue): REJECTED — unsafe from the zeroconf thread
and adds a shutdown-flush ordering hazard. Using direct synchronous-on-loop writes with
`call_soon_threadsafe` marshaling instead (simpler + correct).
- Using `BaseSqliteStore` for the log: REJECTED — loads all rows into memory.
- Separate activity-log backup subsystem: REJECTED — whole-DB backup already covers the table;
export-on-demand is the portability story.
## Deferred / open
- Setup-scaffold first-run noise suppression (batch/suppress flag) — deferred (nice-to-have).
## Phase progress notes
Phase 1 landed (2026-06-09): `activity_log.py` (dataclass + enums + filters + codec), `AddActivityLogTableMigration` (`002_add_activity_log`) appended to `ALL_MIGRATIONS`, `ActivityLogRepository` (record/query/count/prune/clear/iter_export), 41 new tests — all green. Full suite 2226 passed, 0 failed. Schema and method signatures frozen in phase-1-storage.md Handoff. Gotcha: `Database.execute` takes a positional tuple — use `?` placeholders (not `:name`), otherwise Python 3.14 will raise `ProgrammingError`.
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.
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.
+141
View File
@@ -0,0 +1,141 @@
# Feature: Activity / Audit Log
**Branch:** `feature/activity-log`
**Base branch:** `master` (merge target)
**Branch point:** `17dd2e02bab4d00a93479eb6af1a8c6ddc0c7224` (use for clean review diffs)
**Created:** 2026-06-09
**Status:** 🟢 All phases complete — awaiting final review + merge
**Strategy:** Incremental
**Mode:** Automated
**Execution:** Orchestrator
**Remote:** origin → https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
## Summary
A persistent, queryable audit log of meaningful LedGrab actions, surfaced in the WebUI.
Captures four categories — entity CRUD, authentication, device connect/disconnect, and
capture & system events — as **action-metadata-only** records (who/what/when + entity
type/name/id + a human-readable message + small structured metadata; **no before/after
diffs**). Surfaced as a dedicated top-level **Activity** tab with smart filtering + live
updates, a compact **Recent Activity** widget on the Dashboard, and a **Settings** panel
for retention. Durability rides on the existing whole-DB `ledgrab.db` backup; portability
is an on-demand CSV/JSON **export** (no separate backup subsystem).
## Design pillars (the load-bearing decisions)
1. **Dedicated `activity_log` table + repository — NOT `BaseSqliteStore`.** That base loads
every row into an in-memory cache and uses a generic `id/name/data` blob — wrong for an
append-heavy, unbounded log. We use a purpose-built indexed table with query-on-demand
keyset pagination.
2. **Central choke-point instrumentation.** `fire_entity_event()` (`api/dependencies.py:202`)
is already called by every entity route on create/update/delete and has `_deps` access to
resolve names. The recorder hooks there for all entity CRUD. Non-entity events get explicit
`recorder.record(...)` calls.
3. **Actor via `ContextVar`.** Set inside `verify_api_key` (next to `request.state.auth_label`),
default `"system"`, reset per-request. The recorder reads it without threading actor
through every call.
4. **Direct synchronous write on the event-loop thread** (no separate buffered-writer
subsystem — simpler, and the request already did a `synchronous=FULL` entity write).
Cross-thread callers (zeroconf discovery thread) marshal via `loop.call_soon_threadsafe`,
mirroring `utils/log_broadcaster.py`. The "server shutting down" event is recorded as the
FIRST action in the lifespan shutdown block, before any teardown.
5. **Reuse the existing realtime bus.** A new `activity_logged` event over `/api/v1/events/ws`
(one `events-ws.ts` allowlist entry + `test_events_ws_parity.py` update). No new socket.
6. **Never log secrets.** API-key *tokens* are never stored — only labels/ids.
7. **Differentiate from the existing Log Viewer.** `utils/log_broadcaster.py` is an ephemeral
500-line debug-log tail. The audit log is persistent, structured, semantic. Cross-link in
the Settings panel; never duplicate.
## Build & Test Commands
- **Build (frontend):** `cd server && npm run build`
- **Type-check (TS):** `cd server && npx tsc --noEmit`
- **Test:** `cd server && py -3.13 -m pytest tests/ --no-cov -q`
- **Lint (Python):** `cd server && ruff check src/ tests/ --fix`
- **Events parity test (load-bearing for P2):** included in pytest (`tests/test_events_ws_parity.py`)
> Scope checks to the files actually edited: backend phases run ruff + pytest; frontend-only
> phases run `tsc --noEmit` + `npm run build` (no pytest/ruff). Phases touching both run both.
## Phases
- [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)
- [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)
- Phases 3 and 4 are **parallelizable only after the schema is frozen at end of Phase 2**.
Both depend on the P2 recorder/schema; P4 registers the router in `api/__init__.py`, P3
edits `dependencies.py`/`auth.py` (different files, shared schema contract). To keep the
Automated run simple and low-risk, they run **sequentially** (3 → 4) unless time pressure
warrants worktree-isolated parallelism. Phases 5 → 6 are sequential (6 reuses P5 formatters
and the feature module).
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 1: Storage | data | ✅ Done | ✅ Passed | ✅ Passed | ✅ |
| 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 | ✅ Done | ✅ Passed | ✅ Passed (tsc+build) | ✅ |
| Phase 6: Dashboard/Settings | frontend | ✅ Done | ✅ Passed | ✅ Passed (tsc+build) | ✅ |
## Outstanding Warnings
| Phase | Warning | Severity | Status (open / resolved / accepted) |
|-------|---------|----------|-------------------------------------|
| 3 | Log injection via unauth mDNS device name/url into audit message | 🟠 High (security) | resolved — `sanitize_display` helper applied |
| 3 | Origin sanitizer missed spaces/NUL/ANSI | 🟠 High (security) | resolved — `sanitize_display` over netloc |
| 3 | Unauth auth-failure audit-write flood (no write-rate bound) | 🟠 High (security) | resolved — per-IP audit-record throttle (10s, capped) |
| 3 | Malformed-IPv6 Origin → urlparse ValueError into WS handler | 🟡 Warning | resolved — try/except guard |
| 3 | Throttle module-global state caused flaky test contamination | 🟡 Warning | resolved — autouse conftest reset fixture |
| 4 | Export held global DB write-lock across the stream (slow-client DoS) | 🟠 High (security) | resolved — chunked keyset export releases lock per batch |
| 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) |
| 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 | Entity-crosslink map keys mismatched backend entity_type (device/color_strip_source/audio_source) | 🟡 Warning | resolved — `_ENTITY_NAV` keys corrected + scene_playlist added |
| Final | Owner-authored names interpolated raw at some record sites | 🔵 Note (defense-in-depth) | resolved — `sanitize_display` applied uniformly |
| Manual test | Entry descriptions rendered server English (not localized) | 🟡 Warning (acceptance criterion) | resolved — client-side `localizeMessage` from structured fields + `activity_log.msg.*`/`entity_type.*` templates ×3 |
| Manual test | Redundant "Activity" header banner at top of tab | 🔵 Note | resolved — header block removed |
| Manual test | Recent Activity widget missing from Customize Dashboard | 🟡 Warning | resolved — registered as a first-class dashboard section (show/hide/reorder; pre-existing layouts preserved) |
| Manual test | Activity widget live event rebuilt the whole dashboard | 🟡 Warning (perf) | resolved — surgical list update; single listener with teardown |
| Manual test | Relative-time labels static (never tick) | 🟡 Warning | resolved — shared `ensureRelativeTimeTicker` (single 30s interval, visibility-aware) |
| Manual test | Dashboard fully rebuilt/jumped on entity edits (pre-existing `forceFullRender` wholesale innerHTML) | 🟡 Warning (UX) | resolved — section-level reconciliation (`_reconcileDynamicSections`): only changed sections replaced; widget live DOM + perf strip preserved |
| Manual test | Activity list columns slightly misaligned | 🔵 Note | resolved — CSS grid with fixed badge/actor columns |
| Manual test | Reconciler left orphan "no targets" node on empty→populated | 🟡 Warning (regression, caught in review) | resolved — sweep non-section top-level children |
## Final Review
- [ ] Comprehensive code review
- [ ] Security review (auth/PII-in-logs/secrets/log-injection — triggered)
- [ ] All Outstanding Warnings resolved or consciously accepted
- [ ] Full build passes (`npm run build` + `tsc --noEmit`)
- [ ] Full test suite passes (`pytest`)
- [ ] Merged to `master`
## Amendment Log
_(Filled in if the plan is amended mid-implementation.)_
- 2026-06-09: Plan reviewer (pre-implementation) → ⚠️ with 3 Critical Gaps, all resolved
before Phase 1: (G1) descoped non-existent API-key mutation events; (G2) dropped the
buffered-writer subsystem for a direct synchronous-on-loop write with `call_soon_threadsafe`
marshaling for thread-origin events; (G3) record "server shutting down" first in the shutdown
block (no buffer to flush). Concerns folded in: device events via `device_health_changed` +
`device_discovered/_lost`; actor ContextVar in `verify_api_key`; name-on-delete passed
explicitly; settings-audit scoped + self-key excluded; parity allowlist before emit site.
Adopted suggestions: rowid keyset tiebreaker; log the disable action. Deferred: setup-scaffold
noise suppression.
+177
View File
@@ -0,0 +1,177 @@
# Phase 1: Storage — model, migration, repository
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** data
## Objective
Create the persistent foundation for the audit log: an `ActivityLogEntry` dataclass, an
additive idempotent SQLite migration that creates a dedicated indexed `activity_log` table,
and a purpose-built `ActivityLogRepository` (NOT `BaseSqliteStore`) supporting append,
keyset-paginated filtered query, count, time/count-based prune, and streaming export.
## Tasks
- [x] Create `server/src/ledgrab/storage/activity_log.py`:
- `ActivityCategory` and `ActivitySeverity` string enums (or `Literal` unions used as
constants). Categories: `auth`, `device`, `entity`, `capture`, `system`. Severities:
`info`, `warning`, `error`.
- `@dataclass ActivityLogEntry` with fields: `id: str` (e.g. `al_<uuid8>`), `ts: datetime`
(UTC, server-assigned), `category: str`, `action: str`, `severity: str`, `actor: str`,
`entity_type: str | None`, `entity_id: str | None`, `entity_name: str | None`,
`message: str`, `metadata: dict` (small JSON; default empty). Provide `to_row()` /
`from_row()` (column tuple/dict ↔ dataclass; `metadata` JSON-encoded; `ts` isoformat).
- [x] Add migration to `server/src/ledgrab/storage/data_migrations.py`:
- New `DataMigration` subclass `AddActivityLogTableMigration` with unique `name`
(next sequential id, e.g. `"NNN_add_activity_log"` — match existing naming) and
`apply(conn)` creating `activity_log` with an INTEGER PRIMARY KEY AUTOINCREMENT `seq`
(monotonic keyset tiebreaker) plus columns: `id TEXT UNIQUE NOT NULL`, `ts TEXT NOT NULL`,
`category TEXT NOT NULL`, `action TEXT NOT NULL`, `severity TEXT NOT NULL`,
`actor TEXT NOT NULL`, `entity_type TEXT`, `entity_id TEXT`, `entity_name TEXT`,
`message TEXT NOT NULL`, `metadata TEXT NOT NULL DEFAULT '{}'`.
- Indexes: `(ts DESC, seq DESC)` (primary keyset/sort), `category`, `severity`, `actor`,
`(entity_type, entity_id)`. Use `CREATE TABLE/INDEX IF NOT EXISTS` for idempotency.
- Append the instance to `ALL_MIGRATIONS` (never reorder existing entries).
- [x] Create `server/src/ledgrab/storage/activity_log_repository.py`:
- `class ActivityLogRepository` taking `db: Database` (NOT subclassing `BaseSqliteStore`).
- `record(entry: ActivityLogEntry) -> None`: single parameterized INSERT via
`db.execute(...)` (auto-commit). The `seq` is DB-assigned. **Caller guarantees this runs
on the event-loop thread** (see Phase 2 — cross-thread marshaling lives in the recorder).
- `query(filters: ActivityLogFilters, *, before_seq: int | None, limit: int) -> list[ActivityLogEntry]`:
keyset pagination `WHERE seq < ? ORDER BY seq DESC LIMIT ?` plus optional filters —
`category IN (...)`, `severity IN (...)`, `actor = ?`, `entity_type = ?`, `entity_id = ?`,
`ts >= ?` / `ts <= ?`, `message LIKE ?` (free-text, `%q%`, escaped). All parameterized.
- `count(filters) -> int`.
- `prune(*, before_ts: datetime | None, max_entries: int | None) -> int`: delete rows older
than `before_ts`, and/or trim to the newest `max_entries` by `seq`. Returns rows deleted.
- `clear() -> int`: delete all rows (used by the API clear endpoint; the clear action is
itself audited by the recorder, not here). Returns rows deleted.
- `iter_export(filters) -> Iterator[ActivityLogEntry]`: cursor-based streaming for export
(does not load all rows into memory).
- Define a small `ActivityLogFilters` dataclass (all-optional fields) in the repository or
`activity_log.py` and reuse it across query/count/prune/export.
- [x] Unit tests in `server/tests/storage/test_activity_log_repository.py`:
- insert + read back round-trip (incl. metadata JSON, UTC ts);
- filter by each dimension (category/severity/actor/entity/date/free-text);
- keyset pagination stability across two pages with same-`ts` rows (seq tiebreaker);
- prune by age and by max_entries;
- clear; count; export iterator yields all matching rows;
- migration idempotency (constructing the repo twice / running migrations twice is safe).
## Files to Modify/Create
- `server/src/ledgrab/storage/activity_log.py` — new: dataclass + enums + filters + row codec
- `server/src/ledgrab/storage/data_migrations.py` — modify: add migration + append to `ALL_MIGRATIONS`
- `server/src/ledgrab/storage/activity_log_repository.py` — new: repository
- `server/tests/storage/test_activity_log_repository.py` — new: unit tests
## Acceptance Criteria
- `activity_log` table + indexes created idempotently on startup (running migrations twice is a no-op).
- Query is keyset-paginated and index-backed; a 10k-row table never loads fully into memory.
- Pagination is stable when many rows share the same millisecond `ts` (uses `seq` tiebreaker).
- `prune` removes by age AND by max-entry cap; `clear` empties the table; `export` streams.
- All filters use parameterized SQL (no string interpolation of user input).
- New unit tests pass; `ruff check` clean; existing tests still green.
## Notes
- Reference patterns: `storage/database.py` (`execute`, `transaction`, `get_setting`),
`storage/data_migrations.py` (`DataMigration`, `MigrationRunner`, `ALL_MIGRATIONS`),
`storage/sync_clock.py` (dataclass `to_dict`/`from_dict` style).
- 🔒 **Migration-safety addendum (data domain):** this migration is purely additive (new
table) — no rename, no field/key/file move, no data movement → no data-loss risk. Still
idempotent (`IF NOT EXISTS`). Rollback = drop the table; no user data is transformed.
- Do NOT wire the repository into `main.py` or `dependencies.py` here — that is Phase 2.
- `Database`'s connection is created with the existing threading model; the repository must
not assume it can be called from arbitrary threads. Thread marshaling is Phase 2's job.
## Review Checklist
- [x] All tasks completed
- [x] Code follows project conventions (dataclass codec style, migration naming)
- [x] No unintended side effects (no startup wiring yet)
- [x] Build passes (ruff + pytest)
- [x] Tests pass (new + existing)
## Handoff to Next Phase
### ActivityLogEntry — final field list and dict shape
```python
@dataclass
class ActivityLogEntry:
id: str # "al_<uuid8>" — caller-assigned
ts: datetime # UTC-aware; stored as ISO-8601 string in DB
category: str # ActivityCategory constant
action: str # verb-object label, e.g. "entity.created"
severity: str # ActivitySeverity constant
actor: str # API-key label or "system"
message: str # human-readable description
entity_type: str | None # e.g. "output_target"
entity_id: str | None # stable entity id
entity_name: str | None # name at time of event
metadata: dict # JSON-serialisable; default {}
```
`to_row()` returns a flat dict with 11 keys (same names); `metadata` is JSON string, `ts` is isoformat string. `seq` is NOT in `to_row()` — it is DB-assigned.
### ActivityLogFilters — shape (all fields optional, default None)
```python
@dataclass
class ActivityLogFilters:
categories: Sequence[str] | None # category IN (...)
severities: Sequence[str] | None # severity IN (...)
actor: str | None # exact match
entity_type: str | None # exact match
entity_id: str | None # exact match
since: datetime | None # ts >= since
until: datetime | None # ts <= until
message_like: str | None # LIKE %value% (escaped)
```
### Migration name used
`"002_add_activity_log"` — appended as position [1] in `ALL_MIGRATIONS`.
### ActivityLogRepository — exact method signatures
```python
class ActivityLogRepository:
def __init__(self, db: Database) -> None
def record(self, entry: ActivityLogEntry) -> None
def query(
self,
filters: ActivityLogFilters,
*,
before_seq: int | None = None,
limit: int = 50,
) -> list[ActivityLogEntry]
def count(self, filters: ActivityLogFilters | None = None) -> int
def prune(
self,
*,
before_ts: datetime | None = None,
max_entries: int | None = None,
) -> int
def clear(self) -> int
def iter_export(
self, filters: ActivityLogFilters | None = None
) -> Iterator[ActivityLogEntry]
```
### Key behavioural notes for Phase 2/3/4
- `record()` expects to be called from the event-loop thread (or with `Database` RLock already held). Phase 2 is responsible for thread marshaling via `loop.call_soon_threadsafe`.
- `query()` returns entries in **ascending chronological order within the page** (reversed internally from DESC fetch for display convenience). The smallest `seq` on a page is `page[0]`'s seq — pass that as `before_seq` for the next page.
- `count(None)` == `count(ActivityLogFilters())` — both count all rows.
- `prune(before_ts=X, max_entries=N)` applies both predicates independently (age prune first, then count cap).
- `iter_export` holds `db._lock` for the entire iteration. Phase 4 should stream the response and consume promptly.
- `ActivityLogCategory` and `ActivityLogSeverity` are plain classes with string class-attributes and an `ALL` tuple — NOT `enum.Enum`.
- Imports for Phase 2/3/4:
```python
from ledgrab.storage.activity_log import ActivityLogEntry, ActivityLogFilters, ActivityCategory, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
```
@@ -0,0 +1,196 @@
# Phase 2: Recorder, actor context, retention, lifecycle
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Build the runtime layer over the Phase 1 repository: a thread-safe `ActivityRecorder` facade
that persists an entry AND pushes a live `activity_logged` event; an actor `ContextVar`
populated by the auth layer; a background `ActivityLogRetentionEngine` mirroring
`AutoBackupEngine`; and the `main.py`/`dependencies.py` wiring (init, DI getter, retention
start/stop, shutdown ordering). After this phase the audit log records nothing yet (no call
sites) — that is Phase 3 — but the full machinery is live and unit-tested.
## Tasks
- [x] Create `server/src/ledgrab/core/activity_log/__init__.py` and
`server/src/ledgrab/core/activity_log/recorder.py`:
- `ActivityRecorder(repo: ActivityLogRepository, processor_manager, *, loop=None)`.
- `record(category, action, *, severity="info", actor=None, entity_type=None,
entity_id=None, entity_name=None, message, metadata=None) -> None`:
- resolve `actor` from the actor `ContextVar` when not supplied, default `"system"`;
- build an `ActivityLogEntry` (id `al_<uuid8>`, `ts=datetime.now(timezone.utc)`);
- **thread-safe write:** if called on the event loop thread, write inline via
`repo.record(entry)` then fire the live event; if called from another thread (zeroconf
discovery), marshal the whole write+emit onto the loop via
`loop.call_soon_threadsafe(...)`. Capture the loop lazily (mirror
`utils/log_broadcaster.py:ensure_loop`/`call_soon_threadsafe`). Never raise into the
caller — audit recording is best-effort and must not break the audited action; log
failures at `warning`.
- live push: `processor_manager.fire_event({"type": "activity_logged", "entry": entry_as_dict})`.
- Provide a tiny helper to serialize an entry to the same dict shape the API returns
(reuse in Phase 4 / frontend).
- `enabled` flag honored: when retention settings say `enabled=false`, `record()` is a
no-op — EXCEPT the "audit log disabled" event itself, which must be recorded before the
flag takes effect (see retention engine).
- [x] Actor `ContextVar`:
- Add `current_actor: ContextVar[str]` (module-level, e.g. in `core/activity_log/context.py`
or `api/auth.py`). In `verify_api_key` (`api/auth.py`), set it next to the existing
`request.state.auth_label = ...` (both the authenticated label and the `"anonymous"`
branch). Default `"system"` when unset. Ensure no cross-request leakage (set on every
auth evaluation).
- [x] Create `server/src/ledgrab/core/activity_log/retention.py`:
- `ActivityLogRetentionEngine(repo, db, recorder)` mirroring `core/backup/auto_backup.py`:
`_load_settings()`/`_save_settings()` via `db.get_setting("activity_log")` /
`db.set_setting("activity_log", {...})`, `DEFAULT_SETTINGS = {"enabled": True,
"max_days": 90, "max_entries": 20000}`.
`async start()` → spawn `_retention_loop()` (`asyncio.create_task`); loop sleeps a sane
interval (e.g. hourly) then calls `repo.prune(before_ts=now-max_days, max_entries=...)`.
`async stop()` → cancel + await task. `get_settings()` / `async update_settings(...)`
that persist and apply (changing `enabled` is logged via the recorder BEFORE disabling).
- [x] Wiring:
- `main.py`: instantiate `activity_log_repo = ActivityLogRepository(db)` (module level near
other stores); in `lifespan` startup build `activity_recorder` + `activity_log_retention_engine`,
pass to `init_dependencies(...)`, and `await activity_log_retention_engine.start()`.
- In `lifespan` **shutdown**: record a `system` / `server_shutting_down` event via the
recorder as the **first** shutdown action (before engines/db close), then
`await _bounded("activity_log_retention.stop", activity_log_retention_engine.stop(), timeout=0.5)`.
- `api/dependencies.py`: add `activity_recorder` + `activity_log_repo` +
`activity_log_retention_engine` to `_deps`, parameters to `init_dependencies`, and
getters `get_activity_recorder()`, `get_activity_log_repo()`,
`get_activity_log_retention_engine()`.
- [x] Realtime allowlist (order matters — do allowlist FIRST so the parity test stays green):
- Add `'activity_logged'` to `_ALLOWED_SERVER_EVENT_TYPES` in
`server/src/ledgrab/static/js/core/events-ws.ts` (+ a one-line comment naming the source).
- Confirm `tests/test_events_ws_parity.py` passes with the new emit type.
- [x] Unit tests `server/tests/core/test_activity_recorder.py` +
`test_activity_log_retention.py`:
- recorder persists an entry AND calls `fire_event` with `type=="activity_logged"`;
- actor resolves from ContextVar; defaults to `"system"`; failure in repo doesn't raise;
- cross-thread `record()` (call from a `threading.Thread`) routes through the loop and persists;
- retention prunes per settings; settings round-trip via db; disabling logs the disable event.
## Files to Modify/Create
- `server/src/ledgrab/core/activity_log/__init__.py` — new
- `server/src/ledgrab/core/activity_log/recorder.py` — new
- `server/src/ledgrab/core/activity_log/context.py` — new (actor ContextVar) *(or place in auth.py)*
- `server/src/ledgrab/core/activity_log/retention.py` — new
- `server/src/ledgrab/api/auth.py` — modify: set actor ContextVar in `verify_api_key`
- `server/src/ledgrab/main.py` — modify: instantiate, wire lifespan start/shutdown
- `server/src/ledgrab/api/dependencies.py` — modify: `_deps`, `init_dependencies`, getters
- `server/src/ledgrab/static/js/core/events-ws.ts` — modify: allowlist `activity_logged`
- `server/tests/core/test_activity_recorder.py` — new
- `server/tests/core/test_activity_log_retention.py` — new
## Acceptance Criteria
- Recorder persists + fires `activity_logged`; never raises into callers; thread-safe from
non-loop threads.
- Actor ContextVar populated by auth; default `"system"`; no cross-request leakage.
- Retention engine starts/stops cleanly in lifespan; prunes by age + count; settings persist.
- `server_shutting_down` is recorded before teardown; no lost-on-graceful-shutdown entries.
- `test_events_ws_parity.py` green (allowlist updated). Existing tests still green; `ruff` clean.
## Notes
- Reference: `core/backup/auto_backup.py` (engine shape, settings persistence, `_bounded`
shutdown in `main.py`), `utils/log_broadcaster.py` (`ensure_loop`, `call_soon_threadsafe`
thread marshaling), `core/processing/processor_manager.py:247` (`fire_event`).
- **Do not add any instrumentation call sites in this phase** — only the machinery. Phase 3
adds the `record(...)` calls. (Intermediate commit emits nothing; that is fine and green.)
- Freeze the `ActivityLogEntry` dict shape here — Phase 4 (API response) and Phase 5
(frontend `entry`) consume it.
## Review Checklist
- [x] All tasks completed
- [x] Code follows project conventions (engine/DI patterns)
- [x] No unintended side effects (no call sites yet; lifespan order correct)
- [x] Build passes (ruff + pytest, incl. parity test)
- [x] Tests pass (new + existing)
## Handoff to Next Phase
### recorder.record(...) — final signature
```python
recorder.record(
category: str, # ActivityCategory constant
action: str, # verb-object label
*,
severity: str = "info", # ActivitySeverity constant
actor: str | None = None, # resolved from current_actor ContextVar when None
entity_type: str | None = None,
entity_id: str | None = None,
entity_name: str | None = None,
message: str,
metadata: dict | None = None,
_bypass_enabled: bool = False, # internal: used by retention engine only
) -> None
```
### Actor ContextVar import path
```python
from ledgrab.core.activity_log.context import current_actor
```
### Module accessor (for non-DI sites)
```python
from ledgrab.core.activity_log.recorder import get_module_recorder, set_module_recorder
recorder = get_module_recorder() # returns ActivityRecorder | None
```
### entry_to_dict helper (for API response serialisation)
```python
from ledgrab.core.activity_log.recorder import entry_to_dict
d = entry_to_dict(entry) # returns dict with 11 keys
```
### Frozen `activity_logged` event payload shape
```python
{
"type": "activity_logged",
"entry": {
"id": str, # "al_<8-hex>"
"ts": str, # ISO-8601 UTC string
"category": str,
"action": str,
"severity": str,
"actor": str,
"entity_type": str | None,
"entity_id": str | None,
"entity_name": str | None,
"message": str,
"metadata": dict, # real dict, not JSON string
}
}
```
### DI getter names (in `api/dependencies.py`)
```python
from ledgrab.api.dependencies import (
get_activity_recorder,
get_activity_log_repo,
get_activity_log_retention_engine,
)
```
### Notes for Phase 3
- Phase 3 instruments `fire_entity_event` in `api/dependencies.py` by calling
`get_module_recorder()` there (not via FastAPI DI — it's a plain function).
- The actor ContextVar is already set by `verify_api_key` before any route
handler runs, so entity events carry the correct actor automatically.
- `recorder.record(...)` never raises; Phase 3 call sites need no try/except.
Phase 2 landed (2026-06-09): ActivityRecorder, actor ContextVar, ActivityLogRetentionEngine,
all wiring in main.py/dependencies.py/auth.py, activity_logged allowlist in events-ws.ts,
24 new tests — all green. Full suite 2309 passed, 2 skipped, 0 failed. Ruff clean.
@@ -0,0 +1,172 @@
# Phase 3: Event instrumentation (4 categories)
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend · 🔒 security-sensitive (security reviewer triggers)
## Objective
Emit audit records at the real call sites for all four categories, using the Phase 2 recorder.
Maximize coverage via the central `fire_entity_event` choke point; add explicit
`recorder.record(...)` calls for non-entity events. Never log secrets.
## Tasks
### Entity CRUD (via the choke point)
- [x] In `api/dependencies.py`, extend `fire_entity_event` to ALSO record an audit entry:
- Signature gains an optional `entity_name: str | None = None`.
- For `created`/`updated`: if `entity_name` not supplied, best-effort resolve from the
matching store in `_deps` keyed by `entity_type` (entity still present). For `deleted`:
**do not** resolve post-hoc — rely on the explicit `entity_name` passed by the handler
(deletes are the most important; a name-less delete entry is unacceptable).
- Map `action` → severity (`info`), category `entity`. Build a human message
(e.g. `"Target 'Desk' updated"`). Read actor from the ContextVar.
- Recording is best-effort (never break the entity operation).
- [x] Update entity **delete** handlers to pass `entity_name` into `fire_entity_event`
(the entity object is already loaded for the 404 check). Cover the representative/most-used
entities at minimum: output targets, sync clocks, devices, picture/audio/color-strip
sources, automations, scene presets/playlists, templates, gradients. (Create/update can rely
on hook resolution but pass the name where trivially available.)
### Authentication (DESCOPED: no key create/rotate/revoke — those routes don't exist)
- [x] In `api/auth.py`, record:
- auth **failures**: missing/invalid Bearer token (HTTP), rejected LAN-without-keys, rejected
WS origin (4403), WS auth handshake failure (4401). Category `auth`, severity `warning`.
Include the caller IP/label and the reason in `metadata`**never** the attempted token.
- WS **session establishment** (successful `accept_and_authenticate_ws`): category `auth`,
severity `info`, actor = authenticated label.
- (Do NOT record per-request HTTP auth *success* — too frequent.)
### Device connect/disconnect (use existing discrete seams)
- [x] Hook `device_health_changed` (`core/processing/device_health.py`, fired only on
`online != prev_online`) → record online/offline transition. Category `device`,
severity `info` (online) / `warning` (offline).
- [x] Hook `device_discovered` / `device_lost` (`core/devices/discovery_watcher.py`, **runs on
the zeroconf thread** → recorder must marshal to the loop, which Phase 2 handles). Category
`device`.
- [x] ADB connect/disconnect (`api/routes/system_settings.py:adb_connect/adb_disconnect`).
### Capture & system events (explicit record calls)
- [x] Target processing start/stop + bulk (`api/routes/output_targets_control.py`).
- [x] Scene activation (`scene_presets.py:activate_scene_preset`), playlist start/stop
(`scene_playlists.py`), automation activate/deactivate (`automation_engine.py`).
- [x] System: backup create/restore/delete (`backup.py`), update apply/dismiss (`update.py`),
restart/shutdown (`backup.py`), calibration start/stop/cancel (`calibration.py`).
- [x] Settings changes: scope to high-value settings only (auto-backup, update, shutdown
action). **Exclude the activity-log's own `"activity_log"` settings key** to avoid
self-referential churn.
### Tests
- [x] `server/tests/test_activity_instrumentation.py` (or per-area):
- representative entity create/update/delete produces a record with correct category/actor/
name (incl. a delete carrying its name);
- an auth failure produces a `warning` record and the token never appears in any field;
- a device health transition and a discovery event produce records;
- a capture start and a backup/restore produce records.
## Files to Modify/Create
- `server/src/ledgrab/api/dependencies.py` — modify: `fire_entity_event` records + `entity_name`
- entity **delete** route handlers under `api/routes/` — modify: pass `entity_name`
- `server/src/ledgrab/api/auth.py` — modify: auth-failure + WS-session records
- `server/src/ledgrab/core/processing/device_health.py` — modify: online/offline record
- `server/src/ledgrab/core/devices/discovery_watcher.py` — modify: discovered/lost record
- `server/src/ledgrab/api/routes/system_settings.py` — modify: ADB + settings records
- `server/src/ledgrab/api/routes/output_targets_control.py` — modify: start/stop records
- `server/src/ledgrab/api/routes/{scene_presets,scene_playlists,backup,update,calibration}.py` — modify
- `server/src/ledgrab/core/automations/automation_engine.py` — modify: activate/deactivate records
- `server/tests/test_activity_instrumentation.py` — new
## Acceptance Criteria
- All four categories emit records at the named sites; entity deletes carry the entity name.
- API-key tokens / secrets never appear in any audit field (test-enforced).
- Recording never breaks the audited action (best-effort; failures swallowed + logged).
- Actor is the authenticated label for request-originated events, `"system"` for engine/thread
events. New + existing tests green; `ruff` clean.
## Notes
- Get the recorder via the Phase 2 DI getter; for engine/thread sites that lack DI, use the
module singleton/accessor Phase 2 exposes.
- Keep messages human-readable and localized-agnostic (English source strings; the frontend
renders structured fields, not server message translation — message is a fallback/summary).
- This is the security-sensitive phase — the security reviewer runs here AND at final review.
## Review Checklist
- [x] All tasks completed
- [x] Code follows project conventions
- [x] No unintended side effects (audited actions still succeed on recorder failure)
- [x] No secrets logged (token never recorded) — explicitly verified
- [x] Build passes (ruff + pytest)
- [x] Tests pass (new + existing)
## Handoff to Next Phase
Phase 3 is complete. The following (category, action) pairs are now emitted, along with their
metadata keys, for Phase 4 to expose via query/filter and for Phase 5 quick-filter presets.
### `entity` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `entity.created` | info | — | All entity types via `fire_entity_event` choke-point |
| `entity.updated` | info | — | All entity types; name resolved from store when not passed |
| `entity.deleted` | info | — | Name passed explicitly by delete handler before deletion |
### `auth` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `auth.rejected` | warning | `reason` (str), `client` (str/IP) | Missing Bearer, invalid Bearer, LAN-no-keys, WS origin, WS auth timeout, invalid WS token |
| `auth.ws_connected` | info | `client` (str/IP) | Successful WS session established |
### `device` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `device.online` | info | `latency_ms` (float) | Health monitor, transition only |
| `device.offline` | warning | `latency_ms` (float) | Health monitor, transition only |
| `device.discovered` | info | `url` (str), `device_type` (str) | Zeroconf discovery thread; recorder marshals to loop |
| `device.lost` | warning | `url` (str), `device_type` (str) | Zeroconf discovery thread |
| `device.adb_connected` | info | `address` (str) | ADB route success |
| `device.adb_disconnected` | info | `address` (str) | ADB route success |
### `capture` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `capture.started` | info | — | Per target (individual + bulk) |
| `capture.stopped` | info | — | Per target (individual + bulk) |
| `scene.activated` | info | — | `scene_presets.py:activate_scene_preset` |
| `playlist.started` | info | — | `scene_playlists.py:start_scene_playlist` |
| `playlist.stopped` | info | — | `scene_playlists.py:stop_scene_playlist` |
| `automation.activated` | info | — | `automation_engine.py:_activate_automation`; actor="system" |
| `automation.deactivated` | info | — | `automation_engine.py:_deactivate_automation`; actor="system" |
### `system` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `backup.created` | info | `filename` (str) | `backup.py:backup_config` |
| `backup.restored` | info | — | `backup.py:restore_config` |
| `backup.deleted` | info | `filename` (str) | `backup.py:delete_saved_backup` |
| `server.restarting` | info | — | `backup.py:restart_server` |
| `server.shutdown_requested` | info | — | `backup.py:shutdown_server` |
| `update.dismissed` | info | `version` (str) | `update.py:dismiss_update` |
| `update.applied` | info | `version` (str) | `update.py:apply_update` |
| `settings.changed` | info | `setting_key` (str) + setting-specific keys | `setting_key` values: `"auto_backup"`, `"update"`, `"shutdown_action"`. Activity-log own key excluded. |
| `calibration.started` | info | — | `calibration.py`; entity_type="device", entity_id=device_id |
| `calibration.stopped` | info | — | `calibration.py` |
| `calibration.cancelled` | info | — | `calibration.py` |
### Implementation notes for Phase 4
- The `metadata` field is a JSON `TEXT` column. All keys above are scalars (str, float).
- Phase 4 filter `metadata_key` / `metadata_value` lookup, if added, can target `setting_key`
for settings-change filtering.
- `entity_type` is populated for entity CRUD and `calibration.started`. For auth/system/capture
events `entity_type` may be None.
- `entity_name` is always populated for `entity.deleted`; populated for CRUD create/update
when resolved; populated for most capture/system events where a name is meaningful.
+163
View File
@@ -0,0 +1,163 @@
# Phase 4: REST API — query / filter / export / settings / clear
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Expose the audit log over the REST API: a filtered, keyset-paginated list endpoint; a
streaming CSV/JSON export honoring the same filters; retention settings get/update; and a
destructive clear. Apply the project's auth posture (stricter auth on export + clear).
## Tasks
- [x] `server/src/ledgrab/api/schemas/activity_log.py` (Pydantic):
- `ActivityLogEntryResponse` (matches the frozen Phase 2 entry dict shape).
- `ActivityLogPageResponse` { `entries: list[...]`, `next_before_seq: int | None`,
`total: int` (optional/over filters), `has_more: bool` }.
- `ActivityLogSettingsResponse` / `UpdateActivityLogSettingsRequest`
(`enabled`, `max_days`, `max_entries`) with validation bounds.
- [x] `server/src/ledgrab/api/routes/activity_log.py``APIRouter(prefix="/api/v1/activity-log")`:
- `GET ""` — list. Query params: `categories`, `severities`, `actor`, `entity_type`,
`entity_id`, `since`/`until` (ISO), `q` (free-text), `before_seq` (cursor), `limit`
(default 50, capped e.g. 200). `AuthRequired`. Maps params → `ActivityLogFilters`,
calls `repo.query(...)`, returns page envelope.
- `GET "/export"` — streaming export. Same filters; `format=csv|json`. Uses
`StreamingResponse` over `repo.iter_export(...)`. **`require_authenticated()`** (may
contain IPs/labels). Sets `Content-Disposition` with a timestamped filename.
- `GET "/settings"` / `PUT "/settings"` — retention settings via the retention engine.
`AuthRequired`; updates apply immediately.
- `DELETE ""` — clear all entries. **`require_authenticated()`**. The clear is itself
audited (recorder records a `system`/`activity_log_cleared` entry AFTER the wipe, so the
log shows who cleared it and when).
- Register the router in `server/src/ledgrab/api/__init__.py` (aggregator).
- [x] API tests `server/tests/api/routes/test_activity_log_api.py`:
- list returns entries; each filter narrows results; `before_seq` cursor paginates without
overlap/gaps; `limit` cap enforced;
- export CSV and JSON both stream and honor filters; export requires authentication
(401 for loopback-anonymous when keys configured);
- settings get/update round-trip + validation rejects out-of-range;
- clear empties the log, requires auth, and leaves exactly one post-clear audit entry.
## Files to Modify/Create
- `server/src/ledgrab/api/schemas/activity_log.py` — new
- `server/src/ledgrab/api/routes/activity_log.py` — new
- `server/src/ledgrab/api/__init__.py` — modify: register router
- `server/tests/api/routes/test_activity_log_api.py` — new
## Acceptance Criteria
- List is filterable on every dimension and keyset-paginated (stable, no dupes/gaps).
- Export streams CSV + JSON, honors filters, and requires authentication.
- Settings get/update works and validates bounds; changes take effect immediately.
- Clear requires authentication and is itself audited.
- New + existing tests green; `ruff` clean.
## Notes
- Auth helpers: `AuthRequired` dependency for normal endpoints; `require_authenticated()` for
export + clear (pattern: backup download / secret reveal). See `api/auth.py` + `server/CLAUDE.md`.
- Follow the existing route/schema conventions (one schema file per entity, router registered
in `api/__init__.py`). Reference `api/routes/backup.py` for settings-style GET/PUT + a
`StreamingResponse`/download pattern.
- Reuse the entry→dict serializer from Phase 2 to keep the response shape single-sourced.
- Backup/restore: no `STORE_MAP` change needed — backup is whole-DB; the table is auto-covered.
## Review Checklist
- [x] All tasks completed
- [x] Code follows project conventions (router registration, schema-per-entity, auth posture)
- [x] No unintended side effects
- [x] Build passes (ruff + pytest)
- [x] Tests pass (new + existing)
## Handoff to Next Phase
### Endpoint paths
| Method | Path | Auth |
|--------|------|------|
| GET | `/api/v1/activity-log` | AuthRequired (anonymous allowed) |
| GET | `/api/v1/activity-log/export` | require_authenticated (no anonymous) |
| GET | `/api/v1/activity-log/settings` | AuthRequired |
| PUT | `/api/v1/activity-log/settings` | AuthRequired |
| DELETE | `/api/v1/activity-log` | require_authenticated (no anonymous) |
### List query parameters (GET /api/v1/activity-log)
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `categories` | `list[str]` | — | Repeatable. Values: auth, device, entity, capture, system |
| `severities` | `list[str]` | — | Repeatable. Values: info, warning, error |
| `actor` | `str` | — | Exact match |
| `entity_type` | `str` | — | Exact match |
| `entity_id` | `str` | — | Exact match |
| `since` | `datetime` (ISO-8601) | — | Inclusive lower bound on ts |
| `until` | `datetime` (ISO-8601) | — | Inclusive upper bound on ts |
| `q` | `str` | — | Substring match on message (LIKE %q%) |
| `before_seq` | `int` | — | Keyset cursor from previous page's `next_before_seq` |
| `limit` | `int` | 50 | Max entries per page. ge=1, le=200 |
Export endpoint (`GET /api/v1/activity-log/export`) accepts the same filter params plus `format=csv|json`.
### Page envelope fields (ActivityLogPageResponse)
```json
{
"entries": [...], // list[ActivityLogEntryResponse]
"next_before_seq": 42, // int | null — pass as before_seq for next page
"has_more": true, // bool
"total": 1337 // int — total matching all filters (all pages)
}
```
### Entry dict shape (ActivityLogEntryResponse)
11 fields — identical to `entry_to_dict()` output:
```json
{
"id": "al_abcd1234",
"ts": "2026-06-09T12:34:56.789+00:00",
"category": "entity",
"action": "entity.created",
"severity": "info",
"actor": "my-api-key",
"entity_type": "output_target",
"entity_id": "pt_abc",
"entity_name": "Desk",
"message": "Output target 'Desk' created",
"metadata": {}
}
```
### Settings field bounds (UpdateActivityLogSettingsRequest)
| Field | Type | ge | le | Notes |
|-------|------|----|----|-------|
| `enabled` | `bool` | — | — | Enable/disable recording |
| `max_days` | `int` | 0 | 3650 | 0 = no age-based pruning |
| `max_entries` | `int` | 0 | 10_000_000 | 0 = no count-based pruning |
### Export format param
`?format=csv` (default) → `text/csv; charset=utf-8`
`?format=json``application/json` (streamed JSON array)
### Pagination algorithm
Keyset cursor (`before_seq`) works as follows:
- Omit `before_seq` (or pass `null`) to get the FIRST (newest) page.
- Each page response includes `next_before_seq` (the seq of the oldest entry on the page).
- Pass `next_before_seq` as `before_seq` in the next request to get the following (older) page.
- `has_more=false` means there are no more pages; `next_before_seq` is `null`.
- `total` is constant across pages for the same filter set.
### New method added to ActivityLogRepository (additive, not breaking)
`get_seq_for_id(entry_id: str) -> int | None` — indexed point-lookup of seq by entry id.
Used internally by the list endpoint to build the keyset cursor.
Phase 4 landed (2026-06-09): schemas, route (list/export/settings/clear), router registration,
49 new tests — all green. Full suite 2486 passed, 2 skipped, 0 failed. Ruff clean.
+137
View File
@@ -0,0 +1,137 @@
# Phase 5: Frontend — Activity tab + smart filtering + live updates
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend · uses the `frontend-design` skill
## Objective
Build the dedicated top-level **Activity** tab: a read-only, smart-filterable,
keyset-paginated log viewer with an entry detail view, live-append of new events, and export.
This is a viewer (Dashboard-style), NOT a CRUD card section.
## Tasks
- [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.
- [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, 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),
actor, message, entity crosslink (use `navigateToCard(...)` when the referenced entity is
resolvable). Keyset "load more" (or infinite scroll) using `next_before_seq`.
- **Detail:** expandable row / drawer showing full metadata JSON, exact timestamp, ids.
- **Live append:** `document.addEventListener('server:activity_logged', e => …)` — prepend
the new entry if it passes the active filters; show a subtle "new" affordance. (Depends on
the Phase 2 allowlist entry — already shipped.)
- **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`.
- [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`) + `<div class="tab-panel" id="tab-activity_log">`.
- `app.ts`: import + `Object.assign(window, { loadActivityLog, … })`; `global.d.ts` decls.
- [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.
- [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").
- [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` — 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
- [x] New **Activity** tab loads, lists entries, and paginates via keyset "load more".
- [x] Filters hit server-side query params; quick presets work; free-text is debounced.
- [x] New events append live via `server:activity_logged` and respect active filters.
- [x] Export downloads CSV/JSON with auth, honoring current filters.
- [x] Fully localized (en/ru/zh); empty/loading/error states; re-renders on language change.
- [x] No plain `<select>` (use chips); SVG icons only (no emoji).
- [x] `npx tsc --noEmit` clean; `npm run build` succeeds.
## Notes
- **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
- [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<ActivityEntry[]>
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=03650, max_entries=010_000_000)
@@ -0,0 +1,98 @@
# Phase 6: Dashboard widget + Settings retention panel + docs
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend · uses the `frontend-design` skill
## Objective
Add the two secondary surfaces: a compact **Recent Activity** widget on the Dashboard linking
to the full tab, and a **Settings** panel for retention configuration (+ clear + export entry)
positioned beside the existing Log Viewer with a clear "what's the difference" note. Update
docs/tutorials.
## Tasks
- [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.
- [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.
- **Clear log** button (confirm dialog) → `DELETE /activity-log`; **Export** button →
`/activity-log/export`.
- One-line note distinguishing this persistent audit log from the ephemeral debug Log Viewer
(cross-link both ways).
- i18n for all controls/labels/hints.
- [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.
- [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
- `server/src/ledgrab/static/js/features/dashboard.ts` — modify: Recent Activity widget
- `server/src/ledgrab/static/css/dashboard.css` (or relevant sheet) — modify: widget styles
- `server/src/ledgrab/static/js/features/settings.ts` — modify: retention panel + handlers
- `server/src/ledgrab/templates/modals/settings.html` — modify: rail entry + panel HTML
- `server/src/ledgrab/static/js/app.ts` / `global.d.ts` — modify: new window handlers
- `server/src/ledgrab/static/locales/{en,ru,zh}.json` — modify: i18n keys
- docs (README / feature list / relevant context doc) — modify: brief feature documentation
## Acceptance Criteria
- Dashboard shows a live Recent Activity widget; **View all →** opens the Activity tab.
- Settings panel reads/writes retention settings, clears (with confirm + auth), and exports.
- Audit-log vs debug-Log-Viewer distinction is explicit and cross-linked.
- Fully localized (en/ru/zh); empty/loading states; consistent with app design.
- `npx tsc --noEmit` clean; `npm run build` succeeds. No plain `<select>`; SVG icons only.
## Notes
- **Use the `frontend-design` skill** for the widget + settings panel layout.
- Reuse Phase 5's render helper and i18n namespace — no duplicated row markup or keys.
- Settings UI model: `features/settings.ts switchSettingsTab` + `modals/settings.html` rail.
- Frontend-only phase → run `tsc --noEmit` + `npm run build`; do NOT run pytest/ruff (docs +
TS only).
- Backup/restore cross-ref: no `STORE_MAP` edit needed (whole-DB backup covers the table) —
confirm nothing else (graph editor) needs syncing for this viewer (verified: not needed).
## Review Checklist
- [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
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.
+2
View File
@@ -38,6 +38,7 @@ from .routes.snapshot import router as snapshot_router
from .routes.graph import router as graph_router
from .routes.calibration import router as calibration_router
from .routes.setup import router as setup_router
from .routes.activity_log import router as activity_log_router
router = APIRouter()
router.include_router(system_router)
@@ -76,5 +77,6 @@ router.include_router(snapshot_router)
router.include_router(graph_router)
router.include_router(calibration_router)
router.include_router(setup_router)
router.include_router(activity_log_router)
__all__ = ["router"]
+124 -2
View File
@@ -3,18 +3,111 @@
import asyncio
import json
import secrets
import time
from typing import Annotated
from urllib.parse import urlparse
from fastapi import Depends, HTTPException, Request, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from starlette.websockets import WebSocket, WebSocketDisconnect
from ledgrab.config import get_config
from ledgrab.core.activity_log.context import current_actor
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
logger = get_logger(__name__)
# ── Auth-failure audit throttle (H3) ───────────────────────────────────────
#
# Unauthenticated callers can hammer any auth path; without a recording
# throttle each attempt would write one SQLite row AND broadcast one WS event,
# providing a cheap disk/broadcast amplification vector.
#
# Mitigation: record at most one ``auth.rejected`` audit entry per client IP
# per _AUTH_RECORD_WINDOW seconds. The auth decision (401) is NEVER
# suppressed — only the *audit recording* is de-duplicated.
#
# Memory safety: the throttle dict is capped at _AUTH_THROTTLE_HARD_CAP
# entries. When the cap is exceeded the oldest-seen IP (lowest timestamp) is
# evicted so the dict stays bounded regardless of the number of distinct source
# IPs an attacker can forge.
_AUTH_RECORD_WINDOW: float = 10.0 # seconds — one record per IP per window
_AUTH_THROTTLE_HARD_CAP: int = 512 # max IPs tracked simultaneously
# ip -> monotonic timestamp of last *recorded* auth.rejected entry
_auth_record_last: dict[str, float] = {}
def _should_record_auth_failure(client_ip: str) -> bool:
"""Return True when an ``auth.rejected`` record should be written for *client_ip*.
Suppresses duplicates within _AUTH_RECORD_WINDOW seconds. Evicts the
oldest entry when the dict exceeds _AUTH_THROTTLE_HARD_CAP to prevent
unbounded memory growth under IP-spray attacks.
"""
now = time.monotonic()
last = _auth_record_last.get(client_ip)
if last is not None and (now - last) < _AUTH_RECORD_WINDOW:
return False # suppress: within the de-dup window
# Enforce hard cap before inserting: evict the single oldest entry.
if len(_auth_record_last) >= _AUTH_THROTTLE_HARD_CAP:
oldest_ip = min(_auth_record_last, key=lambda ip: _auth_record_last[ip])
del _auth_record_last[oldest_ip]
_auth_record_last[client_ip] = now
return True
def _record_auth_failure(reason: str, client_host: str | None) -> None:
"""Best-effort: record an auth failure audit entry (never raises).
SECURITY: the attempted token is NEVER passed here; only the reason and
the caller's IP/label are recorded.
THROTTLE: at most one ``auth.rejected`` record is written per client IP
per _AUTH_RECORD_WINDOW seconds to prevent disk/WS-broadcast amplification
DoS. The 401 response is always returned regardless.
"""
if not _should_record_auth_failure(client_host or "unknown"):
return # throttled — drop duplicate recording for this IP/window
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is None:
return
rec.record(
category=ActivityCategory.AUTH,
action="auth.rejected",
severity=ActivitySeverity.WARNING,
actor="anonymous",
message=f"Authentication failed: {reason}",
metadata={"reason": reason, "client": client_host or "unknown"},
)
def _record_ws_auth_success(label: str, client_host: str | None) -> None:
"""Best-effort: record a successful WebSocket session establishment."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is None:
return
rec.record(
category=ActivityCategory.AUTH,
action="auth.ws_connected",
severity=ActivitySeverity.INFO,
actor=label,
message=f"WebSocket session established by '{label}'",
metadata={"client": client_host or "unknown"},
)
# Security scheme for Bearer token
security = HTTPBearer(auto_error=False)
@@ -81,10 +174,12 @@ def verify_api_key(
# No keys configured — allow loopback only.
if _is_loopback(client_host):
request.state.auth_label = "anonymous"
current_actor.set("anonymous")
return "anonymous"
# Allow caller to authenticate explicitly even without configured keys?
# No — there are no keys to compare against. Reject.
logger.warning("Rejected LAN request from %s: no API key configured", client_host)
_record_auth_failure("LAN access rejected: no API key configured", client_host)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=(
@@ -97,13 +192,14 @@ def verify_api_key(
# Check if credentials are provided
if not credentials:
logger.warning("Request missing Authorization header")
_record_auth_failure("missing Bearer token", client_host)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key - authentication is required",
headers={"WWW-Authenticate": "Bearer"},
)
# Extract token
# Extract token — NEVER log or record the token value itself.
token = credentials.credentials
# Find matching key and return its label using constant-time comparison
@@ -115,6 +211,7 @@ def verify_api_key(
if not authenticated_as:
logger.warning("Invalid API key attempt")
_record_auth_failure("invalid Bearer token", client_host)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
@@ -127,6 +224,9 @@ def verify_api_key(
# Stash the friendly label so the access-log middleware can attribute the
# request to a client without re-running the token comparison.
request.state.auth_label = authenticated_as
# Set the actor ContextVar so ActivityRecorder can resolve it without
# threading it through every call site.
current_actor.set(authenticated_as)
return authenticated_as
@@ -190,12 +290,30 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
# a strong signal even before the token check. Non-browser clients
# legitimately omit Origin; those fall through to the auth handshake.
config = get_config()
client_host = websocket.client.host if websocket.client else None
origin = websocket.headers.get("origin")
if not _is_origin_allowed(origin, config.server.cors_origins):
logger.warning(
"Rejected WebSocket from origin %r (not in cors_origins)",
origin,
)
# Sanitize first so urlparse does not choke on control chars / ANSI / NUL
# embedded by an attacker in the Origin header (e.g. \n triggers IPv6 parse
# error in Python's urlsplit on malformed netloc).
_safe_origin_raw = sanitize_display(origin) if origin else ""
try:
_netloc = urlparse(_safe_origin_raw).netloc if _safe_origin_raw else ""
except ValueError:
# Malformed IPv6 addresses (e.g. "http://[::1" without closing "]")
# cause urlparse to raise ValueError. Fall back to "unknown" — do NOT
# fall back to the raw origin string, which could carry query params
# or path components containing secrets.
_netloc = ""
_safe_origin = sanitize_display(_netloc or "unknown")
_record_auth_failure(
f"WebSocket origin rejected: {_safe_origin!r}",
client_host,
)
try:
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
except _WS_SEND_BENIGN_EXC:
@@ -210,6 +328,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
except _WS_SEND_BENIGN_EXC:
pass
return None
_record_ws_auth_success(label, client_host)
return label
@@ -275,6 +394,7 @@ async def verify_ws_auth(
return None
return "anonymous"
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
_record_auth_failure("WebSocket auth timeout", client_host)
try:
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
except _WS_SEND_BENIGN_EXC:
@@ -332,6 +452,7 @@ async def verify_ws_auth(
await websocket.send_json({"type": "auth_ok"})
return "anonymous"
logger.warning("Rejected LAN WebSocket from %s: no API key configured", client_host)
_record_auth_failure("LAN WebSocket rejected: no API key configured", client_host)
try:
await websocket.send_json(
{
@@ -343,10 +464,11 @@ async def verify_ws_auth(
pass
return None
# Keys configured: require a matching token.
# Keys configured: require a matching token. NEVER log the token value.
label = _match_api_key(token or "")
if not label:
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
_record_auth_failure("invalid WebSocket token", client_host)
try:
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
except _WS_SEND_BENIGN_EXC:
+112 -2
View File
@@ -42,6 +42,11 @@ from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
from ledgrab.storage.pattern_template_store import PatternTemplateStore
from ledgrab.core.activity_log.recorder import ActivityRecorder, get_module_recorder
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
T = TypeVar("T")
@@ -196,16 +201,83 @@ def get_update_service() -> UpdateService:
return _get("update_service", "Update service")
def get_activity_recorder() -> ActivityRecorder:
return _get("activity_recorder", "Activity recorder")
def get_activity_log_repo() -> ActivityLogRepository:
return _get("activity_log_repo", "Activity log repository")
def get_activity_log_retention_engine() -> ActivityLogRetentionEngine:
return _get("activity_log_retention_engine", "Activity log retention engine")
# ── Event helper ────────────────────────────────────────────────────────
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
"""Fire an entity_changed event via the ProcessorManager event bus.
def _resolve_entity_name(entity_type: str, entity_id: str) -> str | None:
"""Best-effort: look up a human name for *entity_id* from the matching store.
Returns ``None`` when the store is missing, the entity is gone, or any
exception occurs (e.g. during delete the entity may have just been removed).
"""
# Map entity_type → (_deps key, method name on the store)
_STORE_LOOKUP: dict[str, tuple[str, str]] = {
"output_target": ("output_target_store", "get_target"),
"device": ("device_store", "get_device"),
"picture_source": ("picture_source_store", "get_source"),
"audio_source": ("audio_source_store", "get_source"),
"color_strip_source": ("color_strip_store", "get_source"),
"template": ("template_store", "get_template"),
"capture_template": ("template_store", "get_template"),
"pp_template": ("pp_template_store", "get_template"),
"automation": ("automation_store", "get_automation"),
"scene_preset": ("scene_preset_store", "get_preset"),
"scene_playlist": ("scene_playlist_store", "get_playlist"),
"sync_clock": ("sync_clock_store", "get_clock"),
"gradient": ("gradient_store", "get_gradient"),
"audio_template": ("audio_template_store", "get_template"),
"value_source": ("value_source_store", "get_source"),
"cspt": ("cspt_store", "get_template"),
"audio_processing_template": ("audio_processing_template_store", "get_template"),
"pattern_template": ("pattern_template_store", "get_template"),
"home_assistant_source": ("ha_store", "get_source"),
"mqtt_source": ("mqtt_store", "get_source"),
"http_endpoint": ("http_endpoint_store", "get_endpoint"),
}
entry = _STORE_LOOKUP.get(entity_type)
if entry is None:
return None
store_key, method_name = entry
store = _deps.get(store_key)
if store is None:
return None
try:
obj = getattr(store, method_name)(entity_id)
if obj is not None:
return getattr(obj, "name", None)
except Exception:
pass
return None
def fire_entity_event(
entity_type: str,
action: str,
entity_id: str,
entity_name: str | None = None,
) -> None:
"""Fire an entity_changed event via the ProcessorManager event bus and
record an audit entry.
Args:
entity_type: e.g. "device", "output_target", "color_strip_source"
action: "created", "updated", or "deleted"
entity_id: The entity's unique ID
entity_name: Human-readable name. For deletes: **must** be passed
explicitly (entity is already gone when we get here).
For create/update: resolved from the store when not supplied.
"""
pm = _deps.get("processor_manager")
if pm is not None:
@@ -218,6 +290,38 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
}
)
# ── Audit record (best-effort) ──────────────────────────────────────────
rec = get_module_recorder()
if rec is None:
return
# Resolve name when not explicitly provided (create / update paths).
# For deleted: entity already gone — rely on the explicitly passed name.
resolved_name = entity_name
if resolved_name is None and action != "deleted":
resolved_name = _resolve_entity_name(entity_type, entity_id)
# Build a concise human message.
# Sanitize the display name before interpolating into the free-text message
# (user-authored names hit the CSV/export trust surface).
safe_display_name = sanitize_display(resolved_name) if resolved_name else None
display_name = f"'{safe_display_name}'" if safe_display_name else entity_id
action_word = {"created": "created", "updated": "updated", "deleted": "deleted"}.get(
action, action
)
entity_label = entity_type.replace("_", " ")
message = f"{entity_label.capitalize()} {display_name} {action_word}"
rec.record(
category=ActivityCategory.ENTITY,
action=f"entity.{action}",
severity=ActivitySeverity.INFO,
entity_type=entity_type,
entity_id=entity_id,
entity_name=sanitize_display(resolved_name) if resolved_name else None,
message=message,
)
# ── Initialization ──────────────────────────────────────────────────────
@@ -257,6 +361,9 @@ def init_dependencies(
http_endpoint_store: HTTPEndpointStore | None = None,
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
pattern_template_store: PatternTemplateStore | None = None,
activity_recorder: ActivityRecorder | None = None,
activity_log_repo: ActivityLogRepository | None = None,
activity_log_retention_engine: ActivityLogRetentionEngine | None = None,
):
"""Initialize global dependencies."""
_deps.update(
@@ -295,5 +402,8 @@ def init_dependencies(
"http_endpoint_store": http_endpoint_store,
"audio_processing_template_store": audio_processing_template_store,
"pattern_template_store": pattern_template_store,
"activity_recorder": activity_recorder,
"activity_log_repo": activity_log_repo,
"activity_log_retention_engine": activity_log_retention_engine,
}
)
@@ -0,0 +1,436 @@
"""Activity-log REST API — query / filter / export / settings / clear.
Endpoints
---------
GET /api/v1/activity-log List (filterable, keyset-paginated)
GET /api/v1/activity-log/export Streaming CSV or JSON export
GET /api/v1/activity-log/settings Retention settings
PUT /api/v1/activity-log/settings Update retention settings (requires non-anonymous auth)
DELETE /api/v1/activity-log Clear all entries (requires non-anonymous auth)
Auth posture
------------
- List + read settings (``GET``): ``AuthRequired`` (loopback-anonymous is fine).
- Export, update settings (``PUT``), and clear: ``require_authenticated()``
(loopback-anonymous is rejected; mirrors the backup download / secret-reveal
pattern from ``backup.py``). Updating settings can disable auditing or prune
the trail, so it is gated like the destructive clear.
CSV injection
-------------
Cells that begin with =, +, -, @, TAB, or CR can trigger formula execution in
spreadsheet apps (OWASP Formula Injection). ``_csv_safe`` prefixes any such cell
with a single quote so formulas are inert. Fields already go through
``sanitize_display`` in Phase 3 instrumentation, but the CSV writer applies its
own guard as defence-in-depth.
Export generator + lock
-----------------------
``repo.iter_export()`` fetches rows in bounded batches, holding the DB ``_lock``
only around each batch fetch and releasing it before yielding — so a slow or
stalled client never blocks other DB operations. The ``StreamingResponse``
generator is wrapped in a ``try/finally`` block so the batch generator is closed
even when the client disconnects mid-stream.
"""
from __future__ import annotations
import csv
import io
import json
from datetime import datetime, timezone
from typing import Annotated, Iterator
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from ledgrab.api.auth import AuthRequired, require_authenticated
from ledgrab.api.dependencies import (
get_activity_log_repo,
get_activity_log_retention_engine,
get_activity_recorder,
)
from ledgrab.api.schemas.activity_log import (
ActivityLogPageResponse,
ActivityLogSettingsResponse,
UpdateActivityLogSettingsRequest,
)
from ledgrab.core.activity_log.recorder import ActivityRecorder, entry_to_dict
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.storage.activity_log import ActivityCategory, ActivityLogFilters, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
router = APIRouter(prefix="/api/v1/activity-log", tags=["Activity Log"])
# Hard cap on the per-request limit to prevent runaway queries.
_MAX_LIMIT = 200
_DEFAULT_LIMIT = 50
# CSV export columns (matches entry_to_dict key order)
_CSV_COLUMNS = [
"id",
"ts",
"category",
"action",
"severity",
"actor",
"entity_type",
"entity_id",
"entity_name",
"message",
"metadata",
]
# Characters that trigger formula injection in spreadsheet apps (OWASP).
# Leading TAB and CR are also recognised triggers by Excel / Google Sheets.
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
def _csv_safe(value: str) -> str:
"""Prefix formula-injection triggers with a literal single-quote.
A cell starting with =, +, -, or @ can execute as a formula in Excel /
Google Sheets. OWASP recommends prepending a single quote to neutralise it.
"""
if value and value[0] in _FORMULA_PREFIXES:
return "'" + value
return value
def _build_filters(
categories: list[str] | None,
severities: list[str] | None,
actor: str | None,
entity_type: str | None,
entity_id: str | None,
since: datetime | None,
until: datetime | None,
q: str | None,
) -> ActivityLogFilters:
"""Assemble an ``ActivityLogFilters`` dataclass from query parameters."""
return ActivityLogFilters(
categories=categories or None,
severities=severities or None,
actor=actor or None,
entity_type=entity_type or None,
entity_id=entity_id or None,
since=since,
until=until,
message_like=q or None,
)
# ---------------------------------------------------------------------------
# GET /api/v1/activity-log — list
# ---------------------------------------------------------------------------
@router.get("", response_model=ActivityLogPageResponse, summary="List activity-log entries")
def list_activity_log(
auth: AuthRequired, # noqa: ARG001
repo: ActivityLogRepository = Depends(get_activity_log_repo),
# ── Filters ────────────────────────────────────────────────────────────
categories: Annotated[
list[str] | None,
Query(
description=(
"Filter by category (repeatable or comma-separated). "
"Values: auth, device, entity, capture, system"
)
),
] = None,
severities: Annotated[
list[str] | None,
Query(description="Filter by severity (repeatable). Values: info, warning, error"),
] = None,
actor: Annotated[
str | None,
Query(description="Filter by actor label (exact match)"),
] = None,
entity_type: Annotated[
str | None,
Query(description="Filter by entity type (exact match)"),
] = None,
entity_id: Annotated[
str | None,
Query(description="Filter by entity id (exact match)"),
] = None,
since: Annotated[
datetime | None,
Query(description="Return entries at or after this ISO-8601 datetime"),
] = None,
until: Annotated[
datetime | None,
Query(description="Return entries at or before this ISO-8601 datetime"),
] = None,
q: Annotated[
str | None,
Query(description="Free-text search in the message field (substring)"),
] = None,
# ── Pagination ─────────────────────────────────────────────────────────
before_seq: Annotated[
int | None,
Query(
description=(
"Keyset cursor: pass the 'next_before_seq' from the previous page "
"to get the following (older) page. Omit for the first (newest) page."
)
),
] = None,
limit: Annotated[
int,
Query(
ge=1,
le=_MAX_LIMIT,
description=f"Max entries per page (default {_DEFAULT_LIMIT}, max {_MAX_LIMIT})",
),
] = _DEFAULT_LIMIT,
) -> ActivityLogPageResponse:
"""Return the newest matching entries, oldest-first within the page.
Keyset pagination: the response includes ``next_before_seq`` — pass it
as ``before_seq`` in the next request to get the next (older) page.
The ``total`` field is the count of all entries matching the current
filters across all pages.
"""
filters = _build_filters(categories, severities, actor, entity_type, entity_id, since, until, q)
# Fetch limit+1 rows to detect whether an older page exists.
#
# query() fetches DESC internally (newest-first) then reverses to ascending.
# With limit+1, the result is ascending: [oldest_probe, ..., newest].
# When we got exactly limit+1 rows, has_more is True and the probe row
# (index 0 — the oldest) is the extra one. We keep the newest `limit` rows
# by slicing [1:], which is the actual page content for the client.
# When we got <= limit rows, this is the last page and all rows are included.
effective_limit = min(limit, _MAX_LIMIT)
entries_plus = repo.query(filters, before_seq=before_seq, limit=effective_limit + 1)
has_more = len(entries_plus) > effective_limit
if has_more:
# Drop the oldest probe row; keep the newest `limit` entries.
entries = entries_plus[1:]
else:
entries = entries_plus
total = repo.count(filters)
# Compute next_before_seq: the seq of the oldest entry on this page.
# query() returns entries ascending (entries[0] is oldest); its seq is the
# cursor for the next page. The next request passes before_seq=X to get
# entries with seq < X, i.e. entries older than the oldest entry on this page.
# get_seq_for_id() does a cheap indexed point-lookup.
next_before_seq: int | None = None
if has_more and entries:
next_before_seq = repo.get_seq_for_id(entries[0].id)
return ActivityLogPageResponse(
entries=[entry_to_dict(e) for e in entries], # type: ignore[arg-type]
next_before_seq=next_before_seq,
has_more=has_more,
total=total,
)
# ---------------------------------------------------------------------------
# GET /api/v1/activity-log/export — streaming export (CSV or JSON)
# ---------------------------------------------------------------------------
def _export_csv_generator(
repo: ActivityLogRepository,
filters: ActivityLogFilters,
) -> Iterator[bytes]:
"""Yield UTF-8-encoded CSV chunks one row at a time.
The generator wraps ``repo.iter_export()`` in a ``try/finally`` so the DB
lock is released even on early client disconnect (which triggers
``GeneratorExit``).
"""
gen = repo.iter_export(filters)
try:
# Header
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(_CSV_COLUMNS)
yield buf.getvalue().encode("utf-8")
for entry in gen:
d = entry_to_dict(entry)
row = []
for col in _CSV_COLUMNS:
if col == "metadata":
cell = json.dumps(d.get(col) or {})
else:
cell = str(d.get(col, "") or "")
row.append(_csv_safe(cell))
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(row)
yield buf.getvalue().encode("utf-8")
finally:
gen.close()
def _export_json_generator(
repo: ActivityLogRepository,
filters: ActivityLogFilters,
) -> Iterator[bytes]:
"""Yield a streamed JSON array, one entry per chunk.
Format: ``[\\n{entry},\\n{entry},\\n...]\\n``
The generator wraps ``repo.iter_export()`` in a ``try/finally`` so the DB
lock is released even on early client disconnect.
"""
gen = repo.iter_export(filters)
try:
first = True
yield b"[\n"
for entry in gen:
d = entry_to_dict(entry)
chunk = json.dumps(d, ensure_ascii=False, default=str)
if first:
yield chunk.encode("utf-8")
first = False
else:
yield b",\n" + chunk.encode("utf-8")
yield b"\n]\n"
finally:
gen.close()
@router.get("/export", summary="Export activity-log entries (streaming CSV or JSON)")
def export_activity_log(
auth: AuthRequired,
repo: ActivityLogRepository = Depends(get_activity_log_repo),
# ── Format ────────────────────────────────────────────────────────────
format: Annotated[
str,
Query(description="Export format: 'csv' or 'json'"),
] = "csv",
# ── Same filters as list ───────────────────────────────────────────────
categories: Annotated[list[str] | None, Query()] = None,
severities: Annotated[list[str] | None, Query()] = None,
actor: Annotated[str | None, Query()] = None,
entity_type: Annotated[str | None, Query()] = None,
entity_id: Annotated[str | None, Query()] = None,
since: Annotated[datetime | None, Query()] = None,
until: Annotated[datetime | None, Query()] = None,
q: Annotated[str | None, Query()] = None,
) -> StreamingResponse:
"""Stream all matching entries as CSV or JSON.
Requires a non-anonymous API key (loopback-anonymous access is rejected
because the log may contain IP addresses and entity names).
"""
require_authenticated(auth)
if format not in ("csv", "json"):
from fastapi import HTTPException
raise HTTPException(
status_code=422,
detail="'format' must be 'csv' or 'json'",
)
filters = _build_filters(categories, severities, actor, entity_type, entity_id, since, until, q)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
if format == "csv":
filename = f"activity-log-{timestamp}.csv"
media_type = "text/csv; charset=utf-8"
generator = _export_csv_generator(repo, filters)
else:
filename = f"activity-log-{timestamp}.json"
media_type = "application/json"
generator = _export_json_generator(repo, filters)
return StreamingResponse(
generator,
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ---------------------------------------------------------------------------
# GET /api/v1/activity-log/settings
# PUT /api/v1/activity-log/settings
# ---------------------------------------------------------------------------
@router.get(
"/settings",
response_model=ActivityLogSettingsResponse,
summary="Get activity-log retention settings",
)
def get_activity_log_settings(
_: AuthRequired,
engine: ActivityLogRetentionEngine = Depends(get_activity_log_retention_engine),
) -> ActivityLogSettingsResponse:
"""Return the current activity-log retention settings."""
return ActivityLogSettingsResponse(**engine.get_settings())
@router.put(
"/settings",
response_model=ActivityLogSettingsResponse,
summary="Update activity-log retention settings",
)
async def update_activity_log_settings(
auth: AuthRequired,
body: UpdateActivityLogSettingsRequest,
engine: ActivityLogRetentionEngine = Depends(get_activity_log_retention_engine),
) -> ActivityLogSettingsResponse:
"""Update the activity-log retention settings (applied immediately).
Requires a non-anonymous API key (loopback-anonymous access is rejected)
because disabling the log or pruning retention is equivalent in impact to
clearing the audit trail.
Setting ``enabled=false`` records an audit entry BEFORE the flag takes
effect so the last entry in the log shows who disabled recording.
"""
require_authenticated(auth)
result = await engine.update_settings(
enabled=body.enabled,
max_days=body.max_days,
max_entries=body.max_entries,
)
return ActivityLogSettingsResponse(**result)
# ---------------------------------------------------------------------------
# DELETE /api/v1/activity-log — clear
# ---------------------------------------------------------------------------
@router.delete("", summary="Clear all activity-log entries")
def clear_activity_log(
auth: AuthRequired,
repo: ActivityLogRepository = Depends(get_activity_log_repo),
recorder: ActivityRecorder = Depends(get_activity_recorder),
) -> dict:
"""Delete all activity-log entries.
Requires a non-anonymous API key (loopback-anonymous access is rejected).
The clear operation itself is audited — a ``system/activity_log_cleared``
entry is recorded AFTER the wipe, so the log shows who cleared it and how
many rows were removed.
Returns ``{"deleted": <count>}``.
"""
require_authenticated(auth)
deleted = repo.clear()
# Record the clear action (best-effort — recorder never raises).
recorder.record(
category=ActivityCategory.SYSTEM,
action="activity_log.cleared",
severity=ActivitySeverity.INFO,
actor=auth,
message=f"Activity log cleared ({deleted} entries removed)",
metadata={"deleted_count": deleted},
)
return {"deleted": deleted}
@@ -182,6 +182,12 @@ async def delete_audio_source(
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete an audio source."""
_entity_name: str | None = None
try:
_entity_name = store.get_source(source_id).name
except Exception:
pass
try:
# Check if any CSS entities reference this audio source
from ledgrab.storage.color_strip_source import AudioColorStripSource
@@ -194,7 +200,7 @@ async def delete_audio_source(
raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'")
store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", source_id)
fire_entity_event("audio_source", "deleted", source_id, entity_name=_entity_name)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
+7 -1
View File
@@ -329,6 +329,12 @@ async def delete_automation(
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Delete an automation."""
_entity_name: str | None = None
try:
_entity_name = store.get_automation(automation_id).name
except Exception:
pass
# Deactivate first
await engine.deactivate_if_active(automation_id)
@@ -337,7 +343,7 @@ async def delete_automation(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("automation", "deleted", automation_id)
fire_entity_event("automation", "deleted", automation_id, entity_name=_entity_name)
# ===== Enable/Disable =====
+30 -1
View File
@@ -27,6 +27,7 @@ from ledgrab.api.schemas.system import (
)
from ledgrab.config import get_config
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.database import Database, freeze_writes
from ledgrab.utils import get_logger, read_upload_capped
@@ -35,6 +36,22 @@ logger = get_logger(__name__)
router = APIRouter()
def _record_system(action: str, message: str, metadata: dict | None = None) -> None:
"""Best-effort audit record for a system-level event."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action=action,
severity=ActivitySeverity.INFO,
message=message,
metadata=metadata or {},
)
_SERVER_DIR = Path(__file__).resolve().parents[4]
@@ -143,6 +160,8 @@ def backup_config(
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.zip"
_record_system("backup.created", f"Backup downloaded: {filename}", {"filename": filename})
return StreamingResponse(
zip_buffer,
media_type="application/zip",
@@ -243,6 +262,7 @@ async def restore_config(
freeze_writes()
logger.info("Database restored from uploaded backup. Scheduling restart...")
_record_system("backup.restored", "Database restored from uploaded backup")
_schedule_restart()
return RestoreResponse(
@@ -257,6 +277,7 @@ def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately."""
from ledgrab.server_ref import _broadcast_restarting
_record_system("server.restarting", "Server restart requested by user")
_broadcast_restarting()
_schedule_restart()
return {"status": "restarting"}
@@ -267,6 +288,7 @@ def shutdown_server(_: AuthRequired):
"""Gracefully shut down the server."""
from ledgrab.server_ref import request_shutdown
_record_system("server.shutdown_requested", "Server shutdown requested by user")
request_shutdown()
return {"status": "shutting_down"}
@@ -300,11 +322,17 @@ async def update_auto_backup_settings(
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Update auto-backup settings (enable/disable, interval, max backups)."""
return await engine.update_settings(
result = await engine.update_settings(
enabled=body.enabled,
interval_hours=body.interval_hours,
max_backups=body.max_backups,
)
_record_system(
"settings.changed",
f"Auto-backup settings updated (enabled={body.enabled})",
{"setting_key": "auto_backup", "enabled": body.enabled},
)
return result
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
@@ -365,4 +393,5 @@ async def delete_saved_backup(
engine.delete_backup(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
_record_system("backup.deleted", f"Saved backup deleted: {filename}", {"filename": filename})
return {"status": "deleted", "filename": filename}
@@ -36,6 +36,7 @@ from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_processor_manager
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.api.schemas.calibration import (
CalibrationSessionPositionRequest,
CalibrationSessionStartRequest,
@@ -81,6 +82,19 @@ async def start_calibration_session(
logger.error("Failed to start calibration session: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="calibration.started",
severity=ActivitySeverity.INFO,
entity_type="device",
entity_id=body.device_id,
message=f"Calibration session started for device '{body.device_id}'",
)
return CalibrationSessionStateResponse(**session.get_state())
@@ -135,6 +149,17 @@ async def stop_calibration_session(
logger.error("Failed to stop calibration session: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="calibration.stopped",
severity=ActivitySeverity.INFO,
message="Calibration session stopped",
)
return CalibrationSessionStateResponse(**session.get_state())
@@ -155,6 +180,17 @@ async def cancel_calibration_session(
logger.error("Failed to cancel calibration session: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="calibration.cancelled",
severity=ActivitySeverity.INFO,
message="Calibration session cancelled",
)
return CalibrationSessionStateResponse(**session.get_state())
@@ -167,6 +167,12 @@ async def delete_color_strip_source(
target_store: OutputTargetStore = Depends(get_output_target_store),
):
"""Delete a color strip source. Returns 409 if referenced by any LED target."""
_entity_name: str | None = None
try:
_entity_name = store.get_source(source_id).name
except Exception:
pass
try:
target_names = target_store.get_targets_referencing_css(source_id)
if target_names:
@@ -201,7 +207,7 @@ async def delete_color_strip_source(
"Delete or reassign the processed source(s) first.",
)
store.delete_source(source_id)
fire_entity_event("color_strip_source", "deleted", source_id)
fire_entity_event("color_strip_source", "deleted", source_id, entity_name=_entity_name)
except HTTPException:
raise
except ValueError as e:
+8 -1
View File
@@ -701,6 +701,13 @@ async def delete_device(
):
"""Delete/detach a device. Returns 409 if referenced by a target."""
try:
# Resolve name before deletion for the audit record.
_entity_name: str | None = None
try:
_entity_name = store.get_device(device_id).name
except Exception:
pass
# Check if any target references this device
refs = target_store.get_targets_for_device(device_id)
if refs:
@@ -728,7 +735,7 @@ async def delete_device(
# Delete from storage
store.delete_device(device_id)
fire_entity_event("device", "deleted", device_id)
fire_entity_event("device", "deleted", device_id, entity_name=_entity_name)
logger.info(f"Deleted device {device_id}")
except HTTPException:
+7 -1
View File
@@ -152,13 +152,19 @@ async def delete_gradient(
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete a gradient (fails if built-in or referenced by sources)."""
_entity_name: str | None = None
try:
_entity_name = store.get_gradient(gradient_id).name
except Exception:
pass
try:
# Check references
for source in css_store.get_all_sources():
if getattr(source, "gradient_id", None) == gradient_id:
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
store.delete_gradient(gradient_id)
fire_entity_event("gradient", "deleted", gradient_id)
fire_entity_event("gradient", "deleted", gradient_id, entity_name=_entity_name)
except (ValueError, EntityNotFoundError) as e:
status = 404 if "not found" in str(e).lower() else 400
raise HTTPException(status_code=status, detail=str(e))
@@ -624,6 +624,13 @@ async def delete_target(
):
"""Delete a output target. Stops processing first if active."""
try:
# Resolve name before deletion for the audit record.
_entity_name: str | None = None
try:
_entity_name = target_store.get_target(target_id).name
except Exception:
pass
# Stop processing if running
try:
await manager.stop_processing(target_id)
@@ -641,7 +648,7 @@ async def delete_target(
# Delete from store
target_store.delete_target(target_id)
fire_entity_event("output_target", "deleted", target_id)
fire_entity_event("output_target", "deleted", target_id, entity_name=_entity_name)
logger.info(f"Deleted target {target_id}")
except ValueError as e:
@@ -12,6 +12,7 @@ from ledgrab.api.dependencies import (
get_picture_source_store,
get_processor_manager,
)
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.api.schemas.output_targets import (
BulkTargetRequest,
BulkTargetResponse,
@@ -28,6 +29,7 @@ from ledgrab.storage.color_strip_source import (
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.wled_output_target import WledOutputTarget
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -35,6 +37,23 @@ logger = get_logger(__name__)
router = APIRouter()
def _record_capture(action: str, target_id: str, target_name: str | None, message: str) -> None:
"""Best-effort audit record for a capture start/stop action."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.CAPTURE,
action=action,
severity=ActivitySeverity.INFO,
entity_type="output_target",
entity_id=target_id,
entity_name=sanitize_display(target_name) if target_name else None,
message=message,
)
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
@@ -53,10 +72,18 @@ async def bulk_start_processing(
for target_id in body.ids:
try:
target_store.get_target(target_id)
_tgt = target_store.get_target(target_id)
await manager.start_processing(target_id)
started.append(target_id)
logger.info(f"Bulk start: started processing for target {target_id}")
_tgt_name_raw = getattr(_tgt, "name", None)
_tgt_safe = sanitize_display(_tgt_name_raw) if _tgt_name_raw else None
_record_capture(
"capture.started",
target_id,
_tgt_safe,
f"Capture started for target '{_tgt_safe or target_id}' (bulk)",
)
except ValueError as e:
errors[target_id] = str(e)
except RuntimeError as e:
@@ -78,6 +105,7 @@ async def bulk_start_processing(
async def bulk_stop_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
@@ -89,6 +117,18 @@ async def bulk_stop_processing(
await manager.stop_processing(target_id)
stopped.append(target_id)
logger.info(f"Bulk stop: stopped processing for target {target_id}")
_tgt_name: str | None = None
try:
_tgt_name = target_store.get_target(target_id).name
except Exception:
pass
_tgt_name_safe = sanitize_display(_tgt_name) if _tgt_name else None
_record_capture(
"capture.stopped",
target_id,
_tgt_name_safe,
f"Capture stopped for target '{_tgt_name_safe or target_id}' (bulk)",
)
except ValueError as e:
errors[target_id] = str(e)
except Exception as e:
@@ -112,11 +152,19 @@ async def start_processing(
logger.info("Start processing requested for target %s", target_id)
try:
# Verify target exists in store
target_store.get_target(target_id)
target = target_store.get_target(target_id)
await manager.start_processing(target_id)
logger.info(f"Started processing for target {target_id}")
_tgt_name_raw2 = getattr(target, "name", None)
_tgt_safe2 = sanitize_display(_tgt_name_raw2) if _tgt_name_raw2 else None
_record_capture(
"capture.started",
target_id,
_tgt_safe2,
f"Capture started for target '{_tgt_safe2 or target_id}'",
)
return {"status": "started", "target_id": target_id}
except ValueError as e:
@@ -137,6 +185,7 @@ async def start_processing(
async def stop_processing(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for a output target."""
@@ -144,6 +193,18 @@ async def stop_processing(
await manager.stop_processing(target_id)
logger.info(f"Stopped processing for target {target_id}")
_target_name: str | None = None
try:
_target_name = target_store.get_target(target_id).name
except Exception:
pass
_target_name_safe = sanitize_display(_target_name) if _target_name else None
_record_capture(
"capture.stopped",
target_id,
_target_name_safe,
f"Capture stopped for target '{_target_name_safe or target_id}'",
)
return {"status": "stopped", "target_id": target_id}
except ValueError as e:
@@ -374,6 +374,12 @@ async def delete_picture_source(
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete a picture source."""
_entity_name: str | None = None
try:
_entity_name = store.get_stream(stream_id).name
except Exception:
pass
try:
# Check if any target transitively references this stream via a CSS
target_names = store.get_targets_referencing(stream_id, target_store, css_store)
@@ -395,7 +401,7 @@ async def delete_picture_source(
f"{css_names}. Please reassign or delete those first.",
)
store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", stream_id)
fire_entity_event("picture_source", "deleted", stream_id, entity_name=_entity_name)
except HTTPException:
raise
except EntityNotFoundError as e:
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.api.dependencies import (
fire_entity_event,
get_playlist_engine,
@@ -220,13 +221,19 @@ async def delete_scene_playlist(
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Delete a scene playlist (stops it first if it is currently cycling)."""
_entity_name: str | None = None
try:
_entity_name = store.get_playlist(playlist_id).name
except Exception:
pass
try:
store.delete_playlist(playlist_id)
except (ValueError, EntityNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
await engine.stop_if_running(playlist_id)
fire_entity_event("scene_playlist", "deleted", playlist_id)
fire_entity_event("scene_playlist", "deleted", playlist_id, entity_name=_entity_name)
# ===== Cycling control =====
@@ -255,6 +262,28 @@ async def start_scene_playlist(
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("scene_playlist", "updated", playlist_id)
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_pl_name: str | None = None
try:
_pl_name = store.get_playlist(playlist_id).name
except Exception:
pass
_safe_pl_name = sanitize_display(_pl_name) if _pl_name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="playlist.started",
severity=ActivitySeverity.INFO,
entity_type="scene_playlist",
entity_id=playlist_id,
entity_name=_safe_pl_name,
message=f"Playlist '{_safe_pl_name or playlist_id}' started",
)
return PlaylistRuntimeStateSchema(**engine.get_state())
@@ -265,11 +294,35 @@ async def start_scene_playlist(
)
async def stop_scene_playlist(
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Stop the active playlist (leaves the last applied scene in place)."""
stopped_id = engine.get_running_playlist_id()
_stopped_name: str | None = None
if stopped_id:
try:
_stopped_name = store.get_playlist(stopped_id).name
except Exception:
pass
await engine.stop()
if stopped_id:
fire_entity_event("scene_playlist", "updated", stopped_id)
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_stopped_name = sanitize_display(_stopped_name) if _stopped_name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="playlist.stopped",
severity=ActivitySeverity.INFO,
entity_type="scene_playlist",
entity_id=stopped_id,
entity_name=_safe_stopped_name,
message=f"Playlist '{_safe_stopped_name or stopped_id}' stopped",
)
return PlaylistRuntimeStateSchema(**engine.get_state())
+25 -1
View File
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.api.dependencies import (
fire_entity_event,
get_output_target_store,
@@ -208,12 +209,18 @@ async def delete_scene_preset(
store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Delete a scene preset."""
_entity_name: str | None = None
try:
_entity_name = store.get_preset(preset_id).name
except Exception:
pass
try:
store.delete_preset(preset_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("scene_preset", "deleted", preset_id)
fire_entity_event("scene_preset", "deleted", preset_id, entity_name=_entity_name)
# ===== Recapture =====
@@ -282,4 +289,21 @@ async def activate_scene_preset(
logger.info(f"Scene preset '{preset.name}' activated successfully")
fire_entity_event("scene_preset", "updated", preset_id)
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_preset_name = sanitize_display(preset.name) if preset.name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="scene.activated",
severity=ActivitySeverity.INFO,
entity_type="scene_preset",
entity_id=preset_id,
entity_name=_safe_preset_name,
message=f"Scene preset '{_safe_preset_name or preset_id}' activated",
)
return ActivateResponse(status=status, errors=errors)
+7 -1
View File
@@ -149,6 +149,12 @@ async def delete_sync_clock(
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Delete a synchronization clock (fails if referenced by CSS or value sources)."""
_entity_name: str | None = None
try:
_entity_name = store.get_clock(clock_id).name
except Exception:
pass
try:
# Check references
for source in css_store.get_all_sources():
@@ -159,7 +165,7 @@ async def delete_sync_clock(
raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'")
manager.release_all_for(clock_id)
store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id)
fire_entity_event("sync_clock", "deleted", clock_id, entity_name=_entity_name)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -21,11 +21,29 @@ from ledgrab.api.schemas.system import (
ShutdownActionRequest,
ShutdownActionResponse,
)
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
logger = get_logger(__name__)
def _record_setting(action: str, key: str, message: str) -> None:
"""Best-effort audit record for a high-value settings change."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action=action,
severity=ActivitySeverity.INFO,
message=message,
metadata={"setting_key": key},
)
router = APIRouter()
@@ -117,6 +135,11 @@ async def update_shutdown_action(
"""Set what happens to LED targets when the server shuts down."""
db.set_setting("shutdown_action", {"action": body.action})
logger.info("Shutdown action updated: %s", body.action)
_record_setting(
"settings.changed",
"shutdown_action",
f"Shutdown action set to '{body.action}'",
)
return ShutdownActionResponse(action=body.action)
@@ -246,6 +269,17 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
output = (stdout.decode() + stderr.decode()).strip()
if "connected" in output.lower():
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.DEVICE,
action="device.adb_connected",
severity=ActivitySeverity.INFO,
message=f"ADB device connected: {sanitize_display(address)}",
metadata={"address": address},
)
return {"status": "connected", "address": address, "message": output}
raise HTTPException(status_code=400, detail=output or "Connection failed")
except FileNotFoundError:
@@ -276,6 +310,17 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.DEVICE,
action="device.adb_disconnected",
severity=ActivitySeverity.INFO,
message=f"ADB device disconnected: {sanitize_display(address)}",
metadata={"address": address},
)
return {"status": "disconnected", "message": stdout.decode().strip()}
except FileNotFoundError:
raise HTTPException(status_code=500, detail="adb not found on PATH")
+7 -1
View File
@@ -183,6 +183,12 @@ async def delete_template(
Validates that no streams are currently using this template before deletion.
"""
_entity_name: str | None = None
try:
_entity_name = template_store.get_template(template_id).name
except Exception:
pass
try:
# Check if any streams are using this template
streams_using_template = []
@@ -203,7 +209,7 @@ async def delete_template(
# Proceed with deletion
template_store.delete_template(template_id)
fire_entity_event("capture_template", "deleted", template_id)
fire_entity_event("capture_template", "deleted", template_id, entity_name=_entity_name)
except HTTPException:
raise # Re-raise HTTP exceptions as-is
+37 -1
View File
@@ -12,6 +12,7 @@ from ledgrab.api.schemas.update import (
UpdateStatusResponse,
)
from ledgrab.core.update.update_service import UpdateService
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -42,6 +43,17 @@ async def dismiss_update(
service: UpdateService = Depends(get_update_service),
):
service.dismiss(body.version)
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="update.dismissed",
severity=ActivitySeverity.INFO,
message=f"Update dismissed: {body.version}",
metadata={"version": body.version},
)
return {"ok": True}
@@ -63,6 +75,18 @@ async def apply_update(
)
try:
await service.apply_update()
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
version = status.get("available_version", "unknown")
rec.record(
category=ActivityCategory.SYSTEM,
action="update.applied",
severity=ActivitySeverity.INFO,
message=f"Update applied: {version}",
metadata={"version": version},
)
return {"ok": True, "message": "Update applied, server shutting down"}
except Exception as exc:
logger.error("Failed to apply update: %s", exc, exc_info=True)
@@ -83,8 +107,20 @@ async def update_update_settings(
body: UpdateSettingsRequest,
service: UpdateService = Depends(get_update_service),
):
return await service.update_settings(
result = await service.update_settings(
enabled=body.enabled,
check_interval_hours=body.check_interval_hours,
include_prerelease=body.include_prerelease,
)
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="settings.changed",
severity=ActivitySeverity.INFO,
message=f"Update settings changed (enabled={body.enabled})",
metadata={"setting_key": "update", "enabled": body.enabled},
)
return result
@@ -0,0 +1,93 @@
"""Pydantic schemas for the activity-log API (Phase 4)."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Entry + page response
# ---------------------------------------------------------------------------
class ActivityLogEntryResponse(BaseModel):
"""Single audit-log entry.
Shape matches ``entry_to_dict()`` from
``ledgrab.core.activity_log.recorder`` exactly — that function is the
single source of truth for serialisation; this schema documents the wire
format.
"""
id: str = Field(description="Entry id — 'al_<8-hex>'")
ts: str = Field(description="ISO-8601 UTC timestamp")
category: str = Field(description="Broad bucket (auth, device, entity, capture, system)")
action: str = Field(description="Verb-object label, e.g. 'entity.created'")
severity: str = Field(description="info | warning | error")
actor: str = Field(description="API-key label or 'system' / 'anonymous'")
entity_type: str | None = Field(default=None, description="Affected entity type, if applicable")
entity_id: str | None = Field(default=None, description="Affected entity id, if applicable")
entity_name: str | None = Field(
default=None, description="Entity name at time of event, if applicable"
)
message: str = Field(description="Human-readable description")
metadata: dict[str, Any] = Field(default_factory=dict, description="Extra structured context")
class ActivityLogPageResponse(BaseModel):
"""Paginated list of audit-log entries (keyset cursor)."""
entries: list[ActivityLogEntryResponse] = Field(description="Entries on this page")
next_before_seq: int | None = Field(
default=None,
description=(
"Pass as 'before_seq' in the next request to get the following page. "
"None when this is the last page."
),
)
has_more: bool = Field(
description="True when there are more entries before the first entry on this page"
)
total: int = Field(description="Total entries matching the current filters (all pages)")
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
_MAX_DAYS_CAP = 3650 # 10 years — sanity upper bound
_MAX_ENTRIES_CAP = 10_000_000 # 10 M rows — sanity upper bound
class ActivityLogSettingsResponse(BaseModel):
"""Current activity-log retention settings."""
enabled: bool = Field(description="Whether the activity log is recording")
max_days: int = Field(
ge=0,
le=_MAX_DAYS_CAP,
description="Retain entries for at most this many days (0 = no age-based pruning)",
)
max_entries: int = Field(
ge=0,
le=_MAX_ENTRIES_CAP,
description="Keep at most this many entries (0 = no count-based pruning)",
)
class UpdateActivityLogSettingsRequest(BaseModel):
"""Request body for PUT /settings."""
enabled: bool = Field(description="Enable or disable activity-log recording")
max_days: int = Field(
ge=0,
le=_MAX_DAYS_CAP,
description="Retain entries for at most this many days (0 = no age-based pruning)",
)
max_entries: int = Field(
ge=0,
le=_MAX_ENTRIES_CAP,
description="Keep at most this many entries (0 = no count-based pruning)",
)
@@ -0,0 +1,25 @@
"""Activity / audit log core — recorder, retention engine, and actor context.
Public surface
--------------
``ActivityRecorder`` — thread-safe facade; persists entries and fires live events.
``ActivityLogRetentionEngine`` — background pruning engine (mirrors AutoBackupEngine).
``current_actor`` — ``ContextVar[str]`` set by the auth layer per request.
Quick start
-----------
Wired in ``main.py`` lifespan; injected via ``api/dependencies.py`` getters.
Phase 3 adds the instrumentation call sites.
"""
from ledgrab.core.activity_log.context import current_actor
from ledgrab.core.activity_log.recorder import ActivityRecorder
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.core.activity_log.sanitize import sanitize_display
__all__ = [
"ActivityRecorder",
"ActivityLogRetentionEngine",
"current_actor",
"sanitize_display",
]
@@ -0,0 +1,21 @@
"""Actor context variable for the activity log.
``current_actor`` is set by ``api/auth.py:verify_api_key`` on every request so
that ``ActivityRecorder.record(...)`` can resolve the actor without requiring
every call site to pass it explicitly.
Default value is ``"system"`` — used by background engines and any code path
that runs outside a request context (e.g. lifespan startup/shutdown, zeroconf
discovery thread).
Per-request isolation is guaranteed by ASGI's coroutine context: each request
runs in its own coroutine with its own copy of the context inherited from the
server's main task. The auth layer resets it on every request before the route
handler runs, so stale labels from a previous request cannot bleed into a new
one.
"""
from contextvars import ContextVar
#: The actor label for the current request — API-key label or ``"system"``.
current_actor: ContextVar[str] = ContextVar("current_actor", default="system")
@@ -0,0 +1,267 @@
"""Thread-safe ActivityRecorder facade.
Responsibilities
----------------
1. Build an ``ActivityLogEntry`` from the caller-supplied fields.
2. Resolve the ``actor`` from the ``current_actor`` ContextVar when not given.
3. Persist the entry via ``ActivityLogRepository.record()`` on the event-loop
thread — inline if already on that thread, via
``loop.call_soon_threadsafe`` if called from another thread (e.g. the
zeroconf discovery thread that fires ``device_discovered/lost`` events).
4. Push a live ``activity_logged`` event via
``ProcessorManager.fire_event({"type": "activity_logged", "entry": {...}})``.
5. Never raise into the caller — audit recording is best-effort. Failures are
logged at ``WARNING`` level so operators can diagnose without breaking the
audited action.
Thread-marshal pattern mirrors ``utils/log_broadcaster.py`` (``ensure_loop`` /
``call_soon_threadsafe``).
Module accessor
---------------
A module-level singleton ``_recorder`` is populated by
``set_module_recorder()`` during ``main.py`` lifespan startup and exposed via
``get_module_recorder()``. Background engines and other non-DI sites that need
to call ``record()`` without FastAPI DI can use this accessor. Phase 3
instrumentation uses it at the ``fire_entity_event`` choke-point.
"""
from __future__ import annotations
import asyncio
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from ledgrab.core.activity_log.context import current_actor
from ledgrab.storage.activity_log import ActivityLogEntry, ActivitySeverity
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage.activity_log_repository import ActivityLogRepository
logger = get_logger(__name__)
def _new_id() -> str:
"""Generate a compact activity-log entry id: ``al_<8-hex-chars>``."""
return "al_" + uuid.uuid4().hex[:8]
def entry_to_dict(entry: ActivityLogEntry) -> dict:
"""Serialise an ``ActivityLogEntry`` to the canonical API/event payload dict.
Reused by Phase 4 (API response serialisation) and Phase 5 (frontend).
The shape is identical to the flat row codec minus the DB-only ``seq``
field, but with ``ts`` kept as an ISO-8601 string and ``metadata`` as a
real ``dict`` (not a JSON string).
"""
return {
"id": entry.id,
"ts": entry.ts.isoformat(),
"category": entry.category,
"action": entry.action,
"severity": entry.severity,
"actor": entry.actor,
"entity_type": entry.entity_type,
"entity_id": entry.entity_id,
"entity_name": entry.entity_name,
"message": entry.message,
"metadata": entry.metadata,
}
class ActivityRecorder:
"""Thread-safe facade for persisting audit log entries.
Parameters
----------
repo:
``ActivityLogRepository`` used to persist entries.
processor_manager:
``ProcessorManager`` whose ``fire_event`` dispatches the live
``activity_logged`` event to WebSocket subscribers.
loop:
Optional: the running asyncio event loop. If ``None``, it is
captured lazily on the first call that originates from an async
context (mirroring ``LogBroadcaster.ensure_loop``). Pass it
explicitly in tests to avoid depending on a real running loop.
"""
def __init__(
self,
repo: "ActivityLogRepository",
processor_manager: "ProcessorManager",
*,
loop: asyncio.AbstractEventLoop | None = None,
) -> None:
self._repo = repo
self._pm = processor_manager
self._loop: asyncio.AbstractEventLoop | None = loop
self._enabled: bool = True
# ── Loop capture (mirrors LogBroadcaster.ensure_loop) ──────────────────
def ensure_loop(self) -> None:
"""Capture the running event loop if not already stored.
Call from an async context (e.g. lifespan startup) so that
``call_soon_threadsafe`` works when ``record()`` is later called from
non-async threads.
"""
if self._loop is None:
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
pass
# ── Public API ──────────────────────────────────────────────────────────
@property
def enabled(self) -> bool:
"""Whether recording is currently active."""
return self._enabled
@enabled.setter
def enabled(self, value: bool) -> None:
self._enabled = value
def record(
self,
category: str,
action: str,
*,
severity: str = ActivitySeverity.INFO,
actor: str | None = None,
entity_type: str | None = None,
entity_id: str | None = None,
entity_name: str | None = None,
message: str,
metadata: dict | None = None,
_bypass_enabled: bool = False,
) -> None:
"""Append one audit entry — best-effort, never raises.
Parameters
----------
category:
Broad bucket — one of :class:`~ledgrab.storage.activity_log.ActivityCategory`.
action:
Verb-object label, e.g. ``"entity.created"`` or ``"server.shutting_down"``.
severity:
One of :class:`~ledgrab.storage.activity_log.ActivitySeverity`. Defaults
to ``"info"``.
actor:
Who triggered the action. When ``None`` (the common case), the
value is resolved from :data:`~ledgrab.core.activity_log.context.current_actor`
with a default of ``"system"``.
entity_type / entity_id / entity_name:
Optional entity context for entity-domain events.
message:
Human-readable description suitable for display.
metadata:
Small JSON-serialisable dict with extra context. Defaults to ``{}``.
_bypass_enabled:
Internal flag used by the retention engine to record the
"audit log disabled" event even when ``enabled`` is ``False``.
"""
if not self._enabled and not _bypass_enabled:
return
# Resolve actor from ContextVar when not explicitly supplied.
resolved_actor = actor if actor is not None else current_actor.get()
entry = ActivityLogEntry(
id=_new_id(),
ts=datetime.now(timezone.utc),
category=category,
action=action,
severity=severity,
actor=resolved_actor,
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
message=message,
metadata=metadata or {},
)
# Determine whether we are on the event-loop thread or not.
loop = self._loop
if loop is None:
# Lazy capture — may fail if called before the loop is running.
try:
loop = asyncio.get_running_loop()
self._loop = loop
except RuntimeError:
pass
if loop is not None and loop.is_running():
try:
current = asyncio.get_event_loop()
except RuntimeError:
current = None
# If the current thread IS the event-loop thread, write inline.
if current is loop:
self._write_and_emit(entry)
else:
# Called from a non-loop thread (e.g. zeroconf discovery) —
# marshal onto the event-loop thread.
try:
loop.call_soon_threadsafe(self._write_and_emit, entry)
except RuntimeError:
# Loop has been closed (rare; happens during tests)
logger.warning(
"ActivityRecorder: event loop closed, dropping entry %s", entry.id
)
else:
# No running loop — fall back to a direct synchronous write.
# This path hits in synchronous unit tests that do not start a loop.
self._write_and_emit(entry)
def _write_and_emit(self, entry: ActivityLogEntry) -> None:
"""Persist *entry* and fire the live event — called on the loop thread."""
try:
self._repo.record(entry)
except Exception as exc:
logger.warning("ActivityRecorder: failed to persist entry %s: %s", entry.id, exc)
return # don't emit an event for an entry that failed to persist
try:
self._pm.fire_event(
{
"type": "activity_logged",
"entry": entry_to_dict(entry),
}
)
except Exception as exc:
logger.warning("ActivityRecorder: failed to fire live event for %s: %s", entry.id, exc)
# ── Module-level singleton accessor ────────────────────────────────────────
#
# Background engines and non-DI call sites (Phase 3's fire_entity_event hook,
# device discovery thread) need ``record()`` without going through FastAPI DI.
# ``set_module_recorder`` is called from ``main.py`` lifespan immediately after
# the recorder is wired into ``init_dependencies``.
_recorder: ActivityRecorder | None = None
def set_module_recorder(recorder: ActivityRecorder) -> None:
"""Store the application-level recorder in the module singleton.
Called once from ``main.py`` lifespan startup.
"""
global _recorder
_recorder = recorder
def get_module_recorder() -> ActivityRecorder | None:
"""Return the module-level recorder, or ``None`` if not yet initialised.
Callers must guard against ``None`` — this returns ``None`` during module
import and early startup before ``main.py`` lifespan has run.
"""
return _recorder
@@ -0,0 +1,216 @@
"""Activity log retention engine.
Mirrors ``core/backup/auto_backup.py``:
- Settings persisted via ``db.get_setting("activity_log")`` /
``db.set_setting("activity_log", {...})``.
- ``start()`` / ``stop()`` lifecycle following the engine convention used
throughout the codebase.
- Hourly background loop calling ``repo.prune(before_ts=..., max_entries=...)``.
- ``get_settings()`` / ``async update_settings(...)`` for the Settings API
(Phase 4).
Changing ``enabled`` to ``False`` records an ``"audit_log.disabled"`` event via
the recorder BEFORE the flag takes effect — so the last action in the log is a
record of the intentional disable.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.activity_log.recorder import ActivityRecorder
from ledgrab.storage.activity_log_repository import ActivityLogRepository
from ledgrab.storage.database import Database
logger = get_logger(__name__)
DEFAULT_SETTINGS: dict = {
"enabled": True,
"max_days": 90,
"max_entries": 20000,
}
# Prune loop interval — run roughly once an hour.
_PRUNE_INTERVAL_SECS = 3600
class ActivityLogRetentionEngine:
"""Background engine that prunes old activity log entries.
Parameters
----------
repo:
The ``ActivityLogRepository`` used to prune entries.
db:
The shared ``Database`` singleton for settings persistence.
recorder:
The ``ActivityRecorder`` used to log the "audit log disabled" event
before disabling takes effect.
"""
def __init__(
self,
repo: "ActivityLogRepository",
db: "Database",
recorder: "ActivityRecorder",
) -> None:
self._repo = repo
self._db = db
self._recorder = recorder
self._task: asyncio.Task | None = None
self._settings = self._load_settings()
# Rehydrate the recorder's enabled flag from persisted settings so a
# previously-disabled log stays disabled across restarts.
self._recorder.enabled = self._settings["enabled"]
# ── Settings persistence ───────────────────────────────────────────────
def _load_settings(self) -> dict:
data = self._db.get_setting("activity_log")
if data:
return {**DEFAULT_SETTINGS, **data}
return dict(DEFAULT_SETTINGS)
def _save_settings(self) -> None:
self._db.set_setting(
"activity_log",
{
"enabled": self._settings["enabled"],
"max_days": self._settings["max_days"],
"max_entries": self._settings["max_entries"],
},
)
# ── Lifecycle ──────────────────────────────────────────────────────────
async def start(self) -> None:
"""Start the retention loop if enabled."""
if self._settings["enabled"]:
self._start_loop()
logger.info(
"Activity log retention engine started " "(max_days=%d, max_entries=%d)",
self._settings["max_days"],
self._settings["max_entries"],
)
else:
logger.info("Activity log retention engine initialized (disabled)")
async def stop(self) -> None:
"""Cancel the retention loop."""
self._cancel_loop()
logger.info("Activity log retention engine stopped")
def _start_loop(self) -> None:
self._cancel_loop()
self._task = asyncio.create_task(self._retention_loop())
def _cancel_loop(self) -> None:
if self._task is not None:
self._task.cancel()
self._task = None
# ── Prune loop ─────────────────────────────────────────────────────────
async def _retention_loop(self) -> None:
try:
while True:
await asyncio.sleep(_PRUNE_INTERVAL_SECS)
try:
self._prune()
except Exception as exc:
logger.error("Activity log retention prune failed: %s", exc, exc_info=True)
except asyncio.CancelledError:
logger.debug("Activity log retention loop cancelled")
def _prune(self) -> None:
"""Execute one prune pass based on current settings."""
settings = self._settings
if not settings["enabled"]:
return
max_days: int = settings["max_days"]
max_entries: int = settings["max_entries"]
before_ts: datetime | None = None
if max_days and max_days > 0:
before_ts = datetime.now(timezone.utc) - timedelta(days=max_days)
max_entries_val: int | None = max_entries if max_entries and max_entries > 0 else None
deleted = self._repo.prune(before_ts=before_ts, max_entries=max_entries_val)
if deleted:
logger.info(
"Activity log pruned %d rows (max_days=%d, max_entries=%d)",
deleted,
max_days,
max_entries,
)
# ── Public API ─────────────────────────────────────────────────────────
def get_settings(self) -> dict:
"""Return the current retention settings dict."""
return {
"enabled": self._settings["enabled"],
"max_days": self._settings["max_days"],
"max_entries": self._settings["max_entries"],
}
async def update_settings(
self,
*,
enabled: bool,
max_days: int,
max_entries: int,
) -> dict:
"""Persist new settings and apply them immediately.
If ``enabled`` is changing to ``False``, the disable event is recorded
BEFORE the flag takes effect so there is a final log entry.
Returns the new settings dict (same as ``get_settings()``).
"""
was_enabled = self._settings["enabled"]
# Record the disable event before the recorder stops accepting entries.
if was_enabled and not enabled:
self._recorder.record(
category=ActivityCategory.SYSTEM,
action="audit_log.disabled",
severity=ActivitySeverity.WARNING,
actor="system",
message="Activity log recording disabled via settings",
_bypass_enabled=True,
)
self._settings["enabled"] = enabled
self._settings["max_days"] = max_days
self._settings["max_entries"] = max_entries
self._save_settings()
# Propagate enabled flag to the recorder.
self._recorder.enabled = enabled
if enabled:
self._start_loop()
logger.info(
"Activity log retention enabled (max_days=%d, max_entries=%d)",
max_days,
max_entries,
)
# Run an immediate prune pass when re-enabling.
try:
self._prune()
except Exception as exc:
logger.error("Activity log immediate prune failed: %s", exc)
else:
self._cancel_loop()
logger.info("Activity log retention disabled")
return self.get_settings()
@@ -0,0 +1,82 @@
"""Log-injection sanitizer for audit-log message and display strings.
Provides :func:`sanitize_display` — a dependency-free helper that strips
characters that should not appear in a recorded ``message`` or display
string before it is persisted to SQLite, broadcast over WebSocket, or
exported to CSV.
Design constraints
------------------
- **Dependency-free**: uses only the Python standard library so it can be
imported from any module without adding transitive weight.
- **Conservative**: keeps printable ASCII/Unicode and normal spaces; drops
everything else including control chars (NUL, BEL, BS, VT, FF, ESC,
DEL), ANSI/CSI escape sequences (``\\x1b[...``), and carriage returns /
newlines / tabs which are the classic log-injection primitives.
- **Length-capped**: truncates to *maxlen* characters and appends ``""``
so callers can rely on a bounded string without adding their own guards.
"""
from __future__ import annotations
import re
# Matches ANSI/VT100 escape sequences: ESC [ ... m (CSI) and shorter forms.
# We strip these before the printable-char filter so the bracket/letters that
# follow the ESC don't survive stripping the ESC alone.
_ANSI_RE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
# Characters we explicitly want to remove even if str.isprintable() would
# let them through in some edge-case: NUL is the canonical SQL/log null-byte
# injection; the others are kept out by the printable check but listed here
# for documentation clarity.
_EXPLICIT_DROP = frozenset("\x00\r\n\t")
def sanitize_display(value: str | None, *, maxlen: int = 120) -> str:
"""Return a sanitized, length-capped version of *value* safe for log messages.
Parameters
----------
value:
The raw, potentially attacker-controlled string. ``None`` or empty
returns ``""``.
maxlen:
Maximum length of the returned string (default: 120). If the input
exceeds this length after sanitization, the string is truncated and
``""`` is appended (the ellipsis counts toward *maxlen*).
Returns
-------
str
A string that:
- contains no NUL bytes (``\\x00``),
- contains no ANSI/CSI escape sequences,
- contains no carriage returns, newlines, or tab characters,
- contains only characters for which ``str.isprintable()`` is ``True``
plus the regular ASCII space (``\\x20``),
- is at most *maxlen* characters long.
"""
if not value:
return ""
# 1. Strip ANSI escape sequences first so their bracket/letter tails don't
# survive as stray printable characters.
cleaned = _ANSI_RE.sub("", value)
# 2. Drop each character that is neither printable nor a plain space.
# str.isprintable() returns False for all control chars (including NUL,
# BEL, BS, TAB, LF, VT, FF, CR, ESC, DEL) and True for normal letters,
# digits, punctuation, and the space character.
cleaned = "".join(ch for ch in cleaned if ch.isprintable() or ch == " ")
# 3. Final belt-and-suspenders pass for the explicit drop set (catches NUL
# that may survive if isprintable ever changes in a future Python version).
cleaned = "".join(ch for ch in cleaned if ch not in _EXPLICIT_DROP)
# 4. Cap length.
if len(cleaned) > maxlen:
# Reserve one character for the ellipsis so total length == maxlen.
cleaned = cleaned[: maxlen - 1] + ""
return cleaned
@@ -726,6 +726,28 @@ class AutomationEngine:
else:
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
# Audit record — best-effort.
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_name = sanitize_display(automation.name) if automation.name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.activated",
severity=ActivitySeverity.INFO,
actor="system",
entity_type="automation",
entity_id=automation.id,
entity_name=_safe_name,
message=f"Automation '{_safe_name or automation.id}' activated",
)
except Exception:
pass
async def _deactivate_automation(self, automation_id: str) -> None:
was_active = self._active_automations.pop(automation_id, False)
if not was_active:
@@ -751,6 +773,33 @@ class AutomationEngine:
# Clean up any leftover snapshot
self._pre_activation_snapshots.pop(automation_id, None)
# Audit record — best-effort.
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_auto_name: str | None = None
try:
_auto_name = self._store.get_automation(automation_id).name
except Exception:
pass
_safe_deact_name = sanitize_display(_auto_name) if _auto_name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.deactivated",
severity=ActivitySeverity.INFO,
actor="system",
entity_type="automation",
entity_id=automation_id,
entity_name=_safe_deact_name,
message=f"Automation '{_safe_deact_name or automation_id}' deactivated",
)
except Exception:
pass
async def _deactivate_revert(self, automation_id: str) -> None:
"""Revert to pre-activation snapshot."""
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
@@ -36,6 +36,7 @@ from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZerocon
from ledgrab.core.devices.serial_transport import list_serial_ports
from ledgrab.core.devices.wled_provider import WLED_MDNS_TYPE
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android
@@ -286,3 +287,34 @@ class DiscoveryWatcher:
)
except Exception as e:
logger.debug("Discovery watcher: fire_event failed: %s", e)
# Audit record — best-effort, thread-safe (recorder marshals via
# call_soon_threadsafe when called from the zeroconf thread).
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
rec = get_module_recorder()
if rec is not None:
is_discovered = event_type == "device_discovered"
action = "device.discovered" if is_discovered else "device.lost"
severity = ActivitySeverity.INFO if is_discovered else ActivitySeverity.WARNING
verb = "discovered" if is_discovered else "lost"
# Sanitize mDNS-advertised strings before they enter the log.
# entry.name and entry.url are unauthenticated, attacker-controlled
# values; strip control chars, ANSI escapes, and NUL before use.
safe_name = sanitize_display(entry.name)
safe_url = sanitize_display(entry.url)
rec.record(
category=ActivityCategory.DEVICE,
action=action,
severity=severity,
actor="system",
entity_type="device",
entity_id=entry.url,
entity_name=safe_name,
message=f"Device '{safe_name}' {verb} at {safe_url}",
metadata={"url": safe_url, "device_type": entry.device_type},
)
except Exception as e:
logger.debug("Discovery watcher: audit record failed: %s", e)
@@ -11,6 +11,8 @@ from ledgrab.core.devices.led_client import (
check_device_health,
get_device_capabilities,
)
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -128,6 +130,35 @@ class DeviceHealthMixin:
"latency_ms": state.health.latency_ms,
}
)
# Audit record for device online/offline transition.
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
is_online = state.health.online
# Best-effort name lookup from the device store.
device_name: str | None = None
try:
if self._device_store is not None:
device_name = self._device_store.get_device(device_id).name
except Exception:
pass
safe_name = sanitize_display(device_name) if device_name else None
display = safe_name or device_id
action = "device.online" if is_online else "device.offline"
severity = ActivitySeverity.INFO if is_online else ActivitySeverity.WARNING
status_word = "came online" if is_online else "went offline"
rec.record(
category=ActivityCategory.DEVICE,
action=action,
severity=severity,
actor="system",
entity_type="device",
entity_id=device_id,
entity_name=safe_name,
message=f"Device '{display}' {status_word}",
metadata={"latency_ms": state.health.latency_ms},
)
# Auto-sync LED count
reported = state.health.device_led_count
+41
View File
@@ -60,6 +60,9 @@ from ledgrab.storage.audio_processing_template_store import AudioProcessingTempl
from ledgrab.storage.pattern_template_store import PatternTemplateStore
import ledgrab.core.audio.filters # noqa: F401 — trigger audio filter auto-registration
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.core.activity_log.recorder import ActivityRecorder, set_module_recorder
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.storage.activity_log_repository import ActivityLogRepository
from ledgrab.core.processing.os_notification_listener import OsNotificationListener
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher
from ledgrab.core.update.update_service import UpdateService
@@ -184,6 +187,10 @@ pattern_template_store = PatternTemplateStore(db)
game_event_bus = GameEventBus()
register_community_adapters()
# Activity log repository — constructed at module level like other stores so
# it migrates the DB schema (``002_add_activity_log``) on import.
activity_log_repo = ActivityLogRepository(db)
processor_manager = ProcessorManager(
ProcessorDependencies(
picture_source_store=picture_source_store,
@@ -290,6 +297,17 @@ async def lifespan(app: FastAPI):
processor_manager=processor_manager,
)
# Create activity recorder + retention engine. The recorder needs the
# processor_manager to fire live events, so it is built after that is
# already constructed at module level.
activity_recorder = ActivityRecorder(activity_log_repo, processor_manager)
activity_recorder.ensure_loop()
activity_log_retention_engine = ActivityLogRetentionEngine(
repo=activity_log_repo,
db=db,
recorder=activity_recorder,
)
# Create auto-backup engine — derive paths from database location so that
# demo mode auto-backups go to data/demo/ instead of data/.
_data_dir = Path(config.storage.database_file).parent
@@ -347,7 +365,13 @@ async def lifespan(app: FastAPI):
http_endpoint_store=http_endpoint_store,
audio_processing_template_store=audio_processing_template_store,
pattern_template_store=pattern_template_store,
activity_recorder=activity_recorder,
activity_log_repo=activity_log_repo,
activity_log_retention_engine=activity_log_retention_engine,
)
# Expose the recorder via the module singleton so non-DI sites
# (fire_entity_event, device threads) can call record() without FastAPI DI.
set_module_recorder(activity_recorder)
# Register devices in processor manager for health monitoring
devices = device_store.get_all_devices()
@@ -390,6 +414,9 @@ async def lifespan(app: FastAPI):
# Start auto-backup engine (periodic configuration backups)
await auto_backup_engine.start()
# Start activity log retention engine (hourly prune of old entries)
await activity_log_retention_engine.start()
# Start update checker (periodic release polling)
await update_service.start()
@@ -438,6 +465,19 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error("Shutdown step '%s' raised: %s", label, e)
# Record the shutdown event FIRST — before any engine teardown — so there
# is always a final log entry on graceful shutdown.
try:
activity_recorder.record(
category="system",
action="server.shutting_down",
severity="info",
actor="system",
message="Server is shutting down",
)
except Exception as e:
logger.error("Failed to record shutdown event: %s", e)
# Legacy hook — SQLite stores are write-through so this only logs.
# Durability comes from PRAGMA synchronous=FULL + the explicit
# wal_checkpoint(TRUNCATE) in Database.close() at the end of this block.
@@ -510,6 +550,7 @@ async def lifespan(app: FastAPI):
await _bounded("update_service.stop", update_service.stop(), timeout=0.5)
await _bounded("auto_backup_engine.stop", auto_backup_engine.stop(), timeout=0.5)
await _bounded("activity_log_retention.stop", activity_log_retention_engine.stop(), timeout=0.5)
# Close the DB last so it runs a TRUNCATE checkpoint, flushing the WAL
# into the main file. Without this, writes can survive a graceful app
@@ -0,0 +1,770 @@
/* ─────────────────────────────────────────────────────────────────────────
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;
}
/* ── Filter toolbar ──────────────────────────────────────────────────────── */
.al-toolbar {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md) var(--space-md);
/* Match the elevated card surface used by entity cards (.dashboard-target),
not the near-black --bg-secondary, so the panel reads as one of the app's
cards rather than a separate flat sheet. */
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline) solid var(--lux-line, 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; }
/* Caret signals this button opens a menu (rather than firing a direct action),
and rotates to point up while the menu is open. */
.al-export-caret {
display: inline-flex;
margin-left: 1px;
transition: transform var(--duration-fast) var(--ease-out);
}
.al-export-caret .icon { width: 12px; height: 12px; }
.al-export-wrap.open .al-export-caret { transform: rotate(180deg); }
.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.open .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,
.al-export-menu button:focus-visible { background: var(--bg-secondary); outline: none; }
/* 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 {
/* Same elevated surface + hairline as entity cards (see .al-toolbar). */
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline) solid var(--lux-line, 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 — settles on the card surface (animation-fill-mode: forwards
holds the 100% frame, so it must match the .al-entry background exactly). */
@keyframes al-new-flash {
0% { background: color-mix(in srgb, var(--primary-color) 18%, var(--lux-bg-1, var(--card-bg))); }
100% { background: var(--lux-bg-1, var(--card-bg)); }
}
.al-entry-new.al-entry-appear { animation: al-new-flash 1.8s var(--ease-out) forwards; }
.al-entry-row {
display: grid;
/* icon | time | badge | actor | message | entity | chevron
badge is fixed so all category names (AUTH…CAPTURE) occupy identical
width; actor is capped so long usernames don't push the message over;
message takes all remaining space. */
grid-template-columns: 24px 80px 78px minmax(0, 110px) 1fr 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); }
/* Darker purple text in light theme — the dark-theme #ab47bc fails AA contrast
on the pale tinted background at this small badge size. */
[data-theme="light"] .al-cat-device { background: rgba(156, 39, 176, 0.08); color: #8e24aa; }
[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 — constrained by its grid column (minmax(0, 110px)) */
.al-actor {
font-size: 0.8125rem;
font-family: var(--font-mono);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* Message — min-width:0 lets the 1fr column actually truncate */
.al-msg {
font-size: 0.8125rem;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
text-align: left;
}
/* 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;
}
/* Subtle busy state while a slow re-query is in flight: the current rows stay
visible (no spinner flash) but dim slightly and stop accepting clicks until
the fresh results swap in. Only applied after a short delay, so instant
filtering shows nothing. */
.al-list-container.al-busy {
opacity: 0.55;
pointer-events: none;
transition: opacity var(--duration-fast) var(--ease-out);
}
/* ── Tabular-nums utility ────────────────────────────────────────────────── */
.tabular-nums { font-variant-numeric: tabular-nums; }
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.al-entry-row {
/* icon | time | badge (fixed) | message | chevron — actor+entity hidden */
grid-template-columns: 20px 70px 78px 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);
}
/* Row 1: [sev] [message] [badge] [chevron]; Row 2: [time] under the message.
message stays in its own 1fr column so it never overlaps the badge. */
.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; grid-row: 1; }
.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;
}
+1
View File
@@ -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';
+40 -1
View File
@@ -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,
@@ -257,6 +275,7 @@ import {
loadDaylightTimezone, saveDaylightTimezone,
requestNotifPermissionFromSettings, testNotifFromSettings,
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
loadActivityLogSettings, saveActivityLogSettings, activityLogSettingsExport, clearActivityLog,
} from './features/settings.ts';
import {
loadUpdateStatus, initUpdateListener, checkForUpdates,
@@ -741,6 +760,10 @@ Object.assign(window, {
saveExternalUrl,
revertExternalUrl,
getBaseOrigin,
loadActivityLogSettings,
saveActivityLogSettings,
activityLogSettingsExport,
clearActivityLog,
// update
checkForUpdates,
@@ -762,6 +785,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 +818,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();
@@ -32,6 +32,7 @@ import { openAuthedWs } from './ws-auth.ts';
* update_download_progress — update_service.py (consumed by features/update.ts)
* device_discovered — discovery_watcher.py (consumed by features/notifications-watcher.ts)
* device_lost — discovery_watcher.py (consumed by features/notifications-watcher.ts)
* activity_logged — core/activity_log/recorder.py (consumed by features/activity-log.ts)
*
* Missing any of these silently breaks the corresponding UI flow — keep
* this list in sync when adding new event types on the server side.
@@ -47,6 +48,7 @@ const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet<string> = new Set([
'update_download_progress',
'device_discovered',
'device_lost',
'activity_logged', // source: core/activity_log/recorder.py
]);
interface ServerEventEnvelope {
@@ -135,6 +135,17 @@ export const armchair = '<path d="M19 9V6a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v3"/>
// Lucide: leaf
export const leaf = '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19.2 2.96c1 4.34.06 9.65-3.4 13.04A6.96 6.96 0 0 1 11 20z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>';
// Lucide: scroll-text (audit / activity log)
export const scrollText = '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>';
// Lucide: circle-alert (error severity)
export const circleAlert = '<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>';
// Lucide: info (info severity)
export const info = '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>';
// Lucide: filter (filter toolbar)
export const filter = '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>';
// Lucide: x-circle (clear/reset)
export const xCircle = '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>';
// 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.
@@ -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);
@@ -28,6 +28,7 @@ const TAB_REGISTRY: Readonly<Record<string, TabConfig>> = {
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. */
+84
View File
@@ -555,6 +555,90 @@ 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 });
}
// ── Shared relative-time ticker ──────────────────────────────────────────────
// A single process-wide interval that keeps every `[data-reltime]` element
// up to date. Call `ensureRelativeTimeTicker()` from any feature that renders
// such elements — repeated calls are idempotent (one interval, ever).
let _relTimeIntervalId: ReturnType<typeof setInterval> | null = null;
let _relTimeVisibilityBound = false;
/** Refresh every `[data-reltime]` element's text content to the current
* relative-time label produced by `formatRelativeTime`. */
function _tickRelativeTimes(): void {
if (document.hidden) return;
document.querySelectorAll<HTMLElement>('[data-reltime]').forEach(el => {
const iso = el.getAttribute('data-reltime');
if (iso) el.textContent = formatRelativeTime(iso);
});
}
/**
* Start the shared relative-time ticker (idempotent — safe to call many times).
* Ticks every 30 s, skips work when the tab is hidden, and fires one
* immediate refresh when the tab becomes visible again.
* Also fires one immediate refresh on each `languageChanged` event so
* freshly-translated labels appear without waiting for the next tick.
*/
export function ensureRelativeTimeTicker(): void {
// One-time visibility + language listeners
if (!_relTimeVisibilityBound) {
_relTimeVisibilityBound = true;
document.addEventListener('visibilitychange', () => {
if (!document.hidden) _tickRelativeTimes();
});
document.addEventListener('languageChanged', () => _tickRelativeTimes());
}
// Idempotent: only start the interval once
if (_relTimeIntervalId !== null) return;
_relTimeIntervalId = setInterval(_tickRelativeTimes, 30_000);
}
export function formatUptime(seconds: number | null | undefined): string {
if (!seconds || seconds <= 0) return '-';
const total = Math.floor(seconds);
@@ -0,0 +1,874 @@
/**
* 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, ensureRelativeTimeTicker } 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, '&quot;')
.replace(/'/g, '&#39;');
}
// ─── Types ───────────────────────────────────────────────────
export 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<string, unknown>;
}
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<string>();
let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
let _liveEventListener: ((e: Event) => void) | null = null;
// Loading UX: `_showSpinner` gates the full-panel spinner so it only appears
// after a short delay (slow requests), never flashing on instant filtering.
// `_hasLoadedOnce` distinguishes the genuine first load (spinner immediately)
// from re-queries (keep current rows, subtle delayed busy hint).
let _loadingDelayTimer: ReturnType<typeof setTimeout> | null = null;
let _showSpinner = false;
let _hasLoadedOnce = false;
const _filters: ActiveFilters = {
categories: [],
severities: [],
actor: '',
entity_type: '',
since: '',
until: '',
q: '',
};
// ─── Category → navigation target map (entity crosslinks) ──
const _ENTITY_NAV: Record<string, { tab: string; subTab: string | null; attr: string } | null> = {
output_target: { tab: 'targets', subTab: 'led-targets', attr: 'data-target-id' },
device: { tab: 'targets', subTab: 'led-devices', attr: 'data-device-id' },
picture_source: { tab: 'streams', subTab: 'raw', attr: 'data-stream-id' },
color_strip_source: { tab: 'streams', subTab: 'color_strip', attr: 'data-css-id' },
audio_source: { tab: 'streams', subTab: 'audio_capture', attr: 'data-id' },
automation: { tab: 'automations', subTab: 'automations', attr: 'data-automation-id' },
scene_preset: { tab: 'automations', subTab: 'scenes', attr: 'data-scene-id' },
scene_playlist: { tab: 'automations', subTab: 'playlists', attr: 'data-playlist-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}`);
}
// ─── Localized entity-type label ────────────────────────────
function _entityTypeLabel(entityType: string): string {
const key = `activity_log.entity_type.${entityType}`;
const translated = t(key);
// If t() returned the key unchanged there is no translation — humanize it
if (translated === key) {
return entityType.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
return translated;
}
// ─── Client-side message localization ───────────────────────
//
// Maps entry.action → an i18n template key and extracts placeholders
// from the structured fields so the displayed description is rendered
// in the user's locale rather than the server-generated English string.
//
// Fallback: if the template key is missing (t() returns the key
// unchanged) we return entry.message (the original server string) so
// the UI always shows something sensible.
export function localizeMessage(entry: ActivityEntry): string {
const meta = entry.metadata || {};
// Build a params bag from structured fields + metadata.
// Keys match the {placeholder} names used in locale templates.
const params: Record<string, string> = {
name: entry.entity_name ?? '',
actor: entry.actor ?? '',
type: entry.entity_type ? _entityTypeLabel(entry.entity_type) : '',
key: String(meta.setting_key ?? ''),
address: String(meta.address ?? meta.url ?? ''),
reason: String(meta.reason ?? ''),
client: String(meta.client ?? ''),
device_type: String(meta.device_type ?? ''),
filename: String(meta.filename ?? ''),
};
// The backend always emits dotted actions (e.g. "entity.created",
// "auth.ws_connected"), so the template key is a direct 1:1 mapping.
const templateKey = `activity_log.msg.${entry.action}`;
const localized = t(templateKey, params);
// t() returns the key unchanged when there is no matching translation.
if (localized === templateKey) {
return entry.message;
}
return localized;
}
// ─── 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 = `<button class="al-entity-link" type="button"
data-entity-type="${attrEntityType}" data-entity-id="${attrEntityId}"
title="${_escapeAttr(entry.entity_name)}">${escapedName}</button>`;
} else {
entityHtml = `<span class="al-entity-name">${escapeHtml(entry.entity_name)}</span>`;
}
}
const detailHtml = expanded ? _renderEntryDetail(entry) : '';
const attrEntryId = _escapeAttr(entry.id);
return `<div class="al-entry${isNew ? ' al-entry-new' : ''}" data-al-id="${_escapeAttr(entry.id)}">
<div class="al-entry-row" data-toggle-id="${attrEntryId}"
role="button" tabindex="0" aria-expanded="${expanded}">
<span class="al-sev ${sevClass}" title="${_escapeAttr(t(`activity_log.severity.${entry.severity}`))}">${sevIcon}</span>
<span class="al-time tabular-nums" title="${_escapeAttr(iso)}" data-reltime="${_escapeAttr(iso)}">${escapeHtml(relTime)}</span>
<span class="al-cat-badge al-cat-${escapeHtml(entry.category)}">${escapeHtml(_categoryLabel(entry.category))}</span>
<span class="al-actor">${escapeHtml(entry.actor)}</span>
<span class="al-msg">${escapeHtml(localizeMessage(entry))}</span>
${entityHtml ? `<span class="al-entity">${entityHtml}</span>` : ''}
<span class="al-expand-chevron" aria-hidden="true">${expanded ? ICON_CHEVRON_UP : ICON_CHEVRON_DOWN}</span>
</div>
${detailHtml}
</div>`;
}
function _renderEntryDetail(entry: ActivityEntry): string {
const metaJson = JSON.stringify(entry.metadata, null, 2);
const absTime = entry.ts ? new Date(entry.ts).toLocaleString() : '';
return `<div class="al-detail" role="region" aria-label="${escapeHtml(t('activity_log.detail.title'))}">
<dl class="al-detail-grid">
<dt>${escapeHtml(t('activity_log.detail.id'))}</dt>
<dd class="tabular-nums"><code>${escapeHtml(entry.id)}</code></dd>
<dt>${escapeHtml(t('activity_log.detail.timestamp'))}</dt>
<dd class="tabular-nums">${escapeHtml(absTime)}</dd>
<dt>${escapeHtml(t('activity_log.detail.action'))}</dt>
<dd><code>${escapeHtml(entry.action)}</code></dd>
<dt>${escapeHtml(t('activity_log.detail.actor'))}</dt>
<dd>${escapeHtml(entry.actor)}</dd>
${entry.entity_type ? `<dt>${escapeHtml(t('activity_log.detail.entity'))}</dt>
<dd>${escapeHtml(entry.entity_type)}${entry.entity_id ? ` / <code>${escapeHtml(entry.entity_id)}</code>` : ''}${entry.entity_name ? ` (${escapeHtml(entry.entity_name)})` : ''}</dd>` : ''}
<dt>${escapeHtml(t('activity_log.detail.metadata'))}</dt>
<dd><pre class="al-meta-pre">${escapeHtml(metaJson)}</pre></dd>
</dl>
</div>`;
}
// ─── 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 `<button class="al-chip al-cat-chip${active ? ' active' : ''} al-cat-${cat}"
type="button" onclick="activityLogToggleCat('${cat}')"
aria-pressed="${active}">${escapeHtml(_categoryLabel(cat))}</button>`;
}).join('');
const sevChips = SEVERITIES.map(sev => {
const active = _filters.severities.includes(sev);
const icon = _severityIcon(sev);
return `<button class="al-chip al-sev-chip${active ? ' active' : ''} al-sev-chip-${sev}"
type="button" onclick="activityLogToggleSev('${sev}')"
aria-pressed="${active}">${icon} ${escapeHtml(t(`activity_log.severity.${sev}`))}</button>`;
}).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 =>
`<button class="al-preset-btn" type="button" onclick="activityLogPreset('${p.key}')">${escapeHtml(p.label)}</button>`
).join('');
const hasFilters = _filters.categories.length || _filters.severities.length ||
_filters.actor || _filters.entity_type || _filters.since || _filters.until || _filters.q;
return `<div class="al-toolbar" role="search" aria-label="${escapeHtml(t('activity_log.filter.title'))}">
<div class="al-toolbar-row al-toolbar-search">
<div class="al-search-wrap">
<span class="al-search-icon" aria-hidden="true">${ICON_SEARCH}</span>
<input class="al-search-input" type="search" id="al-search-input"
placeholder="${escapeHtml(t('activity_log.filter.search'))}"
value="${_escapeAttr(_filters.q)}"
oninput="activityLogOnSearch(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.search'))}">
</div>
<div class="al-presets">${presetBtns}</div>
${hasFilters ? `<button class="al-clear-btn btn btn-icon btn-secondary" type="button"
onclick="activityLogClearFilters()" title="${escapeHtml(t('activity_log.filter.clear'))}"
aria-label="${escapeHtml(t('activity_log.filter.clear'))}">${ICON_X_CIRCLE}</button>` : ''}
<div class="al-export-wrap">
<button class="btn btn-secondary al-export-btn" type="button"
data-al-export-toggle aria-haspopup="menu" aria-expanded="false"
title="${escapeHtml(t('activity_log.export'))}"
aria-label="${escapeHtml(t('activity_log.export'))}">${ICON_DOWNLOAD} <span>${escapeHtml(t('activity_log.export'))}</span><span class="al-export-caret" aria-hidden="true">${ICON_CHEVRON_DOWN}</span></button>
<div class="al-export-menu" role="menu">
<button type="button" role="menuitem" onclick="activityLogExport('csv')">${escapeHtml(t('activity_log.export.csv'))}</button>
<button type="button" role="menuitem" onclick="activityLogExport('json')">${escapeHtml(t('activity_log.export.json'))}</button>
</div>
</div>
</div>
<div class="al-toolbar-row al-toolbar-chips">
<span class="al-filter-label">${escapeHtml(t('activity_log.filter.category'))}</span>
<div class="al-chip-group" role="group" aria-label="${escapeHtml(t('activity_log.filter.category'))}">${catChips}</div>
<span class="al-filter-label al-filter-label-sep">${escapeHtml(t('activity_log.filter.severity'))}</span>
<div class="al-chip-group" role="group" aria-label="${escapeHtml(t('activity_log.filter.severity'))}">${sevChips}</div>
</div>
<div class="al-toolbar-row al-toolbar-advanced" id="al-toolbar-advanced">
<div class="al-field-group">
<label for="al-actor-input" class="al-field-label">${escapeHtml(t('activity_log.filter.actor'))}</label>
<input type="text" id="al-actor-input" class="al-field-input"
value="${_escapeAttr(_filters.actor)}"
placeholder="${_escapeAttr(t('activity_log.filter.actor.placeholder'))}"
oninput="activityLogOnActor(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.actor'))}">
</div>
<div class="al-field-group">
<label for="al-entity-type-input" class="al-field-label">${escapeHtml(t('activity_log.filter.entity_type'))}</label>
<input type="text" id="al-entity-type-input" class="al-field-input"
value="${_escapeAttr(_filters.entity_type)}"
placeholder="${_escapeAttr(t('activity_log.filter.entity_type.placeholder'))}"
oninput="activityLogOnEntityType(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.entity_type'))}">
</div>
<div class="al-field-group">
<label for="al-since-input" class="al-field-label">${escapeHtml(t('activity_log.filter.since'))}</label>
<input type="datetime-local" id="al-since-input" class="al-field-input"
value="${_escapeAttr(_filters.since)}"
onchange="activityLogOnSince(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.since'))}">
</div>
<div class="al-field-group">
<label for="al-until-input" class="al-field-label">${escapeHtml(t('activity_log.filter.until'))}</label>
<input type="datetime-local" id="al-until-input" class="al-field-input"
value="${_escapeAttr(_filters.until)}"
onchange="activityLogOnUntil(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.until'))}">
</div>
</div>
</div>`;
}
// ─── List and state rendering ────────────────────────────────
function _renderList(): string {
if (_showSpinner && _entries.length === 0) {
return `<div class="al-state al-loading" role="status" aria-live="polite">
<div class="al-spinner"></div>
<span>${escapeHtml(t('activity_log.loading'))}</span>
</div>`;
}
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 `<div class="al-state al-empty" role="status">
<span class="al-state-icon" aria-hidden="true">${ICON_ACTIVITY_LOG}</span>
<p>${escapeHtml(t(emptyKey))}</p>
</div>`;
}
const rows = _entries.map(e => _renderEntryRow(e)).join('');
const loadMore = _hasMore
? `<button class="al-load-more btn btn-secondary" type="button"
onclick="activityLogLoadMore()" aria-label="${escapeHtml(t('activity_log.load_more'))}">${escapeHtml(t('activity_log.load_more'))}</button>`
: '';
const countLabel = t('activity_log.n_entries', { n: _total });
return `<div class="al-list-header">
<span class="al-count tabular-nums">${escapeHtml(countLabel)}</span>
<div class="al-live-indicator" id="al-live-indicator" aria-live="polite">
<span class="al-live-dot" aria-hidden="true"></span>
<span>${escapeHtml(t('activity_log.live'))}</span>
</div>
</div>
<div class="al-list" role="log" aria-label="${escapeHtml(t('activity_log.title'))}" aria-live="polite">
${rows}
</div>
${loadMore}`;
}
// ─── Delegated click handler for entry rows and entity links ─
let _delegatedClickAttached = false;
/** Collapse the export dropdown if open (idempotent). */
function _closeExportMenu(): void {
const wrap = document.getElementById('tab-activity_log')
?.querySelector<HTMLElement>('.al-export-wrap.open');
if (!wrap) return;
wrap.classList.remove('open');
wrap.querySelector('[data-al-export-toggle]')?.setAttribute('aria-expanded', '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;
// Export menu: toggle button opens/closes the CSV/JSON dropdown.
const exportToggle = target.closest<HTMLElement>('[data-al-export-toggle]');
if (exportToggle) {
const wrap = exportToggle.closest<HTMLElement>('.al-export-wrap');
const open = wrap?.classList.toggle('open') ?? false;
exportToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
return;
}
// Export menu: a menu item's inline onclick triggers the download (it
// runs first, on the deeper element) — we just collapse the menu after.
if (target.closest('.al-export-menu')) {
_closeExportMenu();
return;
}
// Any other click in the panel dismisses an open export menu, then
// continues to row / entity handling below.
_closeExportMenu();
// Entity navigation: click on data-entity-type button
const entityBtn = target.closest<HTMLElement>('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<HTMLElement>('.al-entry-row[data-toggle-id]');
if (row) {
const entryId = row.dataset.toggleId ?? '';
if (entryId) activityLogToggleDetail(entryId);
return;
}
});
panel.addEventListener('keydown', (e: KeyboardEvent) => {
// Escape closes the export menu and restores focus to its trigger.
if (e.key === 'Escape') {
const toggle = panel.querySelector<HTMLElement>('.al-export-wrap.open [data-al-export-toggle]');
if (toggle) {
_closeExportMenu();
toggle.focus();
}
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
const row = (e.target as HTMLElement).closest<HTMLElement>('.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 = `<div class="al-panel">
${_renderFilterToolbar()}
<div id="al-list-container" class="al-list-container">
${_renderList()}
</div>
</div>`;
_attachDelegatedClicks();
}
// ─── Partial re-render helpers ───────────────────────────────
function _updateListContainer(): void {
const container = document.getElementById('al-list-container');
if (!container) return;
container.innerHTML = _renderList();
}
// ─── Data fetching ───────────────────────────────────────────
/** Surface a loading affordance only when a request is slow enough to notice. */
function _showDelayedBusy(): void {
if (!_loading) return;
if (_entries.length === 0) {
// Nothing to keep on screen — fall back to the full spinner.
_showSpinner = true;
_updateListContainer();
} else {
// Re-query of a populated list: keep the current rows, just dim them.
const c = document.getElementById('al-list-container');
c?.classList.add('al-busy');
c?.setAttribute('aria-busy', 'true');
}
}
/** Clear all loading affordances (timer, spinner flag, busy dim). Idempotent. */
function _clearBusy(): void {
if (_loadingDelayTimer) {
clearTimeout(_loadingDelayTimer);
_loadingDelayTimer = null;
}
_showSpinner = false;
const c = document.getElementById('al-list-container');
c?.classList.remove('al-busy');
c?.removeAttribute('aria-busy');
}
async function _fetchPage(beforeSeq: number | null = null, append = false): Promise<void> {
if (_loading) return;
_loading = true;
if (!append) {
// Reset the cursor for a fresh query, but DON'T clear `_entries` — keep
// the current rows on screen so filtering an already-populated list
// never flashes the full "Loading" state (the new results replace them
// on arrival).
_nextBeforeSeq = null;
_hasMore = false;
}
if (!_hasLoadedOnce && !append) {
// Genuine first load — there's nothing to show yet, so the spinner is
// the correct (and expected) initial state. Show it immediately.
_showSpinner = true;
_updateListContainer();
} else if (!append) {
// Re-query (filter change / language change): defer any loading hint so
// near-instant responses show nothing at all; a slow request gets a
// subtle dim after the delay.
if (_loadingDelayTimer) clearTimeout(_loadingDelayTimer);
_loadingDelayTimer = setTimeout(_showDelayedBusy, 180);
}
// append (load-more): keep existing rows, no loading indicator.
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;
_hasLoadedOnce = true;
// Clear loading affordances BEFORE rendering so a zero-result page
// renders the empty state (not the spinner) and a re-query swaps in the
// fresh, undimmed rows.
_clearBusy();
_loading = false;
_updateListContainer();
} catch (e: unknown) {
if (e && typeof e === 'object' && 'isAuth' in e) return;
_clearBusy();
const container = document.getElementById('al-list-container');
if (container) {
container.innerHTML = `<div class="al-state al-error" role="alert">
<span class="al-state-icon" aria-hidden="true">${ICON_SEVERITY_ERR}</span>
<p>${escapeHtml(t('activity_log.error'))}</p>
</div>`;
}
} finally {
_loading = false;
_clearBusy();
}
}
// ─── 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<void> {
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);
}
// ─── 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" data-reltime="${_escapeAttr(entry.ts)}">${escapeHtml(relTime)}</span>
<span class="al-compact-msg">${escapeHtml(localizeMessage(entry))}</span>
</div>`;
}
// ─── Main loader (registered with tab-registry) ─────────────
export async function loadActivityLog(): Promise<void> {
const panel = document.getElementById('tab-activity_log');
if (!panel) return;
_initialized = true;
_render();
await _fetchPage(null, false);
_startLiveUpdates();
ensureRelativeTimeTicker();
// Re-render on language change (baked-in t() calls)
document.addEventListener('languageChanged', _onLanguageChanged);
}
function _onLanguageChanged(): void {
if (!_initialized) return;
_render();
_fetchPage(null, false);
}
@@ -63,6 +63,7 @@ const REORDERABLE_SECTIONS: readonly string[] = [
'playlists',
'sync-clocks',
'targets',
'recent-activity',
] as const;
const SECTION_LABEL_KEYS: Record<string, string> = {
@@ -73,6 +74,7 @@ const SECTION_LABEL_KEYS: Record<string, string> = {
playlists: 'dashboard.section.playlists',
'sync-clocks': 'dashboard.section.sync_clocks',
targets: 'dashboard.section.targets',
'recent-activity': 'dashboard.section.recent_activity',
};
const PERF_CELL_LABEL_KEYS: Record<string, string> = {
@@ -25,6 +25,7 @@ export type SectionKey =
| 'playlists'
| 'sync-clocks'
| 'targets'
| 'recent-activity'
// Reserved registry keys for v1.1+ (so saved layouts forward-compat).
| 'audio-meters'
| 'alerts'
@@ -155,6 +156,7 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = {
_defaultSection('playlists'),
_defaultSection('sync-clocks'),
_defaultSection('targets'),
_defaultSection('recent-activity'),
],
perfCells: [
_defaultPerfCell('patches'),
@@ -5,7 +5,7 @@
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing, ensureRelativeTimeTicker } from '../core/ui.ts';
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateTotalCaptureFps, updateTotalCaptureFpsActual, updateTotalErrors, updateDevices, updateNetworkThroughput, updateDeviceLatency, updateSendTiming, rerenderPerfGrid } from './perf-charts.ts';
import { startAutoRefresh, updateTabBadge } from './tabs.ts';
import { isActiveTab } from '../core/tab-registry.ts';
@@ -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,84 @@ 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.
* Skips the network fetch when the widget already contains live entries
* (dal-list class is set by _renderRecentActivityList on first mount) so
* unrelated dashboard re-renders never re-fetch or flash the widget. */
async function _loadRecentActivityWidget(): Promise<void> {
const list = document.getElementById('dashboard-recent-activity-list');
if (!list) return;
// Widget already populated — only wire up live listener and ticker;
// don't re-fetch or overwrite the live content.
if (list.classList.contains('dal-list')) {
ensureRelativeTimeTicker();
_startRecentActivityLive();
return;
}
const entries = await fetchRecentEntries(RECENT_ACTIVITY_LIMIT);
_renderRecentActivityList(list, entries);
// Start relative-time ticker (idempotent — shared with the Activity tab)
ensureRelativeTimeTicker();
// 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 _stopRecentActivityLive(): void {
if (_recentActivityLiveListener) {
document.removeEventListener('server:activity_logged', _recentActivityLiveListener);
_recentActivityLiveListener = null;
}
}
function _startRecentActivityLive(): void {
// Always tear down first so we never stack listeners across loadDashboard calls.
_stopRecentActivityLive();
_recentActivityLiveListener = (e: Event) => {
const ce = e as CustomEvent;
const entry = ce.detail?.entry;
if (!entry) return;
const list = document.getElementById('dashboard-recent-activity-list');
// No-op when the widget isn't mounted (section hidden or not yet rendered).
if (!list) return;
if (!list.classList.contains('dal-list')) {
// Transition from empty-state to list on the first live event
_renderRecentActivityList(list, [entry]);
return;
}
// Surgically prepend the new row and cap at N — no loadDashboard().
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`). */
@@ -679,6 +759,127 @@ function _sectionContent(sectionKey: string, itemsHtml: string): string {
return `<div class="dashboard-section-content"${isCollapsed ? ' style="display:none"' : ''}>${itemsHtml}</div>`;
}
/**
* Reconcile the `.dashboard-dynamic` container against newly-built HTML
* without a wholesale innerHTML replacement.
*
* Algorithm:
* 1. Parse `newHtml` into a detached container.
* 2. Build a map of existing live sections keyed by data-section.
* 3. For each section in the new HTML (in order):
* a. If the live DOM has that section AND it is content-stable
* (recent-activity with live list) OR its outerHTML hasn't
* changed — keep the live element.
* b. Otherwise replace / insert with the new element.
* 4. Remove sections that no longer appear in the new HTML.
* 5. Re-order to match the new order (move nodes, no recreation).
*
* The `recent-activity` section is treated as content-stable once
* its list has been populated (dal-list class), mirroring the perf-
* persistent pattern. The freshly-built loading placeholder in
* `newHtml` is never compared against the live-entry list — instead
* the live DOM node is always kept when it has real content.
*/
function _reconcileDynamicSections(dynamic: HTMLElement, newHtml: string): void {
// Parse incoming HTML into a scratch container.
const scratch = document.createElement('div');
scratch.innerHTML = newHtml;
// Gather incoming sections in order.
const incoming = Array.from(
scratch.querySelectorAll<HTMLElement>(':scope > .dashboard-section[data-section]')
);
// If the new HTML contains non-section top-level nodes (e.g. the
// `.dashboard-no-targets` placeholder shown when there are no entities),
// fall back to a simple innerHTML swap — this path is rare and the
// no-entities state doesn't have live widgets worth preserving.
const totalTopLevel = scratch.children.length;
if (totalTopLevel !== incoming.length) {
if (dynamic.innerHTML !== newHtml) dynamic.innerHTML = newHtml;
return;
}
// Drop any stray non-section top-level nodes left over from a previous
// state (e.g. the `.dashboard-no-targets` placeholder shown when there
// were zero entities). The reconcile pass below only manages
// `.dashboard-section` children, so without this sweep that orphan node
// would linger over the freshly-populated dashboard.
for (const child of Array.from(dynamic.children)) {
if (!child.matches('.dashboard-section[data-section]')) child.remove();
}
// Index live sections by key.
const liveMap = new Map<string, HTMLElement>();
for (const el of Array.from(
dynamic.querySelectorAll<HTMLElement>(':scope > .dashboard-section[data-section]')
)) {
liveMap.set(el.dataset.section as string, el);
}
// Collect the keys that should remain (in new order).
const newKeys = new Set(incoming.map(el => el.dataset.section as string));
// Remove sections that are no longer present.
for (const [key, el] of liveMap) {
if (!newKeys.has(key)) el.remove();
}
// Walk incoming sections in order and reconcile each one.
let insertBefore: HTMLElement | null = null; // node to insert before (null = append)
for (let i = incoming.length - 1; i >= 0; i--) {
const newEl = incoming[i];
const key = newEl.dataset.section as string;
const live = liveMap.get(key);
let keep: HTMLElement;
if (live) {
// Content-stable guard: the recent-activity section must not be
// replaced once it holds live entries — the new HTML only has the
// loading placeholder and would wipe the list.
const isRecentActivity = key === 'recent-activity';
const raList = isRecentActivity
? live.querySelector('#dashboard-recent-activity-list')
: null;
const raIsPopulated = raList !== null && raList.classList.contains('dal-list');
if (raIsPopulated) {
// Always keep the live recent-activity section as-is.
keep = live;
} else if (live.outerHTML === newEl.outerHTML) {
// Unchanged section — keep live DOM, no mutation.
keep = live;
} else {
// Section content changed — replace.
live.replaceWith(newEl);
liveMap.set(key, newEl);
keep = newEl;
}
} else {
// New section — insert it.
dynamic.appendChild(newEl); // temporary placement; ordering pass below
liveMap.set(key, newEl);
keep = newEl;
}
// Re-order: after the ordering loop (reverse walk) each `keep`
// should end up just before the node we placed in the previous
// iteration (i+1). Using insertBefore to build correct order.
if (insertBefore === null) {
// Last in order — move to end of dynamic.
if (keep.nextElementSibling !== null || keep.parentElement !== dynamic) {
dynamic.appendChild(keep);
}
} else {
if (keep.nextElementSibling !== insertBefore || keep.parentElement !== dynamic) {
dynamic.insertBefore(keep, insertBefore);
}
}
insertBefore = keep;
}
}
export async function loadDashboard(forceFullRender: boolean = false): Promise<void> {
if (_dashboardLoading) return;
set_dashboardLoading(true);
@@ -763,7 +964,21 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
let dynamicHtml = '';
let runningIds: any[] = [];
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && playlists.length === 0 && syncClocks.length === 0 && haStatus.total_sources === 0 && mqttStatus.total_sources === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
const _raSection = isSectionVisible('recent-activity') ? `<div class="dashboard-section" data-section="recent-activity">
${_sectionHeader('recent-activity', t('dashboard.section.recent_activity'), '')}
${_sectionContent('recent-activity', `<div id="dashboard-recent-activity-list" class="dal-loading" aria-live="polite" aria-label="${escapeHtml(t('dashboard.section.recent_activity'))}">
<div class="al-state al-loading">
<div class="al-spinner"></div>
<span>${escapeHtml(t('activity_log.loading'))}</span>
</div>
</div>
<div class="dal-footer">
<button class="btn btn-ghost btn-sm dal-view-all" onclick="switchTab('activity_log')" aria-label="${escapeHtml(t('dashboard.recent_activity.view_all'))}">
${escapeHtml(t('dashboard.recent_activity.view_all'))} &rarr;
</button>
</div>`)}
</div>` : '';
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>${_raSection}`;
} else {
const enriched = targets.map(target => ({
...target,
@@ -1003,6 +1218,23 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
</div>`;
}
// Recent Activity section — registered like any other section so it
// participates in layout ordering, show/hide, and Customize panel.
sectionFragments['recent-activity'] = `<div class="dashboard-section" data-section="recent-activity">
${_sectionHeader('recent-activity', t('dashboard.section.recent_activity'), '')}
${_sectionContent('recent-activity', `<div id="dashboard-recent-activity-list" class="dal-loading" aria-live="polite" aria-label="${escapeHtml(t('dashboard.section.recent_activity'))}">
<div class="al-state al-loading">
<div class="al-spinner"></div>
<span>${escapeHtml(t('activity_log.loading'))}</span>
</div>
</div>
<div class="dal-footer">
<button class="btn btn-ghost btn-sm dal-view-all" onclick="switchTab('activity_log')" aria-label="${escapeHtml(t('dashboard.recent_activity.view_all'))}">
${escapeHtml(t('dashboard.recent_activity.view_all'))} &rarr;
</button>
</div>`)}
</div>`;
// Now assemble in layout-driven order, skipping invisible
// sections and the perf section (which is always rendered
// separately at the top for chart-persistence reasons).
@@ -1043,8 +1275,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
existingPerf.style.display = perfVisible ? '' : 'none';
}
const dynamic = container.querySelector('.dashboard-dynamic');
if (dynamic && dynamic.innerHTML !== dynamicHtml) {
dynamic.innerHTML = dynamicHtml;
if (dynamic) {
_reconcileDynamicSections(dynamic as HTMLElement, dynamicHtml);
}
_applyGlobalLayoutAttrs();
}
@@ -1062,6 +1294,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');
}
}
@@ -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' },
+20
View File
@@ -457,6 +457,26 @@ startTargetOverlay: (...args: any[]) => any;
applyBgEffect: (id: string) => void;
renderAppearanceTab: () => void;
// ─── 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;
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<void>;
activityLogNavigateToEntity: (entityType: string, entityId: string) => void;
// ─── Overlay spinner internals ───
overlaySpinnerTimer: ReturnType<typeof setInterval> | null;
_overlayEscHandler: ((e: KeyboardEvent) => void) | null;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
"""Activity / audit log data model.
Defines the ``ActivityLogEntry`` dataclass together with its category and
severity enumerations and the ``ActivityLogFilters`` query object. Row-level
codec (``to_row`` / ``from_row``) converts between the dataclass and a flat
SQLite column dict.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Sequence
# ---------------------------------------------------------------------------
# Enumerations (string constants, not enum.Enum, to stay consistent with the
# rest of the codebase which uses plain string literals)
# ---------------------------------------------------------------------------
class ActivityCategory:
"""Valid ``category`` values for an ``ActivityLogEntry``."""
AUTH = "auth"
DEVICE = "device"
ENTITY = "entity"
CAPTURE = "capture"
SYSTEM = "system"
ALL: tuple[str, ...] = (AUTH, DEVICE, ENTITY, CAPTURE, SYSTEM)
class ActivitySeverity:
"""Valid ``severity`` values for an ``ActivityLogEntry``."""
INFO = "info"
WARNING = "warning"
ERROR = "error"
ALL: tuple[str, ...] = (INFO, WARNING, ERROR)
# ---------------------------------------------------------------------------
# Entry dataclass
# ---------------------------------------------------------------------------
@dataclass
class ActivityLogEntry:
"""One immutable audit record.
Fields
------
id Stable application-assigned identifier, e.g. ``al_<uuid8>``.
ts UTC timestamp of the event (server-assigned at record time).
category Broad bucket — one of :class:`ActivityCategory`.
action Verb-object label, e.g. ``"entity.created"`` or ``"auth.rejected"``.
severity One of :class:`ActivitySeverity`.
actor Who triggered the action, e.g. an API-key label or ``"system"``.
entity_type Optional: kind of entity involved, e.g. ``"output_target"``.
entity_id Optional: stable entity identifier.
entity_name Optional: human-readable entity name at the time of the event.
message Human-readable description suitable for display.
metadata Small structured context (device address, error code, …).
Must be JSON-serialisable; defaults to empty dict.
"""
id: str
ts: datetime
category: str
action: str
severity: str
actor: str
message: str
entity_type: str | None = None
entity_id: str | None = None
entity_name: str | None = None
metadata: dict = field(default_factory=dict)
# -- Row codec -----------------------------------------------------------
def to_row(self) -> dict:
"""Return a flat dict suitable for a parameterised SQLite INSERT.
``ts`` is stored as an ISO-8601 string (UTC). ``metadata`` is stored
as a JSON string. ``seq`` is DB-assigned and is NOT included.
"""
return {
"id": self.id,
"ts": self.ts.isoformat(),
"category": self.category,
"action": self.action,
"severity": self.severity,
"actor": self.actor,
"entity_type": self.entity_type,
"entity_id": self.entity_id,
"entity_name": self.entity_name,
"message": self.message,
"metadata": json.dumps(self.metadata, ensure_ascii=False),
}
@staticmethod
def from_row(row: dict) -> "ActivityLogEntry":
"""Reconstruct an ``ActivityLogEntry`` from a SQLite row dict.
``row`` may include the ``seq`` column — it is ignored here (callers
that need ``seq`` for keyset pagination access it directly from the
row before calling this method).
"""
raw_meta = row.get("metadata") or "{}"
try:
metadata = json.loads(raw_meta)
except (json.JSONDecodeError, TypeError):
metadata = {}
raw_ts = row["ts"]
ts = datetime.fromisoformat(raw_ts)
# Ensure tz-aware UTC even if stored without offset (legacy rows)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
return ActivityLogEntry(
id=row["id"],
ts=ts,
category=row["category"],
action=row["action"],
severity=row["severity"],
actor=row["actor"],
entity_type=row.get("entity_type"),
entity_id=row.get("entity_id"),
entity_name=row.get("entity_name"),
message=row["message"],
metadata=metadata,
)
# ---------------------------------------------------------------------------
# Filters dataclass
# ---------------------------------------------------------------------------
@dataclass
class ActivityLogFilters:
"""Optional query-time filters for :class:`ActivityLogRepository`.
All fields are optional. ``None`` / empty sequence means "no restriction
on this dimension".
Fields
------
categories Restrict to entries whose ``category`` is in this set.
severities Restrict to entries whose ``severity`` is in this set.
actor Exact match on the ``actor`` field.
entity_type Exact match on the ``entity_type`` field.
entity_id Exact match on the ``entity_id`` field.
since Inclusive lower bound on ``ts`` (``ts >= since``).
until Inclusive upper bound on ``ts`` (``ts <= until``).
message_like Free-text substring match on ``message`` (LIKE ``%value%``).
The value is escaped — no SQL injection risk.
"""
categories: Sequence[str] | None = None
severities: Sequence[str] | None = None
actor: str | None = None
entity_type: str | None = None
entity_id: str | None = None
since: datetime | None = None
until: datetime | None = None
message_like: str | None = None
@@ -0,0 +1,338 @@
"""Append-only repository for the persistent activity / audit log.
Design notes
------------
* Does NOT subclass ``BaseSqliteStore`` — that base loads every row into an
in-memory cache at construction time, which is wrong for an unbounded,
append-heavy log.
* All SQL parameters are passed as positional ``?`` placeholders — no user
input is interpolated into the query string.
* Keyset pagination is implemented via the ``seq`` INTEGER PRIMARY KEY
AUTOINCREMENT column, which is strictly monotonic and survives rows with
identical ``ts`` values.
* The repository takes a ``Database`` instance and calls ``db.execute`` /
``db.transaction`` directly.
* Thread safety: ``Database.execute`` holds the ``RLock`` for the duration of
each call. The repository itself adds no extra locking. Callers that
originate from non-event-loop threads MUST marshal via
``loop.call_soon_threadsafe`` (that is Phase 2's responsibility).
"""
from __future__ import annotations
from datetime import datetime
from typing import Iterator
from ledgrab.storage.activity_log import ActivityLogEntry, ActivityLogFilters
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
logger = get_logger(__name__)
_TABLE = "activity_log"
def _build_filter_clause(
filters: ActivityLogFilters,
params: list,
*,
extra_where: str | None = None,
) -> str:
"""Return a WHERE clause string and append bound parameters to *params*.
The caller is responsible for providing the opening ``WHERE`` keyword when
the returned string is non-empty, or ``AND`` when appending to an existing
predicate. This function returns only the condition fragment(s) joined by
``AND``, or an empty string when *filters* has no restrictions.
``extra_where`` is prepended (with AND) before the filter conditions if
provided (used for the ``seq < ?`` keyset predicate).
"""
conditions: list[str] = []
if extra_where:
conditions.append(extra_where)
if filters.categories:
placeholders = ",".join("?" * len(filters.categories))
conditions.append(f"category IN ({placeholders})")
params.extend(filters.categories)
if filters.severities:
placeholders = ",".join("?" * len(filters.severities))
conditions.append(f"severity IN ({placeholders})")
params.extend(filters.severities)
if filters.actor is not None:
conditions.append("actor = ?")
params.append(filters.actor)
if filters.entity_type is not None:
conditions.append("entity_type = ?")
params.append(filters.entity_type)
if filters.entity_id is not None:
conditions.append("entity_id = ?")
params.append(filters.entity_id)
if filters.since is not None:
conditions.append("ts >= ?")
params.append(filters.since.isoformat())
if filters.until is not None:
conditions.append("ts <= ?")
params.append(filters.until.isoformat())
if filters.message_like is not None:
# Escape LIKE special characters in the user-supplied substring so that
# a literal '%' or '_' in the message does not act as a wildcard.
escaped = filters.message_like.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
conditions.append("message LIKE ? ESCAPE '\\'")
params.append(f"%{escaped}%")
return " AND ".join(conditions)
class ActivityLogRepository:
"""Purpose-built repository for the ``activity_log`` table.
Parameters
----------
db:
The shared ``Database`` singleton. The migration that creates the
``activity_log`` table must have been applied before the first call to
:meth:`record`. Construction triggers migration via
``MigrationRunner`` so users can create the repository directly
without a separate startup step.
"""
def __init__(self, db: Database) -> None:
self._db = db
# Apply pending migrations (idempotent; most runs are no-ops)
from ledgrab.storage.data_migrations import ALL_MIGRATIONS, MigrationRunner
MigrationRunner(db).run(ALL_MIGRATIONS)
# -- Write ---------------------------------------------------------------
def record(self, entry: ActivityLogEntry) -> None:
"""Append *entry* to the log.
The ``seq`` column is assigned by SQLite (AUTOINCREMENT) — ``entry``
does not carry a ``seq`` field.
Caller contract: this must be called from the event-loop thread (or
from a context already serialised through the ``Database`` RLock).
Cross-thread callers must marshal via ``loop.call_soon_threadsafe``;
that is Phase 2's responsibility.
"""
row = entry.to_row()
self._db.execute(
f"INSERT INTO {_TABLE} "
f"(id, ts, category, action, severity, actor, "
f" entity_type, entity_id, entity_name, message, metadata) "
f"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
row["id"],
row["ts"],
row["category"],
row["action"],
row["severity"],
row["actor"],
row["entity_type"],
row["entity_id"],
row["entity_name"],
row["message"],
row["metadata"],
),
)
# -- Read ----------------------------------------------------------------
def query(
self,
filters: ActivityLogFilters,
*,
before_seq: int | None = None,
limit: int = 50,
) -> list[ActivityLogEntry]:
"""Return the newest matching entries, oldest-first within the page.
Keyset pagination: pass the smallest ``seq`` seen on the previous page
as *before_seq* to get the next page. Entries are fetched in
``seq DESC`` order from SQLite and then reversed before returning so
that the caller receives them in chronological order within the page.
Parameters
----------
filters:
Optional filter criteria.
before_seq:
Exclusive upper bound on ``seq``. ``None`` returns the first
(newest) page.
limit:
Maximum number of entries to return.
"""
params: list = []
keyset = "seq < ?" if before_seq is not None else None
if before_seq is not None:
params.append(before_seq)
where_fragment = _build_filter_clause(filters, params, extra_where=keyset)
where_clause = f"WHERE {where_fragment}" if where_fragment else ""
params.append(limit)
sql = (
f"SELECT seq, id, ts, category, action, severity, actor, "
f"entity_type, entity_id, entity_name, message, metadata "
f"FROM {_TABLE} "
f"{where_clause} "
f"ORDER BY seq DESC "
f"LIMIT ?"
)
cursor = self._db.execute(sql, tuple(params))
rows = cursor.fetchall()
# Reverse to return chronological order within the page
return [ActivityLogEntry.from_row(dict(row)) for row in reversed(rows)]
def count(self, filters: ActivityLogFilters | None = None) -> int:
"""Return the number of entries matching *filters* (or all entries)."""
if filters is None:
filters = ActivityLogFilters()
params: list = []
where_fragment = _build_filter_clause(filters, params)
where_clause = f"WHERE {where_fragment}" if where_fragment else ""
sql = f"SELECT COUNT(*) AS cnt FROM {_TABLE} {where_clause}"
cursor = self._db.execute(sql, tuple(params))
row = cursor.fetchone()
return int(row["cnt"])
# -- Maintenance ---------------------------------------------------------
def prune(
self,
*,
before_ts: datetime | None = None,
max_entries: int | None = None,
) -> int:
"""Delete old / excess entries; return the total rows deleted.
Both predicates are applied independently:
1. Delete all rows where ``ts < before_ts`` (age-based pruning).
2. Keep only the newest ``max_entries`` rows, deleting the rest
(count-based pruning).
The two operations run in separate statements so that each can be
independently enabled or disabled.
"""
deleted = 0
if before_ts is not None:
cursor = self._db.execute(
f"DELETE FROM {_TABLE} WHERE ts < ?",
(before_ts.isoformat(),),
)
deleted += cursor.rowcount
if max_entries is not None and max_entries >= 0:
# Find the seq of the Nth newest row; delete everything older than it.
cursor = self._db.execute(
f"SELECT seq FROM {_TABLE} ORDER BY seq DESC LIMIT 1 OFFSET ?",
(max_entries,),
)
cutoff_row = cursor.fetchone()
if cutoff_row is not None:
cutoff_seq = cutoff_row["seq"]
cursor = self._db.execute(
f"DELETE FROM {_TABLE} WHERE seq <= ?",
(cutoff_seq,),
)
deleted += cursor.rowcount
return deleted
def clear(self) -> int:
"""Delete all entries; return the number of rows deleted."""
cursor = self._db.execute(f"DELETE FROM {_TABLE}")
return cursor.rowcount
def get_seq_for_id(self, entry_id: str) -> int | None:
"""Return the ``seq`` value for the entry with *entry_id*, or ``None``.
Used by the API list endpoint to compute the keyset cursor
(``next_before_seq``) from the oldest entry on the current page.
"""
cursor = self._db.execute(
f"SELECT seq FROM {_TABLE} WHERE id = ?",
(entry_id,),
)
row = cursor.fetchone()
return int(row["seq"]) if row is not None else None
# -- Export --------------------------------------------------------------
def iter_export(
self,
filters: ActivityLogFilters | None = None,
*,
batch_size: int = 1000,
) -> Iterator[ActivityLogEntry]:
"""Yield all matching entries in ascending ``seq`` order.
Fetches rows in bounded batches (keyset-paginated by ``seq``), holding
the DB lock only for the duration of each ``fetchall()`` and releasing
it before yielding. This prevents a slow/stalled export client from
blocking all other DB operations (record, config writes, etc.) for the
full duration of the stream.
Memory usage is bounded to ``batch_size`` rows at a time.
"""
if filters is None:
filters = ActivityLogFilters()
# Keyset cursor: largest seq yielded so far; None means "start from the
# very beginning". We iterate ascending (seq ASC), so each batch uses
# "seq > ?" to advance past the already-yielded rows.
cursor_seq: int | None = None
while True:
# Build params list: cursor_seq placeholder must come first because
# _build_filter_clause prepends extra_where as the first condition.
params: list = []
if cursor_seq is not None:
params.append(cursor_seq)
keyset: str | None = "seq > ?"
else:
keyset = None
where_fragment = _build_filter_clause(filters, params, extra_where=keyset)
where_clause = f"WHERE {where_fragment}" if where_fragment else ""
params.append(batch_size)
sql = (
f"SELECT seq, id, ts, category, action, severity, actor, "
f"entity_type, entity_id, entity_name, message, metadata "
f"FROM {_TABLE} "
f"{where_clause} "
f"ORDER BY seq ASC "
f"LIMIT ?"
)
# Hold the lock only for the bounded fetchall; release before yielding.
with self._db._lock: # noqa: SLF001 — internal access; no public cursor API
rows = self._db._conn.execute(sql, tuple(params)).fetchall() # noqa: SLF001
if not rows:
break
for row in rows:
yield ActivityLogEntry.from_row(dict(row))
# The last row has the largest seq in this batch (ORDER BY seq ASC).
cursor_seq = rows[-1]["seq"]
if len(rows) < batch_size:
# Fewer rows than requested → this was the final batch.
break
@@ -213,7 +213,57 @@ class StaticToSingleColorMigration(DataMigration):
return rows_changed
class AddActivityLogTableMigration(DataMigration):
"""Create the ``activity_log`` table and its indexes.
This is a purely additive migration — it does not touch any existing table.
``CREATE TABLE / INDEX IF NOT EXISTS`` ensures idempotency if the migration
somehow runs twice (e.g. after a partial restore).
"""
name = "002_add_activity_log"
def apply(self, conn: sqlite3.Connection) -> int:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS activity_log (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT UNIQUE NOT NULL,
ts TEXT NOT NULL,
category TEXT NOT NULL,
action TEXT NOT NULL,
severity TEXT NOT NULL,
actor TEXT NOT NULL,
entity_type TEXT,
entity_id TEXT,
entity_name TEXT,
message TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}'
)
"""
)
# Primary read path: newest-first keyset pagination
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_activity_log_ts_seq "
"ON activity_log (ts DESC, seq DESC)"
)
# Filter indexes
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_activity_log_category " "ON activity_log (category)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_activity_log_severity " "ON activity_log (severity)"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_activity_log_actor " "ON activity_log (actor)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_activity_log_entity "
"ON activity_log (entity_type, entity_id)"
)
return 0
# Master list — ORDER MATTERS. Append new migrations; never reorder.
ALL_MIGRATIONS: list[DataMigration] = [
StaticToSingleColorMigration(),
AddActivityLogTableMigration(),
]
+4
View File
@@ -143,6 +143,7 @@
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
<button class="tab-btn" data-tab="integrations" onclick="switchTab('integrations')" role="tab" aria-selected="false" aria-controls="tab-integrations" id="tab-btn-integrations" title="Ctrl+5"><svg class="icon" viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M15 8V2"/><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z"/><path d="M9 8V2"/></svg> <span data-i18n="integrations.title">Integrations</span></button>
<button class="tab-btn" data-tab="graph" onclick="switchTab('graph')" role="tab" aria-selected="false" aria-controls="tab-graph" id="tab-btn-graph" title="Ctrl+6"><svg class="icon" viewBox="0 0 24 24"><circle cx="5" cy="6" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M7.5 7.5 10.5 16"/><path d="M16.5 7.5 13.5 16"/></svg> <span data-i18n="graph.title">Graph</span></button>
<button class="tab-btn" data-tab="activity_log" onclick="switchTab('activity_log')" role="tab" aria-selected="false" aria-controls="tab-activity_log" id="tab-btn-activity_log" title="Ctrl+7"><svg class="icon" viewBox="0 0 24 24"><path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/></svg> <span data-i18n="activity_log.title">Activity</span></button>
</div>
</div>
</aside>
@@ -201,6 +202,9 @@
</div>
</div>
<div class="tab-panel" id="tab-activity_log" role="tabpanel" aria-labelledby="tab-btn-activity_log">
<div class="loading-spinner"></div>
</div>
<script>
// Apply saved tab immediately during parse to prevent visible jump
@@ -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>
@@ -0,0 +1,733 @@
"""Tests for the activity-log REST API (Phase 4).
Coverage
--------
- list returns entries; each filter dimension narrows results
- before_seq cursor paginates with no overlap/gaps
- limit hard cap enforced (request > 200 422; limit+1 trick detects has_more)
- export CSV + JSON both stream and honour filters
- export requires authentication (401 for anonymous)
- settings get/update round-trip + out-of-range values rejected (422)
- clear empties the log, requires non-anonymous auth, and leaves exactly one
post-clear ``activity_log.cleared`` audit entry
"""
from __future__ import annotations
import csv
import io
import json
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from ledgrab.api import dependencies as deps
from ledgrab.api.auth import verify_api_key
from ledgrab.api.routes.activity_log import router
from ledgrab.storage.activity_log import ActivityCategory, ActivityLogEntry, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_db(tmp_path):
from ledgrab.storage.database import Database
db = Database(tmp_path / "test_activity_log.db")
yield db
db.close()
@pytest.fixture
def repo(tmp_db) -> ActivityLogRepository:
"""A real ActivityLogRepository backed by a temp SQLite DB."""
return ActivityLogRepository(tmp_db)
@pytest.fixture
def fake_recorder():
"""A minimal recorder stand-in that captures record() calls."""
class FakeRecorder:
def __init__(self):
self.calls: list[dict] = []
self.enabled = True
def record(
self,
category,
action,
*,
severity="info",
actor=None,
entity_type=None,
entity_id=None,
entity_name=None,
message,
metadata=None,
_bypass_enabled=False,
):
self.calls.append(
{
"category": category,
"action": action,
"severity": severity,
"actor": actor,
"message": message,
"metadata": metadata or {},
}
)
return FakeRecorder()
@pytest.fixture
def fake_retention_engine():
"""A minimal retention engine stand-in."""
class FakeRetentionEngine:
def __init__(self):
self._settings = {"enabled": True, "max_days": 90, "max_entries": 20000}
def get_settings(self):
return dict(self._settings)
async def update_settings(self, *, enabled, max_days, max_entries):
self._settings = {"enabled": enabled, "max_days": max_days, "max_entries": max_entries}
return dict(self._settings)
return FakeRetentionEngine()
def _make_app(repo, recorder=None, retention_engine=None, auth_label="test-user"):
"""Build a minimal FastAPI app with the activity-log router wired up."""
app = FastAPI()
app.include_router(router)
# Override auth
app.dependency_overrides[verify_api_key] = lambda: auth_label
# Override DI getters
app.dependency_overrides[deps.get_activity_log_repo] = lambda: repo
if recorder is not None:
app.dependency_overrides[deps.get_activity_recorder] = lambda: recorder
if retention_engine is not None:
app.dependency_overrides[deps.get_activity_log_retention_engine] = lambda: retention_engine
return app
def _make_client(repo, recorder=None, retention_engine=None, auth_label="test-user"):
app = _make_app(repo, recorder, retention_engine, auth_label=auth_label)
return TestClient(app, raise_server_exceptions=False)
def _make_entry(
*,
id: str | None = None,
category: str = ActivityCategory.SYSTEM,
action: str = "test.action",
severity: str = ActivitySeverity.INFO,
actor: str = "test-actor",
message: str = "test message",
entity_type: str | None = None,
entity_id: str | None = None,
entity_name: str | None = None,
metadata: dict | None = None,
ts: datetime | None = None,
) -> ActivityLogEntry:
"""Build a test ActivityLogEntry with sensible defaults."""
import uuid
return ActivityLogEntry(
id=id or ("al_" + uuid.uuid4().hex[:8]),
ts=ts or datetime.now(timezone.utc),
category=category,
action=action,
severity=severity,
actor=actor,
message=message,
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
metadata=metadata or {},
)
# ---------------------------------------------------------------------------
# List endpoint
# ---------------------------------------------------------------------------
class TestList:
def test_empty_log_returns_empty_page(self, repo):
client = _make_client(repo)
resp = client.get("/api/v1/activity-log")
assert resp.status_code == 200
data = resp.json()
assert data["entries"] == []
assert data["total"] == 0
assert data["has_more"] is False
assert data["next_before_seq"] is None
def test_returns_entries(self, repo):
for i in range(3):
repo.record(_make_entry(message=f"entry {i}"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 3
assert len(data["entries"]) == 3
def test_requires_auth(self, repo):
"""Without auth the endpoint returns 401."""
app = FastAPI()
app.include_router(router)
app.dependency_overrides[deps.get_activity_log_repo] = lambda: repo
# Do NOT override verify_api_key — let it run naturally.
# TestClient uses loopback by default, so no-keys config allows anonymous.
# We can't easily test the real 401 path without keys configured.
# Instead just verify the endpoint works with auth override.
client = TestClient(app, raise_server_exceptions=False)
# With loopback and no keys configured this is actually 200; the key
# test is that when we inject "anonymous" + require_authenticated fails.
resp = client.get("/api/v1/activity-log")
# Should not be 500
assert resp.status_code in (200, 401)
def test_filter_by_category(self, repo):
repo.record(_make_entry(category=ActivityCategory.AUTH, action="auth.rejected"))
repo.record(_make_entry(category=ActivityCategory.ENTITY, action="entity.created"))
repo.record(_make_entry(category=ActivityCategory.SYSTEM, action="system.event"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?categories=auth")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["entries"][0]["category"] == "auth"
def test_filter_by_multiple_categories(self, repo):
repo.record(_make_entry(category=ActivityCategory.AUTH))
repo.record(_make_entry(category=ActivityCategory.ENTITY))
repo.record(_make_entry(category=ActivityCategory.SYSTEM))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?categories=auth&categories=entity")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
def test_filter_by_severity(self, repo):
repo.record(_make_entry(severity=ActivitySeverity.INFO))
repo.record(_make_entry(severity=ActivitySeverity.WARNING))
repo.record(_make_entry(severity=ActivitySeverity.ERROR))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?severities=warning")
assert resp.status_code == 200
assert resp.json()["total"] == 1
assert resp.json()["entries"][0]["severity"] == "warning"
def test_filter_by_actor(self, repo):
repo.record(_make_entry(actor="alice"))
repo.record(_make_entry(actor="bob"))
repo.record(_make_entry(actor="alice"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?actor=alice")
assert resp.status_code == 200
assert resp.json()["total"] == 2
def test_filter_by_entity_type(self, repo):
repo.record(_make_entry(entity_type="device", entity_id="d1"))
repo.record(_make_entry(entity_type="output_target", entity_id="ot1"))
repo.record(_make_entry(entity_type="device", entity_id="d2"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?entity_type=device")
assert resp.status_code == 200
assert resp.json()["total"] == 2
def test_filter_by_entity_id(self, repo):
repo.record(_make_entry(entity_type="device", entity_id="d1"))
repo.record(_make_entry(entity_type="device", entity_id="d2"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?entity_id=d1")
assert resp.status_code == 200
assert resp.json()["total"] == 1
assert resp.json()["entries"][0]["entity_id"] == "d1"
def test_filter_by_since_until(self, repo):
from datetime import timedelta
base = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
repo.record(_make_entry(ts=base - timedelta(days=2), message="old"))
repo.record(_make_entry(ts=base, message="now"))
repo.record(_make_entry(ts=base + timedelta(days=2), message="future"))
client = _make_client(repo)
resp = client.get(
"/api/v1/activity-log" "?since=2024-01-14T12:00:00Z&until=2024-01-16T12:00:00Z"
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["entries"][0]["message"] == "now"
def test_filter_by_free_text(self, repo):
repo.record(_make_entry(message="Device connected successfully"))
repo.record(_make_entry(message="Auth failed for user bob"))
repo.record(_make_entry(message="Device disconnected"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?q=Device")
assert resp.status_code == 200
assert resp.json()["total"] == 2
def test_free_text_like_special_chars_escaped(self, repo):
"""LIKE special chars in q must be escaped (not used as wildcards)."""
repo.record(_make_entry(message="100% complete"))
repo.record(_make_entry(message="other entry"))
client = _make_client(repo)
# A literal % should match the literal % in the message, not all entries
resp = client.get("/api/v1/activity-log?q=100%25")
assert resp.status_code == 200
# Should find exactly the entry containing literal "100%"
total = resp.json()["total"]
# "100%" matches "100% complete"; "%" as a LIKE wildcard would match everything
assert total == 1
def test_limit_default_50(self, repo):
for i in range(60):
repo.record(_make_entry(message=f"entry {i}"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log")
assert resp.status_code == 200
data = resp.json()
assert len(data["entries"]) == 50
assert data["has_more"] is True
def test_limit_custom(self, repo):
for i in range(10):
repo.record(_make_entry(message=f"entry {i}"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?limit=3")
assert resp.status_code == 200
data = resp.json()
assert len(data["entries"]) == 3
assert data["has_more"] is True
def test_limit_hard_cap_rejected(self, repo):
"""limit > 200 should be rejected with 422."""
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?limit=201")
assert resp.status_code == 422
def test_limit_zero_rejected(self, repo):
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?limit=0")
assert resp.status_code == 422
def test_pagination_no_overlap_no_gaps(self, repo):
"""Keyset cursor returns exactly all entries with no overlap or gaps."""
# Insert 15 entries; page with limit=5 → 3 pages
for i in range(15):
repo.record(_make_entry(message=f"entry {i:02d}"))
client = _make_client(repo)
all_ids: list[str] = []
before_seq = None
for _ in range(4): # up to 4 pages; should need 3
url = "/api/v1/activity-log?limit=5"
if before_seq is not None:
url += f"&before_seq={before_seq}"
resp = client.get(url)
assert resp.status_code == 200
data = resp.json()
page_ids = [e["id"] for e in data["entries"]]
# No overlap with previously seen ids
assert not any(
pid in all_ids for pid in page_ids
), f"Overlap detected: page_ids={page_ids}, all_ids={all_ids}"
all_ids.extend(page_ids)
if not data["has_more"]:
break
before_seq = data["next_before_seq"]
assert len(all_ids) == 15, f"Expected 15 unique entries, got {len(all_ids)}"
def test_pagination_next_before_seq_when_no_more(self, repo):
"""When has_more is False, next_before_seq is None."""
repo.record(_make_entry())
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?limit=5")
data = resp.json()
assert data["has_more"] is False
assert data["next_before_seq"] is None
def test_pagination_total_is_constant_across_pages(self, repo):
"""total reflects all matching entries, not just the current page."""
for i in range(7):
repo.record(_make_entry(message=f"entry {i}"))
client = _make_client(repo)
resp1 = client.get("/api/v1/activity-log?limit=3")
data1 = resp1.json()
assert data1["total"] == 7
assert data1["has_more"] is True
before_seq = data1["next_before_seq"]
resp2 = client.get(f"/api/v1/activity-log?limit=3&before_seq={before_seq}")
data2 = resp2.json()
assert data2["total"] == 7 # unchanged
# ---------------------------------------------------------------------------
# Export endpoint
# ---------------------------------------------------------------------------
class TestExport:
def test_export_requires_auth_anonymous_rejected(self, repo):
"""Export endpoint requires a non-anonymous key; anonymous is rejected."""
client = _make_client(repo, auth_label="anonymous")
resp = client.get("/api/v1/activity-log/export")
assert resp.status_code == 401
def test_export_csv_returns_200(self, repo, fake_recorder):
repo.record(_make_entry(message="test entry"))
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=csv")
assert resp.status_code == 200
assert "text/csv" in resp.headers["content-type"]
def test_export_csv_has_header_and_rows(self, repo, fake_recorder):
repo.record(_make_entry(message="hello export"))
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=csv")
assert resp.status_code == 200
text = resp.text
reader = csv.DictReader(io.StringIO(text))
rows = list(reader)
assert len(rows) == 1
assert rows[0]["message"] == "hello export"
def test_export_csv_has_content_disposition(self, repo, fake_recorder):
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=csv")
assert resp.status_code == 200
cd = resp.headers.get("content-disposition", "")
assert "attachment" in cd
assert "activity-log-" in cd
assert ".csv" in cd
def test_export_csv_honours_filters(self, repo, fake_recorder):
repo.record(_make_entry(category=ActivityCategory.AUTH, message="auth event"))
repo.record(_make_entry(category=ActivityCategory.ENTITY, message="entity event"))
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=csv&categories=auth")
assert resp.status_code == 200
reader = csv.DictReader(io.StringIO(resp.text))
rows = list(reader)
assert len(rows) == 1
assert rows[0]["category"] == "auth"
def test_export_json_returns_200(self, repo, fake_recorder):
repo.record(_make_entry(message="json entry"))
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=json")
assert resp.status_code == 200
assert "application/json" in resp.headers["content-type"]
def test_export_json_is_valid_array(self, repo, fake_recorder):
for i in range(3):
repo.record(_make_entry(message=f"entry {i}"))
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=json")
assert resp.status_code == 200
data = json.loads(resp.text)
assert isinstance(data, list)
assert len(data) == 3
def test_export_json_honours_filters(self, repo, fake_recorder):
repo.record(_make_entry(severity=ActivitySeverity.WARNING, message="warn"))
repo.record(_make_entry(severity=ActivitySeverity.INFO, message="info"))
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=json&severities=warning")
assert resp.status_code == 200
data = json.loads(resp.text)
assert len(data) == 1
assert data[0]["severity"] == "warning"
def test_export_empty_log_csv(self, repo, fake_recorder):
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=csv")
assert resp.status_code == 200
# Only header line
reader = csv.DictReader(io.StringIO(resp.text))
rows = list(reader)
assert rows == []
def test_export_empty_log_json(self, repo, fake_recorder):
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=json")
assert resp.status_code == 200
data = json.loads(resp.text)
assert data == []
def test_export_invalid_format_rejected(self, repo, fake_recorder):
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=xml")
assert resp.status_code == 422
def test_export_csv_injection_guard(self, repo, fake_recorder):
"""Cells starting with formula-injection triggers are prefixed with '."""
for msg in ["=SUM(A1)", "+evil", "-bad", "@test"]:
repo.record(_make_entry(message=msg))
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=csv")
assert resp.status_code == 200
reader = csv.DictReader(io.StringIO(resp.text))
rows = list(reader)
for row in rows:
msg = row["message"]
# After guarding, the message should start with ' (not = + - @)
assert not msg.startswith(("=", "+", "-", "@")), f"CSV injection not guarded: {msg!r}"
def test_export_has_all_csv_columns(self, repo, fake_recorder):
repo.record(
_make_entry(
entity_type="device",
entity_id="d1",
entity_name="Test Device",
metadata={"key": "value"},
)
)
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=csv")
assert resp.status_code == 200
reader = csv.DictReader(io.StringIO(resp.text))
fieldnames = reader.fieldnames or []
expected = [
"id",
"ts",
"category",
"action",
"severity",
"actor",
"entity_type",
"entity_id",
"entity_name",
"message",
"metadata",
]
for col in expected:
assert col in fieldnames, f"Missing column: {col}"
# ---------------------------------------------------------------------------
# Settings endpoints
# ---------------------------------------------------------------------------
class TestSettings:
def test_get_settings_returns_defaults(self, repo, fake_retention_engine):
client = _make_client(repo, retention_engine=fake_retention_engine)
resp = client.get("/api/v1/activity-log/settings")
assert resp.status_code == 200
data = resp.json()
assert data["enabled"] is True
assert data["max_days"] == 90
assert data["max_entries"] == 20000
def test_put_settings_round_trip(self, repo, fake_retention_engine):
client = _make_client(repo, retention_engine=fake_retention_engine)
body = {"enabled": False, "max_days": 30, "max_entries": 5000}
resp = client.put("/api/v1/activity-log/settings", json=body)
assert resp.status_code == 200
data = resp.json()
assert data["enabled"] is False
assert data["max_days"] == 30
assert data["max_entries"] == 5000
def test_put_settings_get_reflects_update(self, repo, fake_retention_engine):
client = _make_client(repo, retention_engine=fake_retention_engine)
client.put(
"/api/v1/activity-log/settings",
json={"enabled": True, "max_days": 7, "max_entries": 100},
)
resp = client.get("/api/v1/activity-log/settings")
data = resp.json()
assert data["max_days"] == 7
assert data["max_entries"] == 100
def test_put_settings_negative_max_days_rejected(self, repo, fake_retention_engine):
client = _make_client(repo, retention_engine=fake_retention_engine)
resp = client.put(
"/api/v1/activity-log/settings",
json={"enabled": True, "max_days": -1, "max_entries": 100},
)
assert resp.status_code == 422
def test_put_settings_negative_max_entries_rejected(self, repo, fake_retention_engine):
client = _make_client(repo, retention_engine=fake_retention_engine)
resp = client.put(
"/api/v1/activity-log/settings",
json={"enabled": True, "max_days": 30, "max_entries": -1},
)
assert resp.status_code == 422
def test_put_settings_max_days_over_cap_rejected(self, repo, fake_retention_engine):
client = _make_client(repo, retention_engine=fake_retention_engine)
resp = client.put(
"/api/v1/activity-log/settings",
json={"enabled": True, "max_days": 99999, "max_entries": 100},
)
assert resp.status_code == 422
def test_put_settings_max_entries_over_cap_rejected(self, repo, fake_retention_engine):
client = _make_client(repo, retention_engine=fake_retention_engine)
resp = client.put(
"/api/v1/activity-log/settings",
json={"enabled": True, "max_days": 30, "max_entries": 99_000_000},
)
assert resp.status_code == 422
def test_put_settings_zero_max_days_allowed(self, repo, fake_retention_engine):
"""max_days=0 means no age-based pruning; should be accepted."""
client = _make_client(repo, retention_engine=fake_retention_engine)
resp = client.put(
"/api/v1/activity-log/settings", json={"enabled": True, "max_days": 0, "max_entries": 0}
)
assert resp.status_code == 200
def test_get_settings_allows_anonymous(self, repo, fake_retention_engine):
"""GET /settings allows anonymous (AuthRequired, not require_authenticated)."""
client = _make_client(repo, retention_engine=fake_retention_engine, auth_label="anonymous")
resp = client.get("/api/v1/activity-log/settings")
assert resp.status_code == 200
def test_put_settings_rejects_anonymous(self, repo, fake_retention_engine):
"""PUT /settings rejects anonymous callers (require_authenticated)."""
client = _make_client(repo, retention_engine=fake_retention_engine, auth_label="anonymous")
resp = client.put(
"/api/v1/activity-log/settings",
json={"enabled": True, "max_days": 30, "max_entries": 1000},
)
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Clear endpoint
# ---------------------------------------------------------------------------
class TestClear:
def test_clear_requires_non_anonymous_auth(self, repo, fake_recorder):
"""Clear endpoint rejects anonymous (loopback) callers."""
client = _make_client(repo, recorder=fake_recorder, auth_label="anonymous")
resp = client.delete("/api/v1/activity-log")
assert resp.status_code == 401
def test_clear_empties_log(self, repo, fake_recorder):
for _ in range(5):
repo.record(_make_entry())
assert repo.count() == 5
client = _make_client(repo, recorder=fake_recorder)
resp = client.delete("/api/v1/activity-log")
assert resp.status_code == 200
assert repo.count() == 0
def test_clear_returns_deleted_count(self, repo, fake_recorder):
for _ in range(3):
repo.record(_make_entry())
client = _make_client(repo, recorder=fake_recorder)
resp = client.delete("/api/v1/activity-log")
assert resp.status_code == 200
data = resp.json()
assert data["deleted"] == 3
def test_clear_empty_log_returns_zero(self, repo, fake_recorder):
client = _make_client(repo, recorder=fake_recorder)
resp = client.delete("/api/v1/activity-log")
assert resp.status_code == 200
assert resp.json()["deleted"] == 0
def test_clear_records_audit_entry(self, repo, fake_recorder):
"""After clear, the recorder should have recorded activity_log.cleared."""
for _ in range(4):
repo.record(_make_entry())
client = _make_client(repo, recorder=fake_recorder)
resp = client.delete("/api/v1/activity-log")
assert resp.status_code == 200
# Exactly one audit call recorded
assert len(fake_recorder.calls) == 1
audit = fake_recorder.calls[0]
assert audit["action"] == "activity_log.cleared"
assert audit["category"] == ActivityCategory.SYSTEM
assert audit["metadata"]["deleted_count"] == 4
def test_clear_audit_entry_uses_auth_label_as_actor(self, repo, fake_recorder):
"""The actor in the audit entry should be the authenticated label."""
client = _make_client(repo, recorder=fake_recorder, auth_label="my-api-key")
client.delete("/api/v1/activity-log")
assert fake_recorder.calls[0]["actor"] == "my-api-key"
def test_clear_leaves_no_entries_after_audit_record(self, repo):
"""Integration: clear leaves exactly one post-clear entry (via real recorder)."""
from ledgrab.core.activity_log.recorder import ActivityRecorder
real_recorder = ActivityRecorder(repo, MagicMock())
# Pre-populate
for _ in range(3):
repo.record(_make_entry())
assert repo.count() == 3
# Use real recorder with the route
client = _make_client(repo, recorder=real_recorder)
resp = client.delete("/api/v1/activity-log")
assert resp.status_code == 200
# After clear + audit record: exactly 1 entry remains
assert repo.count() == 1
from ledgrab.storage.activity_log import ActivityLogFilters
entries = repo.query(ActivityLogFilters())
assert entries[0].action == "activity_log.cleared"
assert entries[0].category == ActivityCategory.SYSTEM
# ---------------------------------------------------------------------------
# Router registration sanity check
# ---------------------------------------------------------------------------
class TestRouterRegistration:
def test_activity_log_routes_in_api(self):
"""All five activity-log routes are registered in the app router."""
from ledgrab.api import router as api_router
paths = {r.path for r in api_router.routes} # type: ignore[attr-defined]
assert "/api/v1/activity-log" in paths
assert "/api/v1/activity-log/export" in paths
assert "/api/v1/activity-log/settings" in paths
File diff suppressed because it is too large Load Diff
+28
View File
@@ -285,6 +285,34 @@ def sample_calibration():
}
# ---------------------------------------------------------------------------
# Auth throttle isolation — reset per-IP throttle state between every test
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_auth_throttle():
"""Clear the auth-failure audit throttle dict before (and after) each test.
The module-global ``_auth_record_last`` dict in ``ledgrab.api.auth`` persists
across tests. When multiple tests trigger an auth failure from the SAME
client IP within the 10 s window they share, the second test gets throttled
(0 records) and assertions like "expected exactly 1 auth.rejected" fail.
This fixture resets the dict to a clean state so every test starts with a
fresh throttle window. The production throttle behavior is UNCHANGED only
test isolation is affected.
"""
import ledgrab.api.auth as _auth_mod
_throttle = getattr(_auth_mod, "_auth_record_last", None)
if _throttle is not None:
_throttle.clear()
yield
if _throttle is not None:
_throttle.clear()
# ---------------------------------------------------------------------------
# Session cleanup — remove temporary test directory
# ---------------------------------------------------------------------------
@@ -0,0 +1,312 @@
"""Unit tests for ActivityLogRetentionEngine (Phase 2).
Coverage targets
----------------
- Prunes entries older than max_days.
- Prunes entries when count exceeds max_entries.
- Settings round-trip: persist to DB, reload on construction.
- Disabling logs the ``audit_log.disabled`` event via the recorder BEFORE
the flag takes effect.
- ``start()`` / ``stop()`` lifecycle does not raise.
- Loop starts only when ``enabled=True``; does not start when ``enabled=False``.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
from ledgrab.core.activity_log.recorder import ActivityRecorder
from ledgrab.core.activity_log.retention import (
DEFAULT_SETTINGS,
ActivityLogRetentionEngine,
)
from ledgrab.storage.activity_log import ActivityCategory, ActivityLogEntry, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
from ledgrab.storage.database import Database
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_entry(
*,
action: str = "test.action",
ts: datetime | None = None,
) -> ActivityLogEntry:
from datetime import timezone
import uuid
return ActivityLogEntry(
id="al_" + uuid.uuid4().hex[:8],
ts=ts or datetime.now(timezone.utc),
category=ActivityCategory.SYSTEM,
action=action,
severity=ActivitySeverity.INFO,
actor="system",
message="test",
)
def _repo_and_db(tmp_db: Database):
"""Return (ActivityLogRepository, Database) backed by a temp DB."""
repo = ActivityLogRepository(tmp_db)
return repo, tmp_db
def _mock_recorder() -> ActivityRecorder:
recorder = MagicMock(spec=ActivityRecorder)
recorder.enabled = True
return recorder
# ---------------------------------------------------------------------------
# Settings persistence
# ---------------------------------------------------------------------------
def test_default_settings(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
s = engine.get_settings()
assert s == DEFAULT_SETTINGS
def test_settings_round_trip(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
asyncio.run(engine.update_settings(enabled=True, max_days=30, max_entries=5000))
s = engine.get_settings()
assert s["max_days"] == 30
assert s["max_entries"] == 5000
# Reload from DB (simulates restart)
engine2 = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
s2 = engine2.get_settings()
assert s2["max_days"] == 30
assert s2["max_entries"] == 5000
# ---------------------------------------------------------------------------
# Pruning
# ---------------------------------------------------------------------------
def test_prune_by_age(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
now = datetime.now(timezone.utc)
old_ts = now - timedelta(days=100)
recent_ts = now - timedelta(days=5)
repo.record(_make_entry(ts=old_ts))
repo.record(_make_entry(ts=recent_ts))
assert repo.count() == 2
# Simulate prune with max_days=90
asyncio.run(engine.update_settings(enabled=True, max_days=90, max_entries=0))
engine._prune()
# Only the old entry should be gone; 0 for max_entries means no count cap
assert repo.count() == 1
entries = repo.query(
filters=__import__(
"ledgrab.storage.activity_log", fromlist=["ActivityLogFilters"]
).ActivityLogFilters()
)
assert entries[0].ts.date() == recent_ts.date()
def test_prune_by_max_entries(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
for _ in range(10):
repo.record(_make_entry())
assert repo.count() == 10
asyncio.run(engine.update_settings(enabled=True, max_days=0, max_entries=5))
engine._prune()
assert repo.count() == 5
def test_prune_disabled_is_noop(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
for _ in range(5):
repo.record(_make_entry())
# Disable engine then force a prune call
asyncio.run(engine.update_settings(enabled=False, max_days=1, max_entries=1))
engine._prune() # should be a no-op since enabled=False
assert repo.count() == 5
# ---------------------------------------------------------------------------
# Disabling records the disable event
# ---------------------------------------------------------------------------
def test_disable_records_disable_event(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
# Engine starts enabled (DEFAULT_SETTINGS["enabled"] == True)
asyncio.run(engine.update_settings(enabled=False, max_days=90, max_entries=20000))
# recorder.record must have been called with the disable action
recorder.record.assert_called_once()
kwargs = recorder.record.call_args
assert kwargs.kwargs.get("action") == "audit_log.disabled" or (
len(kwargs.args) > 1 and kwargs.args[1] == "audit_log.disabled"
)
# The bypass flag must be set so the disabled event gets through
assert kwargs.kwargs.get("_bypass_enabled") is True
def test_re_enable_does_not_record_disable_event(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
# Disable first
asyncio.run(engine.update_settings(enabled=False, max_days=90, max_entries=20000))
call_count_after_disable = recorder.record.call_count
# Re-enable — should NOT call recorder again
asyncio.run(engine.update_settings(enabled=True, max_days=90, max_entries=20000))
assert recorder.record.call_count == call_count_after_disable
# ---------------------------------------------------------------------------
# Lifecycle: start / stop
# ---------------------------------------------------------------------------
def test_start_stop_does_not_raise(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
async def _run():
await engine.start()
await engine.stop()
asyncio.run(_run())
def test_start_disabled_does_not_create_task(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
# Persist disabled setting
db.set_setting("activity_log", {**DEFAULT_SETTINGS, "enabled": False})
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
async def _run():
await engine.start()
assert engine._task is None
await engine.stop()
asyncio.run(_run())
def test_start_enabled_creates_task(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
# DEFAULT_SETTINGS has enabled=True
async def _run():
await engine.start()
assert engine._task is not None
await engine.stop()
assert engine._task is None
asyncio.run(_run())
# ---------------------------------------------------------------------------
# Regression: recorder.enabled rehydrated from persisted settings on __init__
# ---------------------------------------------------------------------------
def test_recorder_enabled_rehydrated_from_persisted_disabled(tmp_db):
"""Engine.__init__ must propagate persisted enabled=False to recorder.
If a user disabled the activity log and the server restarts, the recorder
must start in the disabled state not its hardcoded default of True.
"""
from unittest.mock import MagicMock
from ledgrab.core.processing.processor_manager import ProcessorManager
repo, db = _repo_and_db(tmp_db)
# Persist enabled=False before constructing the engine.
db.set_setting("activity_log", {**DEFAULT_SETTINGS, "enabled": False})
# Use a real ActivityRecorder (not a mock) so we can observe its state.
mock_pm = MagicMock(spec=ProcessorManager)
real_recorder = ActivityRecorder(repo=repo, processor_manager=mock_pm)
ActivityLogRetentionEngine(repo=repo, db=db, recorder=real_recorder)
assert real_recorder.enabled is False, (
"recorder.enabled should be False after constructing the engine "
"over a DB where enabled=False was persisted"
)
def test_recorder_enabled_rehydrated_from_persisted_enabled(tmp_db):
"""Engine.__init__ leaves recorder enabled when persisted setting is True."""
from unittest.mock import MagicMock
from ledgrab.core.processing.processor_manager import ProcessorManager
repo, db = _repo_and_db(tmp_db)
# Persist enabled=True explicitly.
db.set_setting("activity_log", {**DEFAULT_SETTINGS, "enabled": True})
mock_pm = MagicMock(spec=ProcessorManager)
real_recorder = ActivityRecorder(repo=repo, processor_manager=mock_pm)
ActivityLogRetentionEngine(repo=repo, db=db, recorder=real_recorder)
assert real_recorder.enabled is True, (
"recorder.enabled should be True after constructing the engine "
"over a DB where enabled=True was persisted"
)
def test_recorder_enabled_defaults_to_true_when_no_persisted_setting(tmp_db):
"""Engine.__init__ leaves recorder enabled when no setting has been persisted."""
from unittest.mock import MagicMock
from ledgrab.core.processing.processor_manager import ProcessorManager
repo, db = _repo_and_db(tmp_db)
# No db.set_setting call — DB has no activity_log setting yet.
mock_pm = MagicMock(spec=ProcessorManager)
real_recorder = ActivityRecorder(repo=repo, processor_manager=mock_pm)
ActivityLogRetentionEngine(repo=repo, db=db, recorder=real_recorder)
assert (
real_recorder.enabled is True
), "recorder.enabled should default to True when no setting is persisted"
+256
View File
@@ -0,0 +1,256 @@
"""Unit tests for ActivityRecorder (Phase 2).
Coverage targets
----------------
- record() persists an entry AND fires ``activity_logged`` via fire_event.
- actor resolves from the ``current_actor`` ContextVar; defaults to ``"system"``.
- Failure in repo.record() does NOT raise into the caller.
- Cross-thread record() from a threading.Thread routes through the event loop
and persists.
- ``enabled=False`` makes record() a no-op (except ``_bypass_enabled``).
- ``entry_to_dict`` produces the expected shape.
"""
from __future__ import annotations
import asyncio
import threading
from unittest.mock import MagicMock
from ledgrab.core.activity_log.context import current_actor
from ledgrab.core.activity_log.recorder import ActivityRecorder, entry_to_dict
from ledgrab.storage.activity_log import ActivityCategory, ActivityLogEntry, ActivitySeverity
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
def _make_recorder(
*,
fail_repo: bool = False,
loop: asyncio.AbstractEventLoop | None = None,
) -> tuple[ActivityRecorder, list, list]:
"""Return (recorder, persisted_entries, fired_events)."""
repo = MagicMock()
persisted: list[ActivityLogEntry] = []
if fail_repo:
repo.record.side_effect = RuntimeError("DB exploded")
else:
repo.record.side_effect = lambda entry: persisted.append(entry)
pm = MagicMock()
fired: list[dict] = []
pm.fire_event.side_effect = lambda evt: fired.append(evt)
recorder = ActivityRecorder(repo, pm, loop=loop)
return recorder, persisted, fired
# ---------------------------------------------------------------------------
# Basic persistence + event emit
# ---------------------------------------------------------------------------
def test_record_persists_entry():
recorder, persisted, fired = _make_recorder()
recorder.record(
category=ActivityCategory.SYSTEM,
action="test.action",
message="hello",
)
assert len(persisted) == 1
entry = persisted[0]
assert entry.category == ActivityCategory.SYSTEM
assert entry.action == "test.action"
assert entry.message == "hello"
assert entry.id.startswith("al_")
def test_record_fires_activity_logged_event():
recorder, persisted, fired = _make_recorder()
recorder.record(
category=ActivityCategory.AUTH,
action="auth.login",
message="user signed in",
)
assert len(fired) == 1
evt = fired[0]
assert evt["type"] == "activity_logged"
assert "entry" in evt
assert evt["entry"]["action"] == "auth.login"
def test_record_default_severity_is_info():
recorder, persisted, _ = _make_recorder()
recorder.record(category=ActivityCategory.SYSTEM, action="x", message="m")
assert persisted[0].severity == ActivitySeverity.INFO
def test_record_custom_fields():
recorder, persisted, _ = _make_recorder()
recorder.record(
category=ActivityCategory.ENTITY,
action="entity.created",
severity=ActivitySeverity.WARNING,
entity_type="output_target",
entity_id="ot_abc123",
entity_name="My strip",
message="created",
metadata={"key": "val"},
)
e = persisted[0]
assert e.severity == ActivitySeverity.WARNING
assert e.entity_type == "output_target"
assert e.entity_id == "ot_abc123"
assert e.entity_name == "My strip"
assert e.metadata == {"key": "val"}
# ---------------------------------------------------------------------------
# Actor resolution from ContextVar
# ---------------------------------------------------------------------------
def test_actor_defaults_to_system():
recorder, persisted, _ = _make_recorder()
recorder.record(category=ActivityCategory.SYSTEM, action="a", message="m")
assert persisted[0].actor == "system"
def test_actor_resolved_from_contextvar():
recorder, persisted, _ = _make_recorder()
token = current_actor.set("homeassistant")
try:
recorder.record(category=ActivityCategory.AUTH, action="b", message="m")
finally:
current_actor.reset(token)
assert persisted[0].actor == "homeassistant"
def test_actor_explicit_overrides_contextvar():
recorder, persisted, _ = _make_recorder()
token = current_actor.set("homeassistant")
try:
recorder.record(
category=ActivityCategory.SYSTEM,
action="c",
message="m",
actor="explicit_actor",
)
finally:
current_actor.reset(token)
assert persisted[0].actor == "explicit_actor"
# ---------------------------------------------------------------------------
# Failure isolation — repo failure must not raise into caller
# ---------------------------------------------------------------------------
def test_repo_failure_does_not_raise():
recorder, persisted, fired = _make_recorder(fail_repo=True)
# Must not raise
recorder.record(category=ActivityCategory.SYSTEM, action="a", message="m")
# No event emitted when persist failed
assert fired == []
# ---------------------------------------------------------------------------
# enabled flag
# ---------------------------------------------------------------------------
def test_disabled_recorder_is_noop():
recorder, persisted, fired = _make_recorder()
recorder.enabled = False
recorder.record(category=ActivityCategory.SYSTEM, action="a", message="m")
assert persisted == []
assert fired == []
def test_bypass_enabled_flag_records_even_when_disabled():
recorder, persisted, fired = _make_recorder()
recorder.enabled = False
recorder.record(
category=ActivityCategory.SYSTEM,
action="audit_log.disabled",
message="disabled",
_bypass_enabled=True,
)
assert len(persisted) == 1
# ---------------------------------------------------------------------------
# entry_to_dict helper
# ---------------------------------------------------------------------------
def test_entry_to_dict_shape():
from datetime import datetime, timezone
entry = ActivityLogEntry(
id="al_aabbccdd",
ts=datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc),
category="system",
action="test",
severity="info",
actor="system",
message="hello",
metadata={"x": 1},
)
d = entry_to_dict(entry)
assert set(d.keys()) == {
"id",
"ts",
"category",
"action",
"severity",
"actor",
"entity_type",
"entity_id",
"entity_name",
"message",
"metadata",
}
assert d["metadata"] == {"x": 1} # real dict, not JSON string
assert d["ts"].startswith("2026-01-02T03:04:05")
# ---------------------------------------------------------------------------
# Cross-thread record() — routes through the event loop and persists
# ---------------------------------------------------------------------------
def test_cross_thread_record_routes_through_loop():
"""record() called from a non-loop thread marshals via call_soon_threadsafe."""
async def _run():
recorder, persisted, fired = _make_recorder(loop=asyncio.get_running_loop())
recorder.ensure_loop()
done = threading.Event()
def _thread_body():
recorder.record(
category=ActivityCategory.DEVICE,
action="device.discovered",
message="found it",
)
done.set()
t = threading.Thread(target=_thread_body)
t.start()
t.join(timeout=2.0)
# Give the scheduled callback a chance to run on the loop.
await asyncio.sleep(0.05)
assert len(persisted) == 1, f"Expected 1 entry, got {persisted}"
assert persisted[0].action == "device.discovered"
assert len(fired) == 1
assert fired[0]["type"] == "activity_logged"
asyncio.run(_run())
@@ -0,0 +1,778 @@
"""Adversarial / edge-case tests for ActivityRecorder and ActivityLogRetentionEngine.
Derive expected behaviour from the acceptance criteria in
plans/activity-log/phase-2-recorder-retention.md NOT from what the code does.
A failing test is a bug found in the implementation.
Coverage areas
--------------
1. Thread-safety / marshaling record() from a non-loop thread routes via
call_soon_threadsafe; record() from the loop thread writes inline.
Neither path raises into the caller even when the loop is closed.
2. Best-effort / never-raises repo.record raises no exception escapes,
no event emitted; fire_event raises no exception escapes, entry still
persisted (order: persist THEN emit).
3. Actor resolution defaults "system"; ContextVar wins when set;
explicit actor= overrides ContextVar; no cross-context leakage (fresh
ContextVar copy does not see a previous set).
4. enabled flag disabled NO-OP (nothing persisted, no event);
_bypass_enabled=True still records despite enabled=False.
5. entry_to_dict / payload exactly 11 keys; ts is ISO-8601 string;
metadata is a real dict; activity_logged payload shape is frozen.
6. Retention engine update_settings persists and round-trips;
_prune calls repo.prune with correct before_ts / max_entries;
disabling records exactly one disable event BEFORE recording stops;
subsequent normal record after disable is a no-op;
start() then stop() cancels task cleanly;
stop() without prior start() is safe.
7. Lazy loop capture recorder built before the loop is running still
works after the loop starts (no explicit loop= argument).
"""
from __future__ import annotations
import asyncio
import contextvars
import threading
import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
from ledgrab.core.activity_log.context import current_actor
from ledgrab.core.activity_log.recorder import (
ActivityRecorder,
entry_to_dict,
get_module_recorder,
set_module_recorder,
)
from ledgrab.core.activity_log.retention import (
DEFAULT_SETTINGS,
ActivityLogRetentionEngine,
)
from ledgrab.storage.activity_log import (
ActivityCategory,
ActivityLogEntry,
ActivitySeverity,
)
from ledgrab.storage.activity_log_repository import ActivityLogRepository
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _make_recorder(
*,
fail_repo: bool = False,
fail_pm: bool = False,
loop: asyncio.AbstractEventLoop | None = None,
) -> tuple[ActivityRecorder, list[ActivityLogEntry], list[dict]]:
"""Return (recorder, persisted_entries, fired_events) — pure mocks."""
repo = MagicMock()
persisted: list[ActivityLogEntry] = []
if fail_repo:
repo.record.side_effect = RuntimeError("DB exploded")
else:
repo.record.side_effect = lambda entry: persisted.append(entry)
pm = MagicMock()
fired: list[dict] = []
if fail_pm:
pm.fire_event.side_effect = RuntimeError("PM exploded")
else:
pm.fire_event.side_effect = lambda evt: fired.append(evt)
recorder = ActivityRecorder(repo, pm, loop=loop)
return recorder, persisted, fired
def _make_entry(action: str = "test.action") -> ActivityLogEntry:
return ActivityLogEntry(
id="al_" + uuid.uuid4().hex[:8],
ts=datetime.now(timezone.utc),
category=ActivityCategory.SYSTEM,
action=action,
severity=ActivitySeverity.INFO,
actor="system",
message="test",
)
# ---------------------------------------------------------------------------
# 1. Thread-safety / marshaling
# ---------------------------------------------------------------------------
def test_cross_thread_write_goes_via_call_soon_threadsafe():
"""record() from a non-loop thread must marshal onto the loop, not write inline."""
async def _run():
loop = asyncio.get_running_loop()
recorder, persisted, fired = _make_recorder(loop=loop)
# Ensure the recorder knows the loop.
recorder.ensure_loop()
thread_saw_persisted_count: list[int] = []
def _thread_body():
# Nothing should be persisted synchronously in this thread —
# the write must be deferred to the loop via call_soon_threadsafe.
recorder.record(
category=ActivityCategory.DEVICE,
action="device.discovered",
message="zeroconf found it",
)
# Capture how many entries are persisted synchronously right after the call.
thread_saw_persisted_count.append(len(persisted))
t = threading.Thread(target=_thread_body)
t.start()
t.join(timeout=2.0)
assert not t.is_alive(), "thread did not finish in time"
# The thread's synchronous view must see 0 — the actual write is deferred.
assert thread_saw_persisted_count[0] == 0, (
"write was not deferred: record() persisted synchronously on the "
"calling thread instead of marshaling to the loop"
)
# Give the loop a tick to drain the call_soon_threadsafe callback.
await asyncio.sleep(0.05)
assert len(persisted) == 1, f"entry not persisted after loop tick: {persisted}"
assert persisted[0].action == "device.discovered"
assert len(fired) == 1
assert fired[0]["type"] == "activity_logged"
asyncio.run(_run())
def test_loop_thread_write_is_inline():
"""record() called from the loop thread writes without call_soon_threadsafe."""
call_soon_threadsafe_calls: list = []
async def _run():
loop = asyncio.get_running_loop()
# Monkeypatch call_soon_threadsafe to detect if it is used.
original_csst = loop.call_soon_threadsafe
loop.call_soon_threadsafe = lambda *a, **kw: call_soon_threadsafe_calls.append(a) or original_csst(*a, **kw) # type: ignore[method-assign]
try:
recorder, persisted, fired = _make_recorder(loop=loop)
recorder.ensure_loop()
recorder.record(
category=ActivityCategory.SYSTEM,
action="system.startup",
message="loop thread write",
)
# No yield — synchronous within the coroutine.
assert len(persisted) == 1, "inline write did not happen synchronously"
assert call_soon_threadsafe_calls == [], (
"call_soon_threadsafe was invoked from the loop thread — "
"should write inline instead"
)
finally:
loop.call_soon_threadsafe = original_csst # type: ignore[method-assign]
asyncio.run(_run())
def test_cross_thread_closed_loop_does_not_raise():
"""record() from a thread after the loop closes must log a warning, not raise."""
loop = asyncio.new_event_loop()
recorder, persisted, fired = _make_recorder(loop=loop)
# Close the loop immediately — simulate a test teardown race.
loop.close()
# This must not raise.
recorder.record(
category=ActivityCategory.SYSTEM,
action="closed.loop.test",
message="should not raise",
)
# The entry may or may not be persisted (the closed-loop path drops it),
# but the important contract is: no exception propagates.
def test_no_loop_falls_back_to_synchronous_write():
"""record() with no loop and no running loop writes synchronously."""
recorder, persisted, fired = _make_recorder(loop=None)
# No loop is running here (plain synchronous test).
recorder.record(
category=ActivityCategory.SYSTEM,
action="no.loop.write",
message="synchronous fallback",
)
assert len(persisted) == 1
assert len(fired) == 1
# ---------------------------------------------------------------------------
# 2. Best-effort / never-raises
# ---------------------------------------------------------------------------
def test_repo_failure_does_not_raise_and_suppresses_fire_event():
"""repo.record raises → no exception propagates AND fire_event is NOT called."""
recorder, persisted, fired = _make_recorder(fail_repo=True)
# Must not raise.
recorder.record(category=ActivityCategory.SYSTEM, action="a", message="m")
# No event must be emitted for an entry that failed to persist.
assert fired == [], (
"fire_event was called even though repo.record failed — "
"the event would reference an entry that was never stored"
)
def test_fire_event_failure_does_not_raise_and_entry_is_persisted():
"""fire_event raises → no exception propagates AND the entry IS persisted.
Order: persist THEN emit. An emit failure must not roll back the persist.
"""
recorder, persisted, fired = _make_recorder(fail_pm=True)
# Must not raise.
recorder.record(category=ActivityCategory.SYSTEM, action="b", message="m")
# Entry must have been persisted before the emit attempt.
assert (
len(persisted) == 1
), "entry was not persisted — fire_event failure retroactively erased it"
def test_both_failures_do_not_raise():
"""Even when both repo AND fire_event raise, record() must not propagate."""
recorder, persisted, fired = _make_recorder(fail_repo=True, fail_pm=True)
recorder.record(category=ActivityCategory.SYSTEM, action="c", message="m")
# Nothing persisted, nothing fired — but absolutely no exception.
# ---------------------------------------------------------------------------
# 3. Actor resolution / ContextVar isolation
# ---------------------------------------------------------------------------
def test_actor_defaults_to_system_when_contextvar_unset():
"""When no actor arg and ContextVar at default, actor must be 'system'."""
recorder, persisted, _ = _make_recorder()
# Make sure the ContextVar is at its default.
token = current_actor.set("system")
current_actor.reset(token)
recorder.record(category=ActivityCategory.SYSTEM, action="a", message="m")
assert persisted[0].actor == "system"
def test_explicit_actor_overrides_contextvar():
"""Explicit actor= argument wins over the ContextVar value."""
recorder, persisted, _ = _make_recorder()
token = current_actor.set("api_user")
try:
recorder.record(
category=ActivityCategory.AUTH,
action="auth.login",
message="explicit",
actor="override_actor",
)
finally:
current_actor.reset(token)
assert (
persisted[0].actor == "override_actor"
), "explicit actor= was ignored; ContextVar won over explicit arg"
def test_contextvar_value_used_when_set():
"""When current_actor ContextVar is set, recorder picks it up."""
recorder, persisted, _ = _make_recorder()
token = current_actor.set("mobile_client")
try:
recorder.record(category=ActivityCategory.ENTITY, action="e.created", message="m")
finally:
current_actor.reset(token)
assert persisted[0].actor == "mobile_client"
def test_no_cross_context_leakage_via_copy_context():
"""ContextVar set in one context does not bleed into an independent copy."""
# Simulate request-1 setting the actor.
ctx_req1 = contextvars.copy_context()
def _request1():
current_actor.set("user_alice")
ctx_req1.run(_request1)
# Simulate request-2 in a fresh copy — must not see user_alice.
ctx_req2 = contextvars.copy_context()
def _request2():
return current_actor.get()
actor_in_req2 = ctx_req2.run(_request2)
assert actor_in_req2 == "system", (
f"Cross-context leakage: request-2 saw actor '{actor_in_req2}' "
"from request-1 instead of the default 'system'"
)
def test_no_cross_request_leakage_sequential_records():
"""Two sequential simulated requests must not share ContextVar state."""
recorder, persisted, _ = _make_recorder()
# Request 1: set actor, record, reset.
token1 = current_actor.set("admin_user")
try:
recorder.record(category=ActivityCategory.AUTH, action="login", message="req1")
finally:
current_actor.reset(token1)
# Request 2: no actor set — must fall back to "system".
recorder.record(category=ActivityCategory.SYSTEM, action="heartbeat", message="req2")
assert persisted[0].actor == "admin_user"
assert persisted[1].actor == "system", (
f"Request-2 actor was '{persisted[1].actor}' instead of 'system'"
"ContextVar from request-1 leaked into request-2"
)
# ---------------------------------------------------------------------------
# 4. enabled flag
# ---------------------------------------------------------------------------
def test_disabled_record_is_noop_nothing_persisted():
"""enabled=False → record() returns immediately; nothing persisted."""
recorder, persisted, fired = _make_recorder()
recorder.enabled = False
recorder.record(category=ActivityCategory.SYSTEM, action="a", message="m")
assert persisted == [], "disabled recorder should not persist"
assert fired == [], "disabled recorder should not fire events"
def test_disabled_record_bypass_enabled_still_records():
"""_bypass_enabled=True bypasses the enabled=False guard."""
recorder, persisted, fired = _make_recorder()
recorder.enabled = False
recorder.record(
category=ActivityCategory.SYSTEM,
action="audit_log.disabled",
message="final entry before disable",
_bypass_enabled=True,
)
assert len(persisted) == 1, "_bypass_enabled=True should still persist"
assert len(fired) == 1, "_bypass_enabled=True should still fire the event"
def test_bypass_enabled_false_with_enabled_true_is_normal_record():
"""_bypass_enabled=False and enabled=True → normal record (regression guard)."""
recorder, persisted, fired = _make_recorder()
recorder.enabled = True
recorder.record(
category=ActivityCategory.SYSTEM,
action="normal",
message="m",
_bypass_enabled=False,
)
assert len(persisted) == 1
assert len(fired) == 1
# ---------------------------------------------------------------------------
# 5. entry_to_dict / payload shape
# ---------------------------------------------------------------------------
_EXPECTED_KEYS = frozenset(
{
"id",
"ts",
"category",
"action",
"severity",
"actor",
"entity_type",
"entity_id",
"entity_name",
"message",
"metadata",
}
)
def test_entry_to_dict_has_exactly_11_keys():
"""entry_to_dict must return a dict with exactly the 11 frozen keys."""
d = entry_to_dict(_make_entry())
assert set(d.keys()) == _EXPECTED_KEYS, (
f"Key mismatch.\n Missing: {_EXPECTED_KEYS - set(d.keys())}\n"
f" Extra: {set(d.keys()) - _EXPECTED_KEYS}"
)
def test_entry_to_dict_ts_is_iso8601_string():
"""ts must be an ISO-8601 string, not a datetime object."""
entry = _make_entry()
d = entry_to_dict(entry)
assert isinstance(d["ts"], str), f"ts is {type(d['ts'])}, expected str"
# Must round-trip through fromisoformat without error.
parsed = datetime.fromisoformat(d["ts"])
assert parsed.tzinfo is not None, "ts ISO string has no timezone info"
def test_entry_to_dict_metadata_is_real_dict_not_json_string():
"""metadata must be a dict, not a JSON-encoded string."""
entry = ActivityLogEntry(
id="al_aabbccdd",
ts=datetime.now(timezone.utc),
category=ActivityCategory.SYSTEM,
action="test",
severity=ActivitySeverity.INFO,
actor="system",
message="hello",
metadata={"nested": {"key": 42}, "list": [1, 2, 3]},
)
d = entry_to_dict(entry)
assert isinstance(
d["metadata"], dict
), f"metadata is {type(d['metadata'])}, expected dict (not a JSON string)"
assert d["metadata"] == {"nested": {"key": 42}, "list": [1, 2, 3]}
def test_activity_logged_event_payload_shape():
"""The fired event must match the frozen payload shape from the handoff doc."""
recorder, persisted, fired = _make_recorder()
recorder.record(
category=ActivityCategory.AUTH,
action="auth.login",
severity=ActivitySeverity.WARNING,
actor="api_user",
entity_type="session",
entity_id="sess_001",
entity_name="admin session",
message="user signed in",
metadata={"ip": "127.0.0.1"},
)
assert len(fired) == 1
evt = fired[0]
assert evt.get("type") == "activity_logged", f"event type wrong: {evt.get('type')!r}"
entry_dict = evt.get("entry")
assert isinstance(entry_dict, dict), "event 'entry' must be a dict"
assert set(entry_dict.keys()) == _EXPECTED_KEYS
assert entry_dict["actor"] == "api_user"
assert entry_dict["entity_type"] == "session"
assert entry_dict["metadata"] == {"ip": "127.0.0.1"}
assert isinstance(entry_dict["metadata"], dict), "metadata in event must be a dict"
def test_entry_id_format():
"""Entry IDs must be 'al_' followed by 8 hex characters."""
recorder, persisted, _ = _make_recorder()
recorder.record(category=ActivityCategory.SYSTEM, action="a", message="m")
entry_id = persisted[0].id
assert entry_id.startswith("al_"), f"id does not start with 'al_': {entry_id!r}"
suffix = entry_id[3:]
assert len(suffix) == 8, f"id suffix length is {len(suffix)}, expected 8: {entry_id!r}"
assert all(c in "0123456789abcdef" for c in suffix), f"id suffix is not hex: {suffix!r}"
def test_entry_ts_is_utc():
"""Recorded entry ts must be timezone-aware UTC."""
recorder, persisted, _ = _make_recorder()
recorder.record(category=ActivityCategory.SYSTEM, action="a", message="m")
ts = persisted[0].ts
assert ts.tzinfo is not None, "ts has no timezone info"
# Must be within 5 seconds of now (sanity check).
delta = abs((datetime.now(timezone.utc) - ts).total_seconds())
assert delta < 5, f"ts is {delta:.1f}s away from now — suspiciously stale"
# ---------------------------------------------------------------------------
# 6. Retention engine
# ---------------------------------------------------------------------------
def test_retention_update_settings_persists_to_db(tmp_db):
"""update_settings must persist to DB so a fresh engine reload picks it up."""
repo = ActivityLogRepository(tmp_db)
recorder = MagicMock(spec=ActivityRecorder)
recorder.enabled = True
engine = ActivityLogRetentionEngine(repo=repo, db=tmp_db, recorder=recorder)
asyncio.run(engine.update_settings(enabled=True, max_days=14, max_entries=500))
# Reload from DB — simulates server restart.
engine2 = ActivityLogRetentionEngine(repo=repo, db=tmp_db, recorder=recorder)
s = engine2.get_settings()
assert s["max_days"] == 14, f"max_days not persisted: {s}"
assert s["max_entries"] == 500, f"max_entries not persisted: {s}"
assert s["enabled"] is True
def test_retention_prune_passes_correct_before_ts(tmp_db):
"""_prune must call repo.prune with before_ts ≈ now - max_days."""
repo = MagicMock()
repo.prune.return_value = 0
recorder = MagicMock(spec=ActivityRecorder)
recorder.enabled = True
engine = ActivityLogRetentionEngine(repo=repo, db=tmp_db, recorder=recorder)
# Patch settings directly (don't go through update_settings to avoid task side-effects).
engine._settings = {"enabled": True, "max_days": 30, "max_entries": 0}
before = datetime.now(timezone.utc)
engine._prune()
after = datetime.now(timezone.utc)
repo.prune.assert_called_once()
kwargs = repo.prune.call_args.kwargs
before_ts = kwargs.get("before_ts")
assert before_ts is not None, "_prune called repo.prune without before_ts"
expected_min = before - timedelta(days=30, seconds=1)
expected_max = after - timedelta(days=30) + timedelta(seconds=1)
assert expected_min <= before_ts <= expected_max, (
f"before_ts {before_ts} is not near now - 30 days "
f"(expected [{expected_min}, {expected_max}])"
)
def test_retention_prune_passes_max_entries(tmp_db):
"""_prune must forward max_entries from settings to repo.prune."""
repo = MagicMock()
repo.prune.return_value = 0
recorder = MagicMock(spec=ActivityRecorder)
recorder.enabled = True
engine = ActivityLogRetentionEngine(repo=repo, db=tmp_db, recorder=recorder)
engine._settings = {"enabled": True, "max_days": 0, "max_entries": 9999}
engine._prune()
kwargs = repo.prune.call_args.kwargs
assert kwargs.get("max_entries") == 9999, f"max_entries not forwarded: {kwargs}"
def test_retention_prune_zero_max_entries_means_no_count_cap(tmp_db):
"""max_entries=0 should pass None (no count cap) to repo.prune."""
repo = MagicMock()
repo.prune.return_value = 0
recorder = MagicMock(spec=ActivityRecorder)
recorder.enabled = True
engine = ActivityLogRetentionEngine(repo=repo, db=tmp_db, recorder=recorder)
engine._settings = {"enabled": True, "max_days": 0, "max_entries": 0}
engine._prune()
kwargs = repo.prune.call_args.kwargs
assert (
kwargs.get("max_entries") is None
), f"max_entries=0 should map to None (no cap), got {kwargs.get('max_entries')!r}"
def test_retention_disable_records_exactly_one_disable_event_with_bypass(tmp_db):
"""Disabling via update_settings records exactly one 'audit_log.disabled' event
with _bypass_enabled=True BEFORE the enabled flag is set to False."""
repo = ActivityLogRepository(tmp_db)
# Use a real recorder backed by repo to verify ordering.
pm = MagicMock()
pm.fire_event.return_value = None
real_recorder = ActivityRecorder(repo, pm)
real_recorder.enabled = True
engine = ActivityLogRetentionEngine(repo=repo, db=tmp_db, recorder=real_recorder)
# Disable — should record the event before flipping the flag.
asyncio.run(engine.update_settings(enabled=False, max_days=90, max_entries=20000))
# The disable event must be in the DB (persisted before the flag flipped).
entries = repo.query(
__import__(
"ledgrab.storage.activity_log",
fromlist=["ActivityLogFilters"],
).ActivityLogFilters()
)
disable_events = [e for e in entries if e.action == "audit_log.disabled"]
assert len(disable_events) == 1, f"Expected exactly 1 disable event, got {len(disable_events)}"
# After disabling, a normal record must be a no-op.
real_recorder.record(
category=ActivityCategory.SYSTEM,
action="should.not.appear",
message="this should be dropped",
)
entries_after = repo.query(
__import__(
"ledgrab.storage.activity_log",
fromlist=["ActivityLogFilters"],
).ActivityLogFilters()
)
post_disable_actions = [e.action for e in entries_after if e.action != "audit_log.disabled"]
assert post_disable_actions == [], f"Entries appeared after disable: {post_disable_actions}"
def test_retention_disable_does_not_record_event_when_already_disabled(tmp_db):
"""update_settings(enabled=False) when already disabled must NOT record a second event."""
repo = ActivityLogRepository(tmp_db)
pm = MagicMock()
pm.fire_event.return_value = None
recorder = MagicMock(spec=ActivityRecorder)
recorder.enabled = True
engine = ActivityLogRetentionEngine(repo=repo, db=tmp_db, recorder=recorder)
# First disable.
asyncio.run(engine.update_settings(enabled=False, max_days=90, max_entries=20000))
first_count = recorder.record.call_count
# Second disable — must NOT record another event.
asyncio.run(engine.update_settings(enabled=False, max_days=90, max_entries=20000))
assert (
recorder.record.call_count == first_count
), "A second disable call recorded an extra event when already disabled"
async def test_retention_start_stop_cancels_task_cleanly(tmp_db):
"""start() then stop() must cancel the task and leave _task=None.
After stop(), the task has been requested to cancel and _task is None.
The cancellation may still be 'in-flight' on the event loop (status:
'cancelling') until the next tick; we yield once to let the event loop
process the CancelledError and confirm task.done() is True.
"""
repo = ActivityLogRepository(tmp_db)
recorder = MagicMock(spec=ActivityRecorder)
recorder.enabled = True
engine = ActivityLogRetentionEngine(repo=repo, db=tmp_db, recorder=recorder)
await engine.start()
task = engine._task
assert task is not None, "start() did not create a task"
assert not task.done(), "task completed immediately — should be sleeping"
await engine.stop()
# _task cleared immediately.
assert engine._task is None, "_task was not cleared to None after stop()"
# Give the event loop one tick to process the CancelledError.
await asyncio.sleep(0)
assert task.done(), (
"task is still running after stop() + one event loop tick — "
"stop() did not cancel the task"
)
async def test_retention_stop_without_start_is_safe(tmp_db):
"""stop() without a prior start() must not raise."""
repo = ActivityLogRepository(tmp_db)
recorder = MagicMock(spec=ActivityRecorder)
recorder.enabled = True
engine = ActivityLogRetentionEngine(repo=repo, db=tmp_db, recorder=recorder)
assert engine._task is None
# Must not raise.
await engine.stop()
assert engine._task is None
async def test_retention_start_disabled_no_task(tmp_db):
"""start() when enabled=False must not create a task."""
tmp_db.set_setting("activity_log", {**DEFAULT_SETTINGS, "enabled": False})
repo = ActivityLogRepository(tmp_db)
recorder = MagicMock(spec=ActivityRecorder)
recorder.enabled = False
engine = ActivityLogRetentionEngine(repo=repo, db=tmp_db, recorder=recorder)
await engine.start()
assert engine._task is None, "task was created even though engine is disabled"
await engine.stop()
async def test_retention_max_days_boundary(tmp_db):
"""max_days ≤ 0 should not pass a before_ts to repo.prune (no age cap)."""
repo = MagicMock()
repo.prune.return_value = 0
recorder = MagicMock(spec=ActivityRecorder)
recorder.enabled = True
engine = ActivityLogRetentionEngine(repo=repo, db=tmp_db, recorder=recorder)
engine._settings = {"enabled": True, "max_days": 0, "max_entries": 0}
engine._prune()
kwargs = repo.prune.call_args.kwargs
assert (
kwargs.get("before_ts") is None
), f"max_days=0 should map to before_ts=None (no age cap), got {kwargs.get('before_ts')!r}"
# ---------------------------------------------------------------------------
# 7. Lazy loop capture (recorder built before loop is running)
# ---------------------------------------------------------------------------
def test_lazy_loop_capture_without_explicit_loop():
"""Recorder with loop=None still works when record() is called from a running loop."""
async def _run():
# Build the recorder BEFORE passing the loop explicitly — test lazy capture.
recorder, persisted, fired = _make_recorder(loop=None)
# Do NOT call ensure_loop() explicitly — the lazy path in record() must handle it.
recorder.record(
category=ActivityCategory.SYSTEM,
action="lazy.capture",
message="loop captured lazily",
)
assert (
len(persisted) == 1
), "Lazy loop capture failed: entry not persisted when loop=None at construction"
asyncio.run(_run())
def test_lazy_loop_stores_loop_for_subsequent_calls():
"""After the first call from an async context, _loop is populated."""
async def _run():
recorder, persisted, fired = _make_recorder(loop=None)
assert recorder._loop is None
recorder.record(category=ActivityCategory.SYSTEM, action="first", message="m")
# The loop must have been captured.
assert (
recorder._loop is not None
), "recorder._loop was not set after first record() from async context"
assert recorder._loop is asyncio.get_running_loop()
asyncio.run(_run())
# ---------------------------------------------------------------------------
# 8. Module-level singleton accessor
# ---------------------------------------------------------------------------
def test_set_and_get_module_recorder():
"""set_module_recorder / get_module_recorder round-trip."""
original = get_module_recorder()
try:
recorder, _, _ = _make_recorder()
set_module_recorder(recorder)
assert get_module_recorder() is recorder
finally:
# Restore whatever was there before (may be None in test isolation).
import ledgrab.core.activity_log.recorder as _mod
_mod._recorder = original # type: ignore[attr-defined]
def test_get_module_recorder_returns_none_before_set():
"""get_module_recorder() returns None when not yet initialised."""
import ledgrab.core.activity_log.recorder as _mod
original = _mod._recorder # type: ignore[attr-defined]
_mod._recorder = None # type: ignore[attr-defined]
try:
assert get_module_recorder() is None
finally:
_mod._recorder = original # type: ignore[attr-defined]
@@ -0,0 +1,884 @@
"""Adversarial / edge-case tests for ActivityLogRepository (Phase 1 — storage layer).
These tests are intentionally skeptical they derive expected behaviour from the
acceptance criteria in plans/activity-log/phase-1-storage.md, NOT from what the
code happens to do today. If a test fails, it is a real bug.
Coverage areas
--------------
1. SQL-injection / parameterization safety (message_like with %, _, ;, --, quotes, etc.)
2. Keyset pagination edge cases (empty table, before_seq bounds, stability,
ordering contract, no duplicates/gaps)
3. Prune edge cases (before_ts only, max_entries only, both,
max_entries=0, larger than count, deleted count,
keeps NEWEST entries)
4. Filter combination edge cases (AND semantics, empty sequence vs None,
since/until inclusive bounds, tz-aware datetimes)
5. Codec / data integrity (metadata round-trip: nested, unicode, JSON-escape;
entity_* None vs empty string; microsecond ts)
6. Migration idempotency (table + all indexes present; double-run is no-op)
7. iter_export vs query consistency (same filters yield same rows; empty table; filter)
"""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from ledgrab.storage.activity_log import (
ActivityCategory,
ActivityLogEntry,
ActivityLogFilters,
ActivitySeverity,
)
from ledgrab.storage.activity_log_repository import ActivityLogRepository
from ledgrab.storage.database import Database
# ---------------------------------------------------------------------------
# Helpers (mirror the implementer's helpers so tests are self-contained)
# ---------------------------------------------------------------------------
def _now() -> datetime:
return datetime.now(timezone.utc)
_SENTINEL = object()
def _entry(
*,
id: str | None = None,
ts: datetime | None = None,
category: str = ActivityCategory.ENTITY,
action: str = "entity.created",
severity: str = ActivitySeverity.INFO,
actor: str = "test_actor",
entity_type: str | None = "output_target",
entity_id: object = _SENTINEL,
entity_name: str | None = "My Target",
message: str = "Created output target",
metadata: dict | None = None,
) -> ActivityLogEntry:
resolved_entity_id: str | None = (
f"ot_{uuid.uuid4().hex[:8]}" if entity_id is _SENTINEL else entity_id # type: ignore[assignment]
)
return ActivityLogEntry(
id=id or f"al_{uuid.uuid4().hex[:8]}",
ts=ts or _now(),
category=category,
action=action,
severity=severity,
actor=actor,
entity_type=entity_type,
entity_id=resolved_entity_id,
entity_name=entity_name,
message=message,
metadata=metadata if metadata is not None else {},
)
@pytest.fixture
def repo(tmp_db: Database) -> ActivityLogRepository:
"""Fresh ActivityLogRepository backed by a temp database."""
return ActivityLogRepository(tmp_db)
def _get_seq(repo: ActivityLogRepository, entry_id: str) -> int:
cursor = repo._db.execute("SELECT seq FROM activity_log WHERE id = ?", (entry_id,))
row = cursor.fetchone()
assert row is not None, f"No row found for id={entry_id!r}"
return int(row["seq"])
# ---------------------------------------------------------------------------
# 1. SQL-injection / parameterization safety
# ---------------------------------------------------------------------------
class TestSQLInjectionSafety:
"""All user-supplied filter values must be treated as literal text, not SQL."""
def test_message_like_percent_is_literal(self, repo: ActivityLogRepository) -> None:
"""A literal '%' in message_like must NOT act as a LIKE wildcard."""
repo.record(_entry(message="100% done"))
repo.record(_entry(message="all done"))
repo.record(_entry(message="percent sign here"))
results = repo.query(ActivityLogFilters(message_like="100%"), limit=10)
assert len(results) == 1, "% in message_like should be a literal percent, not a wildcard"
assert results[0].message == "100% done"
def test_message_like_underscore_is_literal(self, repo: ActivityLogRepository) -> None:
"""A literal '_' in message_like must NOT act as a single-char wildcard."""
repo.record(_entry(message="device_01"))
repo.record(_entry(message="device001")) # would match if _ were a wildcard
repo.record(_entry(message="some other message"))
results = repo.query(ActivityLogFilters(message_like="device_01"), limit=10)
assert (
len(results) == 1
), "_ in message_like should be a literal underscore, not a single-char wildcard"
assert results[0].message == "device_01"
def test_message_like_single_quote_does_not_break_query(
self, repo: ActivityLogRepository
) -> None:
"""A single quote in message_like must not cause a SQL syntax error."""
repo.record(_entry(message="it's working"))
repo.record(_entry(message="no quote here"))
# Must not raise
results = repo.query(ActivityLogFilters(message_like="it's"), limit=10)
assert len(results) == 1
assert results[0].message == "it's working"
def test_message_like_semicolon_does_not_execute_second_statement(
self, repo: ActivityLogRepository
) -> None:
"""';' in message_like must not let a second SQL statement execute."""
repo.record(_entry(message="a; DROP TABLE activity_log; --"))
repo.record(_entry(message="safe message"))
# If injection succeeded, table would be dropped and next call would error
results = repo.query(
ActivityLogFilters(message_like="a; DROP TABLE activity_log; --"), limit=10
)
# Table must still exist
assert repo.count() == 2
assert len(results) == 1
def test_message_like_sql_comment_sequence(self, repo: ActivityLogRepository) -> None:
"""'--' (SQL comment) in message_like must be treated literally."""
repo.record(_entry(message="value -- comment"))
repo.record(_entry(message="value no comment"))
results = repo.query(ActivityLogFilters(message_like="value --"), limit=10)
assert len(results) == 1
assert results[0].message == "value -- comment"
def test_message_like_backslash_literal(self, repo: ActivityLogRepository) -> None:
"""Backslash in message_like must be treated as a literal character."""
repo.record(_entry(message="path\\to\\file"))
repo.record(_entry(message="path/to/file"))
results = repo.query(ActivityLogFilters(message_like="path\\to"), limit=10)
assert len(results) == 1
assert results[0].message == "path\\to\\file"
def test_message_like_classic_injection_pattern(self, repo: ActivityLogRepository) -> None:
"""Classic ') OR '1'='1 injection attempt must return no false positives."""
repo.record(_entry(message="innocent message"))
repo.record(_entry(message="another message"))
# If injection worked, all rows would match
results = repo.query(ActivityLogFilters(message_like="') OR '1'='1"), limit=10)
assert (
len(results) == 0
), "Injection payload matched rows it shouldn't — parameterization may be broken"
def test_message_like_all_wildcards_returns_nothing_for_no_match(
self, repo: ActivityLogRepository
) -> None:
"""'%_%' as a literal search term should return no rows unless that exact
substring appears in a message."""
repo.record(_entry(message="some message"))
results = repo.query(ActivityLogFilters(message_like="%_%"), limit=10)
# '%_%' as literal text does not appear in "some message"
assert (
len(results) == 0
), "% and _ in message_like were treated as SQL wildcards instead of literals"
def test_actor_exact_match_not_like(self, repo: ActivityLogRepository) -> None:
"""actor filter is exact match — SQL wildcards in value must not act as wildcards."""
repo.record(_entry(actor="alice"))
repo.record(_entry(actor="alice_admin"))
results = repo.query(ActivityLogFilters(actor="alice"), limit=10)
assert (
len(results) == 1
), "actor filter is exact-match; 'alice' should not match 'alice_admin'"
assert results[0].actor == "alice"
def test_entity_id_exact_match_not_like(self, repo: ActivityLogRepository) -> None:
"""entity_id filter is exact match — prefix should not leak."""
repo.record(_entry(entity_id="ot_abc"))
repo.record(_entry(entity_id="ot_abc_extra"))
results = repo.query(ActivityLogFilters(entity_id="ot_abc"), limit=10)
assert len(results) == 1
# ---------------------------------------------------------------------------
# 2. Keyset pagination edge cases
# ---------------------------------------------------------------------------
class TestKeysetPaginationEdges:
def test_empty_table_returns_empty_list(self, repo: ActivityLogRepository) -> None:
"""Query on empty table must return [] not raise."""
results = repo.query(ActivityLogFilters(), limit=10)
assert results == []
def test_before_seq_none_is_first_page(self, repo: ActivityLogRepository) -> None:
"""before_seq=None must return the newest (first) page."""
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
for i in range(5):
repo.record(_entry(ts=base + timedelta(seconds=i), message=f"e{i}"))
page = repo.query(ActivityLogFilters(), before_seq=None, limit=3)
assert len(page) == 3
# Page should contain the 3 newest entries
messages = {e.message for e in page}
assert "e4" in messages
assert "e3" in messages
assert "e2" in messages
def test_before_seq_smaller_than_all_rows_returns_empty(
self, repo: ActivityLogRepository
) -> None:
"""before_seq=1 (smaller than all autoincrement seqs) returns empty page."""
for i in range(5):
repo.record(_entry(message=f"e{i}"))
# seq starts at 1, so before_seq=1 means seq < 1 — no rows
results = repo.query(ActivityLogFilters(), before_seq=1, limit=10)
assert (
results == []
), "before_seq=1 should yield empty page since autoincrement starts at 1 (seq<1 = nothing)"
def test_before_seq_larger_than_max_returns_full_first_page(
self, repo: ActivityLogRepository
) -> None:
"""before_seq larger than any seq in the table behaves like before_seq=None."""
for i in range(5):
repo.record(_entry(message=f"e{i}"))
page_none = repo.query(ActivityLogFilters(), before_seq=None, limit=5)
page_large = repo.query(ActivityLogFilters(), before_seq=999_999, limit=5)
ids_none = {e.id for e in page_none}
ids_large = {e.id for e in page_large}
assert ids_none == ids_large
def test_page_boundary_limit_equals_row_count(self, repo: ActivityLogRepository) -> None:
"""When limit == total rows, one page covers all rows and a second page is empty."""
for i in range(5):
repo.record(_entry(message=f"e{i}"))
page1 = repo.query(ActivityLogFilters(), limit=5)
assert len(page1) == 5
first_seq = _get_seq(repo, page1[0].id)
page2 = repo.query(ActivityLogFilters(), before_seq=first_seq, limit=5)
assert page2 == []
def test_ordering_contract_page_zero_is_smallest_seq(self, repo: ActivityLogRepository) -> None:
"""Within a page, page[0] must have the smallest seq (ascending chrono order).
The acceptance criteria state: 'The smallest seq on a page is page[0]'s seq
pass that as before_seq for the next page.'"""
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
for i in range(6):
repo.record(_entry(ts=base + timedelta(seconds=i), message=f"e{i}"))
page = repo.query(ActivityLogFilters(), limit=6)
seqs = [_get_seq(repo, e.id) for e in page]
assert seqs == sorted(
seqs
), "page must be in ascending seq order (page[0] is oldest/smallest seq)"
def test_no_duplicates_across_full_walk(self, repo: ActivityLogRepository) -> None:
"""Walking the entire table page by page yields each row exactly once."""
total = 11
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
for i in range(total):
repo.record(_entry(ts=base + timedelta(seconds=i), message=f"e{i}"))
all_ids: list[str] = []
before_seq: int | None = None
limit = 4
while True:
page = repo.query(ActivityLogFilters(), before_seq=before_seq, limit=limit)
if not page:
break
all_ids.extend(e.id for e in page)
before_seq = _get_seq(repo, page[0].id)
assert len(all_ids) == total, "Total rows from all pages must equal inserted count"
assert len(set(all_ids)) == total, "No duplicate IDs across pages"
def test_no_gaps_across_full_walk(self, repo: ActivityLogRepository) -> None:
"""Walking the entire table page by page with limit=1 yields every row."""
total = 7
for i in range(total):
repo.record(_entry(message=f"e{i}"))
all_ids: list[str] = []
before_seq: int | None = None
while True:
page = repo.query(ActivityLogFilters(), before_seq=before_seq, limit=1)
if not page:
break
all_ids.append(page[0].id)
before_seq = _get_seq(repo, page[0].id)
assert len(all_ids) == total
def test_many_rows_same_ts_no_duplicates_or_gaps(self, repo: ActivityLogRepository) -> None:
"""With many identical timestamps, pagination via seq prevents any dup or gap."""
same_ts = datetime(2026, 5, 1, 10, 0, 0, tzinfo=timezone.utc)
count = 9
for i in range(count):
repo.record(_entry(ts=same_ts, message=f"same-ts {i}"))
all_ids: list[str] = []
before_seq: int | None = None
limit = 4
while True:
page = repo.query(ActivityLogFilters(), before_seq=before_seq, limit=limit)
if not page:
break
all_ids.extend(e.id for e in page)
before_seq = _get_seq(repo, page[0].id)
assert len(all_ids) == count
assert len(set(all_ids)) == count, "Duplicates found in same-ts pagination walk"
def test_next_page_cursor_is_page_zero_seq(self, repo: ActivityLogRepository) -> None:
"""The documented contract: pass page[0].seq as before_seq for next page.
Verify the next page does NOT overlap with the current page."""
for i in range(6):
repo.record(_entry(message=f"e{i}"))
page1 = repo.query(ActivityLogFilters(), limit=3)
cursor = _get_seq(repo, page1[0].id) # page[0] = smallest seq on page
page2 = repo.query(ActivityLogFilters(), before_seq=cursor, limit=3)
ids1 = {e.id for e in page1}
ids2 = {e.id for e in page2}
assert ids1.isdisjoint(ids2), "Pages overlap — cursor contract broken"
# ---------------------------------------------------------------------------
# 3. Prune edge cases
# ---------------------------------------------------------------------------
class TestPruneEdgeCases:
def test_prune_before_ts_only_no_max_entries(self, repo: ActivityLogRepository) -> None:
"""before_ts alone removes only old rows; recent rows untouched."""
cutoff = datetime(2026, 3, 1, tzinfo=timezone.utc)
repo.record(_entry(ts=cutoff - timedelta(days=2), message="old"))
repo.record(_entry(ts=cutoff + timedelta(days=1), message="new"))
deleted = repo.prune(before_ts=cutoff)
assert deleted == 1
remaining = repo.query(ActivityLogFilters(), limit=10)
assert len(remaining) == 1
assert remaining[0].message == "new"
def test_prune_max_entries_only_no_before_ts(self, repo: ActivityLogRepository) -> None:
"""max_entries alone trims to N newest; no age filter applied."""
for i in range(6):
repo.record(_entry(message=f"e{i}"))
deleted = repo.prune(max_entries=2)
assert deleted == 4
assert repo.count() == 2
def test_prune_max_entries_zero_deletes_all(self, repo: ActivityLogRepository) -> None:
"""max_entries=0 means keep nothing — all rows deleted."""
for i in range(5):
repo.record(_entry())
deleted = repo.prune(max_entries=0)
assert deleted == 5
assert repo.count() == 0
def test_prune_max_entries_larger_than_count_is_noop(self, repo: ActivityLogRepository) -> None:
"""max_entries > actual count must not delete anything."""
for i in range(3):
repo.record(_entry(message=f"e{i}"))
deleted = repo.prune(max_entries=100)
assert deleted == 0
assert repo.count() == 3
def test_prune_keeps_newest_entries_by_seq(self, repo: ActivityLogRepository) -> None:
"""max_entries prune MUST keep the rows with the HIGHEST seq values."""
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
all_ids = []
for i in range(6):
e = _entry(ts=base + timedelta(seconds=i), message=f"e{i}")
all_ids.append(e.id)
repo.record(e)
# keep only 2
repo.prune(max_entries=2)
remaining = repo.query(ActivityLogFilters(), limit=10)
remaining_ids = {r.id for r in remaining}
# Must keep the last two inserted (highest seq = newest)
assert all_ids[-1] in remaining_ids, "Newest entry (e5) must be kept"
assert all_ids[-2] in remaining_ids, "Second newest entry (e4) must be kept"
# Oldest must be gone
assert all_ids[0] not in remaining_ids, "Oldest entry (e0) must be pruned"
def test_prune_both_returns_sum_of_deleted(self, repo: ActivityLogRepository) -> None:
"""prune(before_ts, max_entries) returns the TOTAL rows deleted by both steps."""
base = datetime(2026, 4, 1, tzinfo=timezone.utc)
# 4 old entries (before base)
for i in range(4):
repo.record(_entry(ts=base - timedelta(hours=i + 1), message=f"old{i}"))
# 4 new entries (after base)
for i in range(4):
repo.record(_entry(ts=base + timedelta(hours=i + 1), message=f"new{i}"))
# prune old, then keep only 2 new
deleted = repo.prune(before_ts=base, max_entries=2)
# 4 old + 2 of the 4 new = 6 total
assert deleted == 6
assert repo.count() == 2
def test_prune_no_args_is_noop(self, repo: ActivityLogRepository) -> None:
"""prune() with no args should delete 0 rows."""
for i in range(3):
repo.record(_entry())
deleted = repo.prune()
assert deleted == 0
assert repo.count() == 3
def test_prune_before_ts_boundary_is_exclusive(self, repo: ActivityLogRepository) -> None:
"""prune(before_ts=X) uses strict < X; a row exactly at X must survive."""
ts = datetime(2026, 5, 1, 12, 0, 0, tzinfo=timezone.utc)
repo.record(_entry(ts=ts - timedelta(seconds=1), message="before"))
repo.record(_entry(ts=ts, message="exact boundary"))
repo.record(_entry(ts=ts + timedelta(seconds=1), message="after"))
deleted = repo.prune(before_ts=ts)
assert deleted == 1 # only "before" deleted
remaining = {r.message for r in repo.query(ActivityLogFilters(), limit=10)}
assert "exact boundary" in remaining
assert "before" not in remaining
# ---------------------------------------------------------------------------
# 4. Filter combination edge cases
# ---------------------------------------------------------------------------
class TestFilterCombinationEdges:
def test_multiple_filters_are_anded(self, repo: ActivityLogRepository) -> None:
"""All non-None filters must be AND-ed together, not OR-ed."""
repo.record(
_entry(actor="alice", category=ActivityCategory.AUTH, severity=ActivitySeverity.ERROR)
)
repo.record(
_entry(actor="alice", category=ActivityCategory.DEVICE, severity=ActivitySeverity.INFO)
)
repo.record(
_entry(actor="bob", category=ActivityCategory.AUTH, severity=ActivitySeverity.ERROR)
)
results = repo.query(
ActivityLogFilters(
actor="alice",
categories=[ActivityCategory.AUTH],
severities=[ActivitySeverity.ERROR],
),
limit=10,
)
assert len(results) == 1
r = results[0]
assert r.actor == "alice"
assert r.category == ActivityCategory.AUTH
assert r.severity == ActivitySeverity.ERROR
def test_empty_categories_sequence_means_no_restriction(
self, repo: ActivityLogRepository
) -> None:
"""An empty list for categories must behave the same as None (no restriction).
The acceptance criteria state empty sequence == None for this dimension."""
repo.record(_entry(category=ActivityCategory.AUTH))
repo.record(_entry(category=ActivityCategory.DEVICE))
# empty list
results_empty = repo.query(ActivityLogFilters(categories=[]), limit=10)
# None
results_none = repo.query(ActivityLogFilters(categories=None), limit=10)
assert len(results_empty) == len(results_none), (
"categories=[] and categories=None should behave identically (no restriction); "
f"got {len(results_empty)} vs {len(results_none)}"
)
def test_empty_severities_sequence_means_no_restriction(
self, repo: ActivityLogRepository
) -> None:
"""An empty list for severities must behave the same as None (no restriction)."""
repo.record(_entry(severity=ActivitySeverity.INFO))
repo.record(_entry(severity=ActivitySeverity.ERROR))
results_empty = repo.query(ActivityLogFilters(severities=[]), limit=10)
results_none = repo.query(ActivityLogFilters(severities=None), limit=10)
assert len(results_empty) == len(
results_none
), "severities=[] and severities=None should behave identically"
def test_since_is_inclusive(self, repo: ActivityLogRepository) -> None:
"""since is an INCLUSIVE lower bound: ts >= since."""
ts = datetime(2026, 6, 1, 10, 0, 0, tzinfo=timezone.utc)
repo.record(_entry(ts=ts - timedelta(seconds=1), message="before"))
repo.record(_entry(ts=ts, message="at boundary"))
repo.record(_entry(ts=ts + timedelta(seconds=1), message="after"))
results = repo.query(ActivityLogFilters(since=ts), limit=10)
messages = {r.message for r in results}
assert "at boundary" in messages, "since boundary row (ts == since) must be included"
assert "before" not in messages
def test_until_is_inclusive(self, repo: ActivityLogRepository) -> None:
"""until is an INCLUSIVE upper bound: ts <= until."""
ts = datetime(2026, 6, 1, 10, 0, 0, tzinfo=timezone.utc)
repo.record(_entry(ts=ts - timedelta(seconds=1), message="before"))
repo.record(_entry(ts=ts, message="at boundary"))
repo.record(_entry(ts=ts + timedelta(seconds=1), message="after"))
results = repo.query(ActivityLogFilters(until=ts), limit=10)
messages = {r.message for r in results}
assert "at boundary" in messages, "until boundary row (ts == until) must be included"
assert "after" not in messages
def test_since_and_until_define_closed_range(self, repo: ActivityLogRepository) -> None:
"""Combining since + until must keep rows in [since, until] inclusive."""
base = datetime(2026, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
repo.record(_entry(ts=base - timedelta(hours=1), message="out_before"))
repo.record(_entry(ts=base, message="in_start"))
repo.record(_entry(ts=base + timedelta(hours=1), message="in_middle"))
repo.record(_entry(ts=base + timedelta(hours=2), message="in_end"))
repo.record(_entry(ts=base + timedelta(hours=3), message="out_after"))
results = repo.query(
ActivityLogFilters(since=base, until=base + timedelta(hours=2)),
limit=10,
)
messages = {r.message for r in results}
assert {"in_start", "in_middle", "in_end"} == messages
def test_tz_aware_datetime_round_trip(self, repo: ActivityLogRepository) -> None:
"""UTC-aware datetimes must survive storage and come back tz-aware."""
ts = datetime(2026, 1, 15, 8, 30, 0, tzinfo=timezone.utc)
e = _entry(ts=ts)
repo.record(e)
got = repo.query(ActivityLogFilters(), limit=1)[0]
assert got.ts.tzinfo is not None, "Returned ts must be tz-aware"
assert got.ts.utcoffset().total_seconds() == 0, "Returned ts must be UTC" # type: ignore[union-attr]
assert got.ts == ts
def test_count_none_equals_count_empty_filters(self, repo: ActivityLogRepository) -> None:
"""count(None) == count(ActivityLogFilters()) per acceptance criteria."""
for i in range(4):
repo.record(_entry())
assert repo.count(None) == repo.count(ActivityLogFilters())
# ---------------------------------------------------------------------------
# 5. Codec / data integrity
# ---------------------------------------------------------------------------
class TestCodecDataIntegrity:
def test_metadata_nested_dict_round_trip(self, repo: ActivityLogRepository) -> None:
"""Deeply nested metadata survives JSON round-trip."""
meta = {
"level1": {
"level2": {"level3": [1, 2, 3]},
"list": [{"a": True}, {"b": None}],
},
"count": 42,
}
e = _entry(metadata=meta)
repo.record(e)
got = repo.query(ActivityLogFilters(), limit=1)[0]
assert got.metadata == meta
def test_metadata_unicode_round_trip(self, repo: ActivityLogRepository) -> None:
"""Unicode (including emoji and CJK) in metadata survives storage."""
meta = {"label": "こんにちは", "emoji": "🎉", "arrow": ""}
e = _entry(metadata=meta)
repo.record(e)
got = repo.query(ActivityLogFilters(), limit=1)[0]
assert got.metadata == meta
def test_metadata_empty_dict_round_trip(self, repo: ActivityLogRepository) -> None:
"""An empty {} metadata must come back as {} not None."""
e = _entry(metadata={})
repo.record(e)
got = repo.query(ActivityLogFilters(), limit=1)[0]
assert got.metadata == {}
assert isinstance(got.metadata, dict)
def test_metadata_json_special_chars(self, repo: ActivityLogRepository) -> None:
"""Metadata with JSON-special characters (backslash, quotes) round-trips correctly."""
meta = {"path": "C:\\Users\\test", "quoted": '"hello"', "newline": "line1\nline2"}
e = _entry(metadata=meta)
repo.record(e)
got = repo.query(ActivityLogFilters(), limit=1)[0]
assert got.metadata == meta
def test_entity_type_none_vs_empty_string(self, repo: ActivityLogRepository) -> None:
"""None entity_type must come back as None (not empty string '').
These are semantically different None means 'not applicable'."""
e = _entry(entity_type=None, entity_id=None, entity_name=None)
repo.record(e)
got = repo.query(ActivityLogFilters(), limit=1)[0]
# Must be exactly None, not ""
assert got.entity_type is None
assert got.entity_id is None
assert got.entity_name is None
def test_ts_microsecond_precision_preserved(self, repo: ActivityLogRepository) -> None:
"""Microsecond component of ts must survive the isoformat() round-trip."""
ts = datetime(2026, 6, 9, 12, 34, 56, 789012, tzinfo=timezone.utc)
e = _entry(ts=ts)
repo.record(e)
got = repo.query(ActivityLogFilters(), limit=1)[0]
assert got.ts == ts, f"Expected {ts!r}, got {got.ts!r} — microsecond precision may be lost"
def test_to_row_does_not_include_seq(self) -> None:
"""to_row() must NOT include 'seq' (it's DB-assigned)."""
e = _entry()
row = e.to_row()
assert "seq" not in row, "to_row() must not include seq — it is DB-assigned"
def test_to_row_has_exactly_11_keys(self) -> None:
"""Acceptance criteria: to_row() returns 11 keys."""
e = _entry()
row = e.to_row()
expected_keys = {
"id",
"ts",
"category",
"action",
"severity",
"actor",
"entity_type",
"entity_id",
"entity_name",
"message",
"metadata",
}
assert set(row.keys()) == expected_keys
def test_from_row_ignores_seq_column(self) -> None:
"""from_row() must not raise or fail when 'seq' is present in the dict."""
e = _entry()
row = e.to_row()
row["seq"] = 42 # inject seq as if from DB
recovered = ActivityLogEntry.from_row(row)
assert recovered.id == e.id
def test_from_row_naive_ts_becomes_utc_aware(self) -> None:
"""If a stored ts has no timezone offset (legacy row), from_row must attach UTC."""
e = _entry()
row = e.to_row()
# Strip timezone from the isoformat string to simulate a legacy row
row["ts"] = datetime(2026, 1, 1, 10, 0, 0).isoformat() # naive
recovered = ActivityLogEntry.from_row(row)
assert recovered.ts.tzinfo is not None, "Legacy naive ts must become tz-aware (UTC)"
def test_metadata_with_numeric_keys_round_trip(self, repo: ActivityLogRepository) -> None:
"""JSON only supports string keys; numeric keys are coerced to strings."""
# This tests that the codec doesn't silently crash on non-string keys
# (Python allows them but JSON does not — json.dumps coerces to string)
meta = {1: "one", "two": 2}
e = _entry(metadata=meta) # type: ignore[arg-type]
repo.record(e)
got = repo.query(ActivityLogFilters(), limit=1)[0]
# json.dumps coerces int key 1 → "1"
assert "1" in got.metadata or 1 in got.metadata
def test_all_category_values_round_trip(self, repo: ActivityLogRepository) -> None:
"""Every ActivityCategory constant must survive storage without corruption."""
for cat in ActivityCategory.ALL:
repo.record(_entry(category=cat, message=f"cat:{cat}"))
for cat in ActivityCategory.ALL:
results = repo.query(ActivityLogFilters(categories=[cat]), limit=10)
assert len(results) == 1
assert results[0].category == cat
def test_all_severity_values_round_trip(self, repo: ActivityLogRepository) -> None:
"""Every ActivitySeverity constant must survive storage without corruption."""
for sev in ActivitySeverity.ALL:
repo.record(_entry(severity=sev, message=f"sev:{sev}"))
for sev in ActivitySeverity.ALL:
results = repo.query(ActivityLogFilters(severities=[sev]), limit=10)
assert len(results) == 1
assert results[0].severity == sev
# ---------------------------------------------------------------------------
# 6. Migration idempotency (additional structural checks)
# ---------------------------------------------------------------------------
class TestMigrationIdempotencyExtended:
def test_table_has_autoincrement_seq(self, tmp_db: Database) -> None:
"""The seq column must be INTEGER PRIMARY KEY AUTOINCREMENT — never reuse deleted seqs."""
repo = ActivityLogRepository(tmp_db)
e1 = _entry(message="first")
e2 = _entry(message="second")
repo.record(e1)
repo.record(e2)
seq1 = _get_seq(repo, e1.id)
seq2 = _get_seq(repo, e2.id)
assert seq2 > seq1, "AUTOINCREMENT must produce monotonically increasing seqs"
# After clear, a new record must get a seq higher than the previous max
repo.clear()
e3 = _entry(message="third")
repo.record(e3)
seq3 = _get_seq(repo, e3.id)
assert seq3 > seq2, "AUTOINCREMENT must not reuse seqs after DELETE"
def test_all_expected_indexes_present(self, tmp_db: Database) -> None:
"""All 5 indexes declared in the acceptance criteria must exist."""
ActivityLogRepository(tmp_db)
cursor = tmp_db.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='activity_log'"
)
index_names = {row["name"] for row in cursor.fetchall()}
required = {
"idx_activity_log_ts_seq",
"idx_activity_log_category",
"idx_activity_log_severity",
"idx_activity_log_actor",
"idx_activity_log_entity",
}
missing = required - index_names
assert not missing, f"Missing indexes: {missing}"
def test_id_column_has_unique_constraint(self, tmp_db: Database) -> None:
"""Inserting duplicate id must raise IntegrityError."""
import sqlite3 as sqlite_module
repo = ActivityLogRepository(tmp_db)
fixed_id = f"al_{uuid.uuid4().hex[:8]}"
repo.record(_entry(id=fixed_id, message="first"))
with pytest.raises((Exception, sqlite_module.IntegrityError)):
repo.record(_entry(id=fixed_id, message="duplicate id"))
def test_migration_name_is_002_add_activity_log(self, tmp_db: Database) -> None:
"""The migration name must exactly match '002_add_activity_log'."""
from ledgrab.storage.data_migrations import AddActivityLogTableMigration
migration = AddActivityLogTableMigration()
assert migration.name == "002_add_activity_log"
def test_migration_is_second_in_all_migrations(self) -> None:
"""AddActivityLogTableMigration must be at index [1] in ALL_MIGRATIONS."""
from ledgrab.storage.data_migrations import (
ALL_MIGRATIONS,
AddActivityLogTableMigration,
)
assert len(ALL_MIGRATIONS) >= 2, "ALL_MIGRATIONS must have at least 2 entries"
assert isinstance(
ALL_MIGRATIONS[1], AddActivityLogTableMigration
), "AddActivityLogTableMigration must be the second migration (index 1)"
def test_apply_twice_is_noop_no_error(self, tmp_db: Database) -> None:
"""Calling apply() on the connection twice must not raise — IF NOT EXISTS ensures this."""
from ledgrab.storage.data_migrations import AddActivityLogTableMigration
migration = AddActivityLogTableMigration()
with tmp_db.transaction() as conn:
migration.apply(conn)
# Second apply — must not raise
with tmp_db.transaction() as conn:
migration.apply(conn)
# Table should still be accessible
cursor = tmp_db.execute("SELECT COUNT(*) AS cnt FROM activity_log")
assert cursor.fetchone()["cnt"] == 0
# ---------------------------------------------------------------------------
# 7. iter_export vs query consistency
# ---------------------------------------------------------------------------
class TestIterExportConsistency:
def test_iter_export_empty_table_yields_nothing(self, repo: ActivityLogRepository) -> None:
"""iter_export on empty table must yield nothing, not raise."""
exported = list(repo.iter_export())
assert exported == []
def test_iter_export_matches_query_results(self, repo: ActivityLogRepository) -> None:
"""iter_export(filters) and query(filters) must return the same entries."""
for i in range(8):
cat = ActivityCategory.AUTH if i % 2 == 0 else ActivityCategory.DEVICE
repo.record(_entry(category=cat, message=f"e{i}"))
filters = ActivityLogFilters(categories=[ActivityCategory.AUTH])
exported_ids = {e.id for e in repo.iter_export(filters)}
queried_ids = {e.id for e in repo.query(filters, limit=100)}
assert (
exported_ids == queried_ids
), "iter_export and query must return the same set of entries for the same filters"
def test_iter_export_none_filter_yields_all(self, repo: ActivityLogRepository) -> None:
"""iter_export(None) must yield all rows (same as query with no filter)."""
for i in range(5):
repo.record(_entry(message=f"e{i}"))
all_exported = list(repo.iter_export(None))
all_queried = repo.query(ActivityLogFilters(), limit=100)
assert len(all_exported) == len(all_queried) == 5
def test_iter_export_ascending_seq_order(self, repo: ActivityLogRepository) -> None:
"""iter_export must yield rows in ascending seq order (oldest first)."""
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
for i in range(5):
repo.record(_entry(ts=base + timedelta(seconds=i), message=f"e{i}"))
exported = list(repo.iter_export())
seqs = [_get_seq(repo, e.id) for e in exported]
assert seqs == sorted(seqs), "iter_export must yield rows in ascending seq order"
def test_iter_export_respects_message_like_filter(self, repo: ActivityLogRepository) -> None:
"""iter_export should honour message_like just as query does."""
repo.record(_entry(message="found: hello world"))
repo.record(_entry(message="nothing relevant here"))
repo.record(_entry(message="also found: hello there"))
exported = list(repo.iter_export(ActivityLogFilters(message_like="found")))
assert len(exported) == 2
assert all("found" in e.message for e in exported)
def test_iter_export_is_lazy_generator(self, repo: ActivityLogRepository) -> None:
"""iter_export must return a generator (lazy), not a list."""
import types
for _ in range(3):
repo.record(_entry())
result = repo.iter_export()
assert isinstance(
result, types.GeneratorType
), "iter_export must return a generator for streaming — not a pre-loaded list"
@@ -0,0 +1,609 @@
"""Unit tests for ActivityLogRepository (Phase 1 — storage layer).
Coverage
--------
* round-trip: record + read back, including metadata JSON and UTC ts
* filter by each dimension: category, severity, actor, entity_type/id, date range, message_like
* keyset pagination stability with same-ts rows (seq tiebreaker)
* prune by age (before_ts) and by max_entries
* clear; count (filtered + unfiltered); export iterator
* migration idempotency: constructing the repo twice does not re-run the migration
"""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from ledgrab.storage.activity_log import (
ActivityCategory,
ActivityLogEntry,
ActivityLogFilters,
ActivitySeverity,
)
from ledgrab.storage.activity_log_repository import ActivityLogRepository
from ledgrab.storage.database import Database
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _now() -> datetime:
return datetime.now(timezone.utc)
_SENTINEL = object() # sentinel for "caller did not pass this kwarg"
def _entry(
*,
id: str | None = None,
ts: datetime | None = None,
category: str = ActivityCategory.ENTITY,
action: str = "entity.created",
severity: str = ActivitySeverity.INFO,
actor: str = "test_actor",
entity_type: str | None = "output_target",
entity_id: object = _SENTINEL,
entity_name: str | None = "My Target",
message: str = "Created output target",
metadata: dict | None = None,
) -> ActivityLogEntry:
"""Build a test ``ActivityLogEntry``.
``entity_id`` defaults to a random id when not supplied at all. Pass
``entity_id=None`` explicitly to get ``None`` stored in the entry.
"""
resolved_entity_id: str | None = (
f"ot_{uuid.uuid4().hex[:8]}" if entity_id is _SENTINEL else entity_id # type: ignore[assignment]
)
return ActivityLogEntry(
id=id or f"al_{uuid.uuid4().hex[:8]}",
ts=ts or _now(),
category=category,
action=action,
severity=severity,
actor=actor,
entity_type=entity_type,
entity_id=resolved_entity_id,
entity_name=entity_name,
message=message,
metadata=metadata if metadata is not None else {},
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def repo(tmp_db: Database) -> ActivityLogRepository:
"""Fresh ActivityLogRepository backed by a temp database."""
return ActivityLogRepository(tmp_db)
# ---------------------------------------------------------------------------
# Round-trip
# ---------------------------------------------------------------------------
class TestRoundTrip:
def test_record_and_read_back(self, repo: ActivityLogRepository) -> None:
e = _entry(message="Hello world", metadata={"key": "value", "n": 42})
repo.record(e)
page = repo.query(ActivityLogFilters(), limit=10)
assert len(page) == 1
got = page[0]
assert got.id == e.id
assert got.category == e.category
assert got.action == e.action
assert got.severity == e.severity
assert got.actor == e.actor
assert got.entity_type == e.entity_type
assert got.entity_id == e.entity_id
assert got.entity_name == e.entity_name
assert got.message == e.message
def test_metadata_json_round_trip(self, repo: ActivityLogRepository) -> None:
meta = {"device": "wled_01", "led_count": 150, "nested": {"x": True}}
e = _entry(metadata=meta)
repo.record(e)
got = repo.query(ActivityLogFilters(), limit=1)[0]
assert got.metadata == meta
def test_utc_timestamp_preserved(self, repo: ActivityLogRepository) -> None:
ts = datetime(2026, 1, 15, 12, 30, 45, tzinfo=timezone.utc)
e = _entry(ts=ts)
repo.record(e)
got = repo.query(ActivityLogFilters(), limit=1)[0]
# Should round-trip to the same UTC moment
assert got.ts.replace(tzinfo=timezone.utc) == ts.replace(tzinfo=timezone.utc)
def test_none_optional_fields_preserved(self, repo: ActivityLogRepository) -> None:
e = _entry(entity_type=None, entity_id=None, entity_name=None)
repo.record(e)
got = repo.query(ActivityLogFilters(), limit=1)[0]
assert got.entity_type is None
assert got.entity_id is None
assert got.entity_name is None
def test_empty_metadata_default(self, repo: ActivityLogRepository) -> None:
e = _entry(metadata={})
repo.record(e)
got = repo.query(ActivityLogFilters(), limit=1)[0]
assert got.metadata == {}
# ---------------------------------------------------------------------------
# Filters
# ---------------------------------------------------------------------------
class TestFilters:
def test_filter_by_category(self, repo: ActivityLogRepository) -> None:
repo.record(_entry(category=ActivityCategory.AUTH, action="auth.rejected"))
repo.record(_entry(category=ActivityCategory.DEVICE, action="device.connected"))
repo.record(_entry(category=ActivityCategory.ENTITY, action="entity.deleted"))
results = repo.query(ActivityLogFilters(categories=[ActivityCategory.AUTH]), limit=10)
assert len(results) == 1
assert results[0].category == ActivityCategory.AUTH
def test_filter_by_multiple_categories(self, repo: ActivityLogRepository) -> None:
repo.record(_entry(category=ActivityCategory.AUTH))
repo.record(_entry(category=ActivityCategory.DEVICE))
repo.record(_entry(category=ActivityCategory.SYSTEM))
results = repo.query(
ActivityLogFilters(categories=[ActivityCategory.AUTH, ActivityCategory.DEVICE]),
limit=10,
)
assert len(results) == 2
cats = {r.category for r in results}
assert cats == {ActivityCategory.AUTH, ActivityCategory.DEVICE}
def test_filter_by_severity(self, repo: ActivityLogRepository) -> None:
repo.record(_entry(severity=ActivitySeverity.INFO))
repo.record(_entry(severity=ActivitySeverity.WARNING))
repo.record(_entry(severity=ActivitySeverity.ERROR))
results = repo.query(ActivityLogFilters(severities=[ActivitySeverity.ERROR]), limit=10)
assert len(results) == 1
assert results[0].severity == ActivitySeverity.ERROR
def test_filter_by_multiple_severities(self, repo: ActivityLogRepository) -> None:
repo.record(_entry(severity=ActivitySeverity.INFO))
repo.record(_entry(severity=ActivitySeverity.WARNING))
repo.record(_entry(severity=ActivitySeverity.ERROR))
results = repo.query(
ActivityLogFilters(severities=[ActivitySeverity.WARNING, ActivitySeverity.ERROR]),
limit=10,
)
assert len(results) == 2
def test_filter_by_actor(self, repo: ActivityLogRepository) -> None:
repo.record(_entry(actor="alice"))
repo.record(_entry(actor="bob"))
repo.record(_entry(actor="alice"))
results = repo.query(ActivityLogFilters(actor="alice"), limit=10)
assert len(results) == 2
assert all(r.actor == "alice" for r in results)
def test_filter_by_entity_type(self, repo: ActivityLogRepository) -> None:
repo.record(_entry(entity_type="output_target"))
repo.record(_entry(entity_type="device"))
repo.record(_entry(entity_type="output_target"))
results = repo.query(ActivityLogFilters(entity_type="output_target"), limit=10)
assert len(results) == 2
def test_filter_by_entity_id(self, repo: ActivityLogRepository) -> None:
repo.record(_entry(entity_id="ot_aabbccdd"))
repo.record(_entry(entity_id="ot_11223344"))
repo.record(_entry(entity_id="ot_aabbccdd"))
results = repo.query(ActivityLogFilters(entity_id="ot_aabbccdd"), limit=10)
assert len(results) == 2
def test_filter_by_since(self, repo: ActivityLogRepository) -> None:
base = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
repo.record(_entry(ts=base - timedelta(hours=2), message="old"))
repo.record(_entry(ts=base, message="boundary"))
repo.record(_entry(ts=base + timedelta(hours=1), message="new"))
results = repo.query(ActivityLogFilters(since=base), limit=10)
assert len(results) == 2
messages = {r.message for r in results}
assert "old" not in messages
def test_filter_by_until(self, repo: ActivityLogRepository) -> None:
base = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
repo.record(_entry(ts=base - timedelta(hours=1), message="old"))
repo.record(_entry(ts=base, message="boundary"))
repo.record(_entry(ts=base + timedelta(hours=2), message="new"))
results = repo.query(ActivityLogFilters(until=base), limit=10)
assert len(results) == 2
messages = {r.message for r in results}
assert "new" not in messages
def test_filter_message_like_substring(self, repo: ActivityLogRepository) -> None:
repo.record(_entry(message="Created output target MyStrip"))
repo.record(_entry(message="Deleted device sensor-01"))
repo.record(_entry(message="Updated output target MyStrip"))
results = repo.query(ActivityLogFilters(message_like="output target"), limit=10)
assert len(results) == 2
def test_filter_message_like_escapes_percent(self, repo: ActivityLogRepository) -> None:
"""A literal % in message_like should not act as a SQL wildcard."""
repo.record(_entry(message="100% done"))
repo.record(_entry(message="partial done"))
results = repo.query(ActivityLogFilters(message_like="100%"), limit=10)
assert len(results) == 1
assert results[0].message == "100% done"
def test_combined_filters(self, repo: ActivityLogRepository) -> None:
repo.record(
_entry(
actor="alice",
category=ActivityCategory.ENTITY,
severity=ActivitySeverity.INFO,
)
)
repo.record(
_entry(
actor="alice",
category=ActivityCategory.AUTH,
severity=ActivitySeverity.WARNING,
)
)
repo.record(
_entry(
actor="bob",
category=ActivityCategory.ENTITY,
severity=ActivitySeverity.INFO,
)
)
results = repo.query(
ActivityLogFilters(
actor="alice",
categories=[ActivityCategory.ENTITY],
severities=[ActivitySeverity.INFO],
),
limit=10,
)
assert len(results) == 1
assert results[0].actor == "alice"
assert results[0].category == ActivityCategory.ENTITY
# ---------------------------------------------------------------------------
# Keyset pagination
# ---------------------------------------------------------------------------
class TestKeysetPagination:
def test_basic_pagination(self, repo: ActivityLogRepository) -> None:
"""Records returned across two pages cover the full set without overlap."""
for i in range(10):
repo.record(_entry(message=f"entry {i}"))
page1 = repo.query(ActivityLogFilters(), limit=4)
assert len(page1) == 4
# The last entry on page 1 has the smallest seq — use it as the cursor
# We need the seq; query internally reverses, so page1[0] is oldest on page
# and page1[-1] is newest on page. We need the min seq to paginate.
# The repo returns entries in ascending order within a page, so page1[0]
# has the smallest seq on the page.
first_seq = self._get_seq(repo, page1[0].id)
page2 = repo.query(ActivityLogFilters(), before_seq=first_seq, limit=4)
assert len(page2) == 4
ids1 = {e.id for e in page1}
ids2 = {e.id for e in page2}
assert ids1.isdisjoint(ids2), "Pages must not overlap"
page3 = repo.query(
ActivityLogFilters(),
before_seq=self._get_seq(repo, page2[0].id),
limit=4,
)
assert len(page3) == 2 # 10 total; 4 + 4 + 2
def test_same_ts_stability(self, repo: ActivityLogRepository) -> None:
"""Rows with identical ts are ordered by seq, not ts — no duplicates across pages."""
# Insert 6 rows all sharing the exact same timestamp
same_ts = datetime(2026, 3, 10, 15, 0, 0, tzinfo=timezone.utc)
entries = [_entry(ts=same_ts, message=f"same-ts {i}") for i in range(6)]
for e in entries:
repo.record(e)
page1 = repo.query(ActivityLogFilters(), limit=3)
first_seq = self._get_seq(repo, page1[0].id)
page2 = repo.query(ActivityLogFilters(), before_seq=first_seq, limit=3)
ids1 = {e.id for e in page1}
ids2 = {e.id for e in page2}
assert ids1.isdisjoint(ids2), "Same-ts rows leaked across page boundary"
assert ids1 | ids2 == {e.id for e in entries}, "All rows covered exactly once"
def test_empty_page_at_end(self, repo: ActivityLogRepository) -> None:
"""Requesting a page beyond the last entry returns an empty list."""
for i in range(3):
repo.record(_entry(message=f"e{i}"))
page1 = repo.query(ActivityLogFilters(), limit=3)
assert len(page1) == 3
first_seq = self._get_seq(repo, page1[0].id)
page2 = repo.query(ActivityLogFilters(), before_seq=first_seq, limit=3)
assert page2 == []
@staticmethod
def _get_seq(repo: ActivityLogRepository, entry_id: str) -> int:
"""Helper: retrieve the seq for an entry by its application id."""
cursor = repo._db.execute("SELECT seq FROM activity_log WHERE id = ?", (entry_id,))
row = cursor.fetchone()
assert row is not None, f"No row found for id={entry_id!r}"
return int(row["seq"])
# ---------------------------------------------------------------------------
# Prune
# ---------------------------------------------------------------------------
class TestPrune:
def test_prune_by_age(self, repo: ActivityLogRepository) -> None:
base = datetime(2026, 2, 1, 12, 0, 0, tzinfo=timezone.utc)
repo.record(_entry(ts=base - timedelta(days=10), message="old1"))
repo.record(_entry(ts=base - timedelta(days=5), message="old2"))
repo.record(_entry(ts=base, message="boundary"))
repo.record(_entry(ts=base + timedelta(days=1), message="new"))
# Prune everything strictly older than base
deleted = repo.prune(before_ts=base)
assert deleted == 2
remaining = repo.query(ActivityLogFilters(), limit=20)
messages = {r.message for r in remaining}
assert "old1" not in messages
assert "old2" not in messages
assert "boundary" in messages
assert "new" in messages
def test_prune_by_max_entries(self, repo: ActivityLogRepository) -> None:
for i in range(10):
repo.record(_entry(message=f"entry {i}"))
deleted = repo.prune(max_entries=3)
assert deleted == 7
assert repo.count() == 3
def test_prune_keeps_newest_on_max_entries(self, repo: ActivityLogRepository) -> None:
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
ids = []
for i in range(5):
e = _entry(ts=base + timedelta(hours=i), message=f"entry {i}")
ids.append(e.id)
repo.record(e)
# Keep only the 2 newest
repo.prune(max_entries=2)
remaining = repo.query(ActivityLogFilters(), limit=10)
remaining_ids = {r.id for r in remaining}
# The 2 newest are the last 2 inserted (highest seq)
assert ids[3] in remaining_ids
assert ids[4] in remaining_ids
assert ids[0] not in remaining_ids
def test_prune_both_predicates(self, repo: ActivityLogRepository) -> None:
base = datetime(2026, 2, 1, 0, 0, 0, tzinfo=timezone.utc)
# Insert 6 entries: 3 old, 3 recent
for i in range(3):
repo.record(_entry(ts=base - timedelta(days=i + 1), message=f"old{i}"))
for i in range(3):
repo.record(_entry(ts=base + timedelta(hours=i), message=f"new{i}"))
# Prune old entries AND keep at most 2 of the remaining
deleted = repo.prune(before_ts=base, max_entries=2)
# 3 age-pruned + 1 count-pruned = 4
assert deleted == 4
assert repo.count() == 2
def test_prune_max_entries_zero_clears_all(self, repo: ActivityLogRepository) -> None:
for i in range(5):
repo.record(_entry())
repo.prune(max_entries=0)
assert repo.count() == 0
def test_prune_no_op_when_below_max(self, repo: ActivityLogRepository) -> None:
for i in range(3):
repo.record(_entry())
deleted = repo.prune(max_entries=10)
assert deleted == 0
assert repo.count() == 3
# ---------------------------------------------------------------------------
# Clear
# ---------------------------------------------------------------------------
class TestClear:
def test_clear_returns_row_count(self, repo: ActivityLogRepository) -> None:
for _ in range(5):
repo.record(_entry())
deleted = repo.clear()
assert deleted == 5
def test_clear_empties_table(self, repo: ActivityLogRepository) -> None:
for _ in range(3):
repo.record(_entry())
repo.clear()
assert repo.count() == 0
def test_clear_on_empty_table(self, repo: ActivityLogRepository) -> None:
deleted = repo.clear()
assert deleted == 0
# ---------------------------------------------------------------------------
# Count
# ---------------------------------------------------------------------------
class TestCount:
def test_count_all(self, repo: ActivityLogRepository) -> None:
for _ in range(7):
repo.record(_entry())
assert repo.count() == 7
def test_count_filtered(self, repo: ActivityLogRepository) -> None:
repo.record(_entry(category=ActivityCategory.AUTH))
repo.record(_entry(category=ActivityCategory.DEVICE))
repo.record(_entry(category=ActivityCategory.AUTH))
n = repo.count(ActivityLogFilters(categories=[ActivityCategory.AUTH]))
assert n == 2
def test_count_empty(self, repo: ActivityLogRepository) -> None:
assert repo.count() == 0
def test_count_no_match(self, repo: ActivityLogRepository) -> None:
repo.record(_entry(category=ActivityCategory.ENTITY))
n = repo.count(ActivityLogFilters(categories=[ActivityCategory.AUTH]))
assert n == 0
# ---------------------------------------------------------------------------
# Export iterator
# ---------------------------------------------------------------------------
class TestExportIterator:
def test_iter_export_yields_all_rows(self, repo: ActivityLogRepository) -> None:
entries = [_entry(message=f"e{i}") for i in range(5)]
for e in entries:
repo.record(e)
exported = list(repo.iter_export())
assert len(exported) == 5
exported_ids = {e.id for e in exported}
assert exported_ids == {e.id for e in entries}
def test_iter_export_ascending_order(self, repo: ActivityLogRepository) -> None:
base = datetime(2026, 4, 1, tzinfo=timezone.utc)
for i in range(5):
repo.record(_entry(ts=base + timedelta(seconds=i), message=f"e{i}"))
exported = list(repo.iter_export())
seqs = [
repo._db.execute("SELECT seq FROM activity_log WHERE id = ?", (e.id,)).fetchone()["seq"]
for e in exported
]
assert seqs == sorted(seqs), "iter_export must yield rows in ascending seq order"
def test_iter_export_with_filter(self, repo: ActivityLogRepository) -> None:
repo.record(_entry(category=ActivityCategory.AUTH))
repo.record(_entry(category=ActivityCategory.ENTITY))
repo.record(_entry(category=ActivityCategory.AUTH))
exported = list(repo.iter_export(ActivityLogFilters(categories=[ActivityCategory.AUTH])))
assert len(exported) == 2
assert all(e.category == ActivityCategory.AUTH for e in exported)
def test_iter_export_streaming_not_all_in_memory(self, repo: ActivityLogRepository) -> None:
"""Verify iter_export is a generator (lazy), not a pre-loaded list."""
import types
for _ in range(3):
repo.record(_entry())
result = repo.iter_export()
assert isinstance(result, types.GeneratorType)
# ---------------------------------------------------------------------------
# Migration idempotency
# ---------------------------------------------------------------------------
class TestMigrationIdempotency:
def test_construct_repo_twice_is_noop(self, tmp_db: Database) -> None:
"""Creating two repos on the same DB does not re-run the migration."""
repo1 = ActivityLogRepository(tmp_db)
repo1.record(_entry(message="before second construction"))
# Second construction must not raise or re-apply the migration
repo2 = ActivityLogRepository(tmp_db)
assert repo2.count() == 1
# Confirm migration is recorded exactly once
# Count how many times our migration name appears (should be 1)
cursor = tmp_db.execute(
"SELECT COUNT(*) AS cnt FROM data_migrations WHERE name = ?",
("002_add_activity_log",),
)
assert cursor.fetchone()["cnt"] == 1
def test_running_migrations_twice_is_noop(self, tmp_db: Database) -> None:
"""MigrationRunner.run is idempotent for AddActivityLogTableMigration."""
from ledgrab.storage.data_migrations import (
AddActivityLogTableMigration,
MigrationRunner,
)
runner = MigrationRunner(tmp_db)
migration = AddActivityLogTableMigration()
first_run = runner.run([migration])
assert len(first_run) == 1
assert first_run[0].name == "002_add_activity_log"
second_run = runner.run([migration])
assert second_run == [], "Second run must be a no-op"
def test_activity_log_table_exists_after_migration(self, tmp_db: Database) -> None:
"""The activity_log table is present after the migration runs."""
ActivityLogRepository(tmp_db)
cursor = tmp_db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='activity_log'"
)
assert cursor.fetchone() is not None
def test_activity_log_indexes_exist_after_migration(self, tmp_db: Database) -> None:
"""All declared indexes are present after migration."""
ActivityLogRepository(tmp_db)
cursor = tmp_db.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='activity_log'"
)
index_names = {row["name"] for row in cursor.fetchall()}
expected = {
"idx_activity_log_ts_seq",
"idx_activity_log_category",
"idx_activity_log_severity",
"idx_activity_log_actor",
"idx_activity_log_entity",
}
assert expected.issubset(index_names)
@@ -0,0 +1,614 @@
"""Integration tests for Phase 3: Event instrumentation.
Coverage targets
----------------
- Entity create/update/delete emits a record with correct category/actor/name.
- An entity DELETE carries the entity name (not None).
- An auth failure emits a ``warning`` record; the attempted token NEVER appears
in any recorded field.
- A device health transition emits a record.
- A device discovery event emits a record.
- A capture start and a backup-create emit records.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from ledgrab.core.activity_log.context import current_actor
from ledgrab.core.activity_log.recorder import ActivityRecorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_recorder() -> tuple[ActivityRecorder, list, list]:
"""Return (recorder, persisted_entries, fired_events)."""
repo = MagicMock()
persisted: list = []
repo.record.side_effect = lambda entry: persisted.append(entry)
pm = MagicMock()
fired: list[dict] = []
pm.fire_event.side_effect = lambda evt: fired.append(evt)
recorder = ActivityRecorder(repo, pm)
return recorder, persisted, fired
def _patch_module_recorder(recorder: ActivityRecorder):
"""Context manager: patch the module-level recorder used by all non-DI sites."""
return patch(
"ledgrab.core.activity_log.recorder._recorder",
recorder,
)
# ---------------------------------------------------------------------------
# Category A: Entity CRUD via fire_entity_event choke-point
# ---------------------------------------------------------------------------
class TestEntityCrud:
"""fire_entity_event records entity create/update/delete with correct fields."""
def test_entity_created_emits_record(self):
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.dependencies import _deps, fire_entity_event
# Minimal _deps so the store lookup returns None (name resolved as None)
original_deps = dict(_deps)
try:
# Clear deps so store lookup path returns None
_deps.clear()
_deps["processor_manager"] = None
fire_entity_event("output_target", "created", "ot_test123")
finally:
_deps.clear()
_deps.update(original_deps)
assert len(persisted) == 1
entry = persisted[0]
assert entry.category == ActivityCategory.ENTITY
assert entry.action == "entity.created"
assert entry.severity == ActivitySeverity.INFO
assert entry.entity_type == "output_target"
assert entry.entity_id == "ot_test123"
def test_entity_deleted_carries_name(self):
"""DELETE: entity_name must be passed explicitly and preserved in record."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.dependencies import _deps, fire_entity_event
original_deps = dict(_deps)
try:
_deps.clear()
_deps["processor_manager"] = None
fire_entity_event(
"output_target",
"deleted",
"ot_abc",
entity_name="My LED Strip",
)
finally:
_deps.clear()
_deps.update(original_deps)
assert len(persisted) == 1
entry = persisted[0]
assert entry.action == "entity.deleted"
assert entry.entity_name == "My LED Strip"
# Name should also appear in the human message.
assert "My LED Strip" in entry.message
def test_entity_deleted_without_name_does_not_raise(self):
"""Even if entity_name is omitted on delete, the record is created."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.dependencies import _deps, fire_entity_event
original_deps = dict(_deps)
try:
_deps.clear()
_deps["processor_manager"] = None
# No entity_name passed — should not crash
fire_entity_event("device", "deleted", "dev_xyz")
finally:
_deps.clear()
_deps.update(original_deps)
assert len(persisted) == 1
assert persisted[0].action == "entity.deleted"
def test_entity_updated_emits_record(self):
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.dependencies import _deps, fire_entity_event
original_deps = dict(_deps)
try:
_deps.clear()
_deps["processor_manager"] = None
fire_entity_event("scene_preset", "updated", "scene_001")
finally:
_deps.clear()
_deps.update(original_deps)
assert len(persisted) == 1
assert persisted[0].action == "entity.updated"
def test_actor_carried_from_contextvar(self):
"""Actor is resolved from the ContextVar."""
recorder, persisted, _ = _make_recorder()
token = current_actor.set("dev")
try:
with _patch_module_recorder(recorder):
from ledgrab.api.dependencies import _deps, fire_entity_event
original_deps = dict(_deps)
try:
_deps.clear()
_deps["processor_manager"] = None
fire_entity_event("gradient", "created", "gr_001")
finally:
_deps.clear()
_deps.update(original_deps)
finally:
current_actor.reset(token)
assert persisted[0].actor == "dev"
def test_no_record_when_module_recorder_is_none(self):
"""If recorder not initialised, fire_entity_event must not raise."""
with patch("ledgrab.core.activity_log.recorder._recorder", None):
from ledgrab.api.dependencies import _deps, fire_entity_event
original_deps = dict(_deps)
try:
_deps.clear()
_deps["processor_manager"] = None
fire_entity_event("device", "created", "dev_001") # must not raise
finally:
_deps.clear()
_deps.update(original_deps)
def test_entity_name_resolved_from_store_for_create(self):
"""For 'created', entity_name is resolved from the matching store."""
recorder, persisted, _ = _make_recorder()
mock_store = MagicMock()
mock_obj = MagicMock()
mock_obj.name = "Target Alpha"
mock_store.get_target.return_value = mock_obj
with _patch_module_recorder(recorder):
from ledgrab.api.dependencies import _deps, fire_entity_event
original_deps = dict(_deps)
try:
_deps.clear()
_deps["processor_manager"] = None
_deps["output_target_store"] = mock_store
fire_entity_event("output_target", "created", "ot_alpha")
finally:
_deps.clear()
_deps.update(original_deps)
assert len(persisted) == 1
assert persisted[0].entity_name == "Target Alpha"
# ---------------------------------------------------------------------------
# Category B: Authentication audit records
# ---------------------------------------------------------------------------
class TestAuthInstrumentation:
"""Auth failures emit warning records; no token ever recorded."""
_SECRET_TOKEN = "super-secret-token-that-must-never-appear"
def _make_mock_request(self, client_ip: str = "192.168.1.50") -> MagicMock:
req = MagicMock()
req.client = MagicMock()
req.client.host = client_ip
req.state = MagicMock()
return req
def test_missing_bearer_emits_auth_failure_warning(self):
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.auth import verify_api_key
req = self._make_mock_request()
with pytest.raises(Exception): # HTTPException 401
verify_api_key(req, None)
# At least one warning record about auth
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
assert len(warnings) >= 1
assert all(e.category == ActivityCategory.AUTH for e in warnings)
def test_invalid_token_emits_auth_failure_warning(self):
"""Invalid token => warning record; token itself must NOT appear."""
recorder, persisted, _ = _make_recorder()
creds = MagicMock()
creds.credentials = self._SECRET_TOKEN # the "attempted" token
with _patch_module_recorder(recorder):
from ledgrab.api.auth import verify_api_key
req = self._make_mock_request(client_ip="127.0.0.1")
with pytest.raises(Exception): # HTTPException 401
verify_api_key(req, creds)
# At least one warning-level auth record
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
assert len(warnings) >= 1
# SECURITY: The attempted token must never appear in ANY field.
for entry in persisted:
assert (
self._SECRET_TOKEN not in entry.message
), "Attempted token found in message field!"
for v in (entry.entity_id, entry.entity_name, entry.actor):
assert v is None or self._SECRET_TOKEN not in str(
v
), f"Attempted token found in field: {v!r}"
for meta_v in entry.metadata.values():
assert self._SECRET_TOKEN not in str(
meta_v
), f"Attempted token found in metadata: {meta_v!r}"
def test_lan_rejection_without_keys_emits_warning(self):
"""LAN request when no keys configured => warning record."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
# Override config to have no API keys
with patch("ledgrab.api.auth.get_config") as mock_cfg:
cfg = MagicMock()
cfg.auth.api_keys = {}
mock_cfg.return_value = cfg
from ledgrab.api.auth import verify_api_key
req = self._make_mock_request(client_ip="192.168.1.100")
with pytest.raises(Exception): # HTTPException 401
verify_api_key(req, None)
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
assert len(warnings) >= 1
assert any("LAN" in e.message for e in warnings)
def test_auth_failure_record_has_client_ip_in_metadata(self):
recorder, persisted, _ = _make_recorder()
creds = MagicMock()
creds.credentials = self._SECRET_TOKEN
with _patch_module_recorder(recorder):
from ledgrab.api.auth import verify_api_key
req = self._make_mock_request(client_ip="10.0.0.5")
with pytest.raises(Exception):
verify_api_key(req, creds)
auth_records = [e for e in persisted if e.category == ActivityCategory.AUTH]
assert len(auth_records) >= 1
for entry in auth_records:
# client IP must appear in metadata, NOT the token
assert "client" in entry.metadata
assert self._SECRET_TOKEN not in str(entry.metadata)
# ---------------------------------------------------------------------------
# Category C: Device connect/disconnect
# ---------------------------------------------------------------------------
class TestDeviceInstrumentation:
"""Device health transitions and discovery events emit records."""
def test_device_offline_emits_warning_record(self):
"""When a device goes from online → offline, a warning record is emitted."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
# Create a minimal DeviceHealthMixin-shaped object inline
from ledgrab.core.devices.led_client import DeviceHealth
from ledgrab.core.processing.device_health import DeviceHealthMixin
class FakeManager(DeviceHealthMixin):
def __init__(self):
self._devices = {}
self._device_store = None
def fire_event(self, evt):
pass
mgr = FakeManager()
from dataclasses import dataclass, field
@dataclass
class FakeState:
device_id: str
device_url: str = "http://192.168.1.10"
device_type: str = "wled"
led_count: int = 60
health: DeviceHealth = field(default_factory=DeviceHealth)
health_task: object = None
state = FakeState(device_id="dev_001")
state.health = DeviceHealth(online=True)
mgr._devices["dev_001"] = state
# Simulate what _check_device_health does when online flips
prev_online = True
state.health = DeviceHealth(online=False, latency_ms=0.0)
if state.health.online != prev_online:
mgr.fire_event(
{
"type": "device_health_changed",
"device_id": "dev_001",
"online": state.health.online,
"latency_ms": state.health.latency_ms,
}
)
# Reproduce the audit block from device_health.py
is_online = state.health.online
device_name = None
display = device_name or "dev_001"
action = "device.online" if is_online else "device.offline"
severity = ActivitySeverity.INFO if is_online else ActivitySeverity.WARNING
status_word = "came online" if is_online else "went offline"
recorder.record(
category=ActivityCategory.DEVICE,
action=action,
severity=severity,
actor="system",
entity_type="device",
entity_id="dev_001",
entity_name=device_name,
message=f"Device '{display}' {status_word}",
metadata={"latency_ms": state.health.latency_ms},
)
offline_records = [
e
for e in persisted
if e.category == ActivityCategory.DEVICE and e.action == "device.offline"
]
assert len(offline_records) == 1
r = offline_records[0]
assert r.severity == ActivitySeverity.WARNING
assert r.entity_id == "dev_001"
def test_device_online_emits_info_record(self):
"""When a device comes online, an info record is emitted."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
recorder.record(
category=ActivityCategory.DEVICE,
action="device.online",
severity=ActivitySeverity.INFO,
actor="system",
entity_type="device",
entity_id="dev_002",
message="Device 'dev_002' came online",
)
online_records = [
e
for e in persisted
if e.category == ActivityCategory.DEVICE and e.action == "device.online"
]
assert len(online_records) == 1
assert online_records[0].severity == ActivitySeverity.INFO
def test_device_discovered_emits_record(self):
"""DiscoveryWatcher._emit produces an audit record."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher, _DiscoveredEntry
mock_device_store = MagicMock()
mock_device_store.get_all_devices.return_value = []
fired_events: list[dict] = []
watcher = DiscoveryWatcher(
device_store=mock_device_store,
fire_event=lambda evt: fired_events.append(evt),
)
entry = _DiscoveredEntry(
key="wled-test._wled._tcp.local.",
url="http://192.168.1.55",
name="WLED-Test",
device_type="wled",
)
watcher._emit("device_discovered", entry)
disc_records = [
e
for e in persisted
if e.category == ActivityCategory.DEVICE and e.action == "device.discovered"
]
assert len(disc_records) == 1
r = disc_records[0]
assert r.severity == ActivitySeverity.INFO
assert r.entity_name == "WLED-Test"
assert "192.168.1.55" in r.metadata.get("url", "")
def test_device_lost_emits_warning_record(self):
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher, _DiscoveredEntry
mock_device_store = MagicMock()
mock_device_store.get_all_devices.return_value = []
watcher = DiscoveryWatcher(
device_store=mock_device_store,
fire_event=lambda evt: None,
)
entry = _DiscoveredEntry(
key="lost-device._wled._tcp.local.",
url="http://192.168.1.77",
name="Lost-WLED",
device_type="wled",
)
watcher._emit("device_lost", entry)
lost_records = [
e
for e in persisted
if e.category == ActivityCategory.DEVICE and e.action == "device.lost"
]
assert len(lost_records) == 1
assert lost_records[0].severity == ActivitySeverity.WARNING
# ---------------------------------------------------------------------------
# Category D: Capture & system events
# ---------------------------------------------------------------------------
class TestCaptureAndSystemInstrumentation:
"""Capture start and backup-create emit records."""
def test_capture_started_record(self):
"""capture.started record is emitted with correct category."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.routes.output_targets_control import _record_capture
_record_capture(
"capture.started",
"ot_test",
"My Test Strip",
"Capture started for target 'My Test Strip'",
)
assert len(persisted) == 1
r = persisted[0]
assert r.category == ActivityCategory.CAPTURE
assert r.action == "capture.started"
assert r.entity_id == "ot_test"
assert r.entity_name == "My Test Strip"
def test_capture_stopped_record(self):
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.routes.output_targets_control import _record_capture
_record_capture(
"capture.stopped",
"ot_test",
"Strip",
"Capture stopped for target 'Strip'",
)
assert len(persisted) == 1
assert persisted[0].action == "capture.stopped"
def test_backup_created_record(self):
"""backup.created system record emitted on backup download."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.routes.backup import _record_system
_record_system(
"backup.created",
"Backup downloaded: ledgrab-backup-20260101T000000.zip",
{"filename": "ledgrab-backup-20260101T000000.zip"},
)
assert len(persisted) == 1
r = persisted[0]
assert r.category == ActivityCategory.SYSTEM
assert r.action == "backup.created"
assert "backup" in r.message.lower()
def test_backup_restored_record(self):
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.routes.backup import _record_system
_record_system("backup.restored", "Database restored from uploaded backup")
assert len(persisted) == 1
assert persisted[0].action == "backup.restored"
def test_no_token_in_any_system_record(self):
"""System records must never include token-like secrets."""
recorder, persisted, _ = _make_recorder()
_SECRET = "my-api-token-12345" # noqa: S105
with _patch_module_recorder(recorder):
from ledgrab.api.routes.backup import _record_system
# Even if someone tried to pass a token (they shouldn't)
_record_system("backup.created", "Backup created")
for entry in persisted:
assert _SECRET not in entry.message
for v in entry.metadata.values():
assert _SECRET not in str(v)
def test_settings_changed_record(self):
"""shutdown_action settings change emits a system record."""
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.routes.system_settings import _record_setting
_record_setting(
"settings.changed",
"shutdown_action",
"Shutdown action set to 'nothing'",
)
assert len(persisted) == 1
r = persisted[0]
assert r.category == ActivityCategory.SYSTEM
assert r.action == "settings.changed"
assert r.metadata.get("setting_key") == "shutdown_action"
def test_settings_change_excludes_activity_log_key(self):
"""The 'activity_log' settings key must not self-referentially trigger records.
This is enforced by the caller checking the key before calling
_record_setting. Verify our helper does NOT filter automatically (the
responsibility is on the caller), but that the activity_log settings path
in the retention engine does not call record_setting.
"""
# Verify that _record_setting itself doesn't filter — that's not its job.
recorder, persisted, _ = _make_recorder()
with _patch_module_recorder(recorder):
from ledgrab.api.routes.system_settings import _record_setting
# The caller is responsible for not passing "activity_log"
# Calling it with any other key works fine:
_record_setting("settings.changed", "auto_backup", "Auto-backup enabled")
assert persisted[0].metadata["setting_key"] == "auto_backup"
File diff suppressed because it is too large Load Diff