Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b43f821046 | |||
| 077c99c7d1 | |||
| ae74cca132 | |||
| 77284e8e7b | |||
| ff1ff06cb5 | |||
| 3dd1ac3f0d | |||
| 6e1dd2111d | |||
| 9a0137fa4c | |||
| 4a0927521a | |||
| 25c613c5cb | |||
| 726f39e2ba | |||
| 1ac4a0f66d | |||
| 1afe7d6fcc | |||
| 17dd2e02ba | |||
| 7a12f39f49 | |||
| dd43f3836d | |||
| d32961085d | |||
| 6cd5e057da | |||
| 81b18089e1 | |||
| abc204c04e | |||
| 9550688c1e | |||
| 9dcd76d264 | |||
| 0409cd8b66 | |||
| 6180569b10 | |||
| f71e10ee06 | |||
| ca59546711 | |||
| 4a82595f26 |
@@ -19,6 +19,7 @@ semantic = true
|
|||||||
# Automatically run `vex update` before search if the index is stale
|
# Automatically run `vex update` before search if the index is stale
|
||||||
auto_update = true
|
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.
|
# Changing the embedder requires a full reindex.
|
||||||
# embedder = "minilm-l6-v2"
|
embedder = "jina-code"
|
||||||
|
|||||||
@@ -82,6 +82,19 @@ LedGrab speaks many protocols, so a single setup can drive everything from a DIY
|
|||||||
- Real-time FPS, latency, and uptime charts
|
- Real-time FPS, latency, and uptime charts
|
||||||
- Localized in English, Russian, and Chinese
|
- 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
|
### Home Assistant Integration
|
||||||
|
|
||||||
- HACS-compatible custom component (separate repository)
|
- HACS-compatible custom component (separate repository)
|
||||||
|
|||||||
+69
-23
@@ -1,54 +1,100 @@
|
|||||||
## v0.8.1 (2026-05-28)
|
## v0.8.2 (2026-06-08)
|
||||||
|
|
||||||
### User-facing changes
|
### User-facing changes
|
||||||
|
|
||||||
#### Features
|
#### Features
|
||||||
|
|
||||||
##### Multi-broker MQTT devices
|
##### WLED native realtime UDP output
|
||||||
|
- New realtime UDP sink speaking WLED's **DRGB / DRGBW / DNRGB** protocols, with automatic revert to the device's prior state when streaming stops ([7728aec](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7728aec))
|
||||||
|
|
||||||
- The device editor now shows an MQTT **broker picker** for `device_type=mqtt` (in both the add-device and device-settings modals), wired into load / save / validate / dirty-check / clone. An empty selection means "first available broker"
|
##### Automatic brightness limiting (ABL) / power budget
|
||||||
- `mqtt_source_id` is now threaded end-to-end through `DeviceCreate` / `DeviceUpdate` / `DeviceResponse` and the device routes; the referenced broker is validated on create **and** update ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- Per-LED power budgeting that caps total draw by scaling brightness to a configurable current/PSU limit, preventing brownouts on long strips ([ffee156](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ffee156))
|
||||||
|
|
||||||
##### Schema-driven wiring-graph editor
|
##### Scene playlists
|
||||||
|
- Scenes can be grouped into **playlists with timed auto-cycling**, so a target can rotate through looks on a schedule ([f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e))
|
||||||
|
- Playlist + cycling state is included in the aggregated `/snapshot` response ([abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c))
|
||||||
|
|
||||||
- The visual graph editor now renders ports and edges generically from a backend-served schema (`GET /api/v1/graph/schema`) instead of hard-coding the connectable-field topology in two places — so client and server can no longer drift
|
##### Auto edge-calibration + guided first-run setup wizard
|
||||||
- New `GET /api/v1/graph` returns the full nodes + edges + validation topology, and `GET /api/v1/graph/dependents/{kind}/{id}` reports what references an entity ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- Backend core for **automatic screen-edge calibration** ([0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8)), a one-call setup scaffold with an onboarding flag ([9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d)), and a browser-driven calibration UI ([9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688))
|
||||||
|
- A **guided first-run setup wizard** ties it together for new installs ([81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808)), with all-provider source discovery and a spatial corner picker ([dd43f38](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd43f38))
|
||||||
|
|
||||||
##### Aggregated snapshot endpoint
|
##### Region-of-interest (ROI) screen capture
|
||||||
|
- Screen sampling can now be cropped to a **region of interest** instead of the whole display ([ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546))
|
||||||
|
|
||||||
- New `GET /api/v1/snapshot` returns all output targets (with processing state + metrics), devices (with brightness), the source / preset / clock lists, and the system block in a **single response** — collapsing the Home Assistant integration's previous ~2N+M request fan-out into one round trip
|
##### Built-in "look" presets
|
||||||
- `?include=` fetches only a subset of sections, and an excluded section also skips its server-side work (e.g. cold-cache hardware brightness probes or the blocking NVML performance query) ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- One-click looks: **Cinematic / Vivid / Cozy / Soft / Cool** ([e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c))
|
||||||
|
|
||||||
|
##### Weekday + timezone scheduling
|
||||||
|
- The time-of-day automation rule now supports **weekday selection and explicit timezones** ([1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac))
|
||||||
|
|
||||||
|
##### Value sources
|
||||||
|
- New **sandboxed-Jinja template combinator** for composing value sources ([6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9)) and optional normalization for magnitude sources ([669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20))
|
||||||
|
|
||||||
|
##### Visual graph editor
|
||||||
|
- The editor is now a **full wiring control surface** ([2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46)), and you can **duplicate a selected subgraph** server-side ([15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82))
|
||||||
|
|
||||||
|
##### Android on-device capture
|
||||||
|
- **System audio playback capture** ([fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1)), **OS notification capture** via NotificationListenerService ([0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83)), **webcam capture** via Camera2 ([4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6)), and a **foreground-app automation condition** ([1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2))
|
||||||
|
|
||||||
#### Bug Fixes
|
#### Bug Fixes
|
||||||
|
- **Security:** removed an active **weak default API key** from the shipped config — fresh installs no longer ship with a guessable key. Set your own key on first run ([5686ae5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5686ae5))
|
||||||
- **Graceful shutdown no longer hangs:** uvicorn's graceful-shutdown wait is now bounded (`GRACEFUL_SHUTDOWN_TIMEOUT`, shared by the desktop, Android, and demo launchers). A lingering events WebSocket (which the browser auto-reconnects) used to keep connections from draining, so the lifespan shutdown never ran — leaving LED targets lit and blocking process exit. Ctrl+C / OS shutdown with the UI open now reliably stops targets and checkpoints the DB ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- Removed a broken legacy `/system/mqtt/settings` route ([fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201))
|
||||||
- **Device update error codes:** `update_device` no longer masks an intentional 4xx (e.g. an unknown `mqtt_source_id` or failed group validation) as a generic 500 ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- Scene brightness value-source changes now sync to the live processor immediately ([02e2ea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02e2ea3))
|
||||||
|
- Wizard hardening: scaffolded targets are registered with the ProcessorManager and the final review step is more robust ([6cd5e05](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6cd5e05))
|
||||||
|
- Installer opens the WebUI only once after "Launch LedGrab" ([05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Development / Internal
|
### Development / Internal
|
||||||
|
|
||||||
#### Backend
|
#### Backend / Storage
|
||||||
|
- `clone()` is now gated behind an **opt-in allowlist**, with expanded duplicate-handling tests ([498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f))
|
||||||
- **Wiring-graph schema engine** (`api/graph_schema.py`): a pure, unit-tested module that is the single source of truth for which reference fields connect which entity kinds; builds the topology and performs dependency lookup plus cycle / dangling-reference detection without booting the app or any store. The route layer only gathers serialized entities and delegates ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
|
||||||
- **Structured access log:** a new middleware emits one structured line per request, attributing it to the authenticated token's friendly label (the key name, **never** the secret) so traffic can be traced to a client (e.g. `homeassistant` vs `android`). uvicorn's own access log is disabled to avoid duplicate lines ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
|
||||||
- Shared `validate_mqtt_source_exists` (`_mqtt_validation.py`) deduplicates the MQTT-source existence check between the device and output-target routes ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
|
||||||
|
|
||||||
#### Frontend
|
#### Frontend
|
||||||
|
- In-progress dashboard customization groundwork ([6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569))
|
||||||
|
|
||||||
- Service-worker refresh for the new bundle ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
#### Docs
|
||||||
|
- Actualized README + API reference with embedded screenshots ([12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6)), graph-editor wiring-control roadmap ([d505388](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d505388)), Android audio-capture design notes ([4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc)); removed stale ANDROID-REVIEW planning docs ([9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15))
|
||||||
|
|
||||||
#### Tests
|
#### Tests
|
||||||
|
- Large new suites for calibration solver/session (incl. adversarial), setup & scene-playlist routes, playlist engine, and ROI capture. Full suite: **2149 passing, 2 skipped**
|
||||||
- New suites: graph routes + schema engine, snapshot routes, access-log middleware, `mqtt_source_id` device regressions, and the bounded-shutdown entrypoint. Full suite: **1614 passing** ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>All Commits (1)</summary>
|
<summary>All Commits (31)</summary>
|
||||||
|
|
||||||
| Hash | Message | Author |
|
| Hash | Message | Author |
|
||||||
| ---- | ------- | ------ |
|
| ---- | ------- | ------ |
|
||||||
| [a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba) | feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers | alexei.dolgolyov |
|
| [dd43f38](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd43f38) | fix(calibration-wizard): all-provider discovery + spatial corner picker | alexei.dolgolyov |
|
||||||
|
| [6cd5e05](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6cd5e05) | fix(setup): register scaffolded target with ProcessorManager + final-review hardening | alexei.dolgolyov |
|
||||||
|
| [81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808) | feat(onboarding): guided first-run setup wizard (phase 4, final) | alexei.dolgolyov |
|
||||||
|
| [abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c) | feat(snapshot): include scene playlists + cycling state in snapshot | alexei.dolgolyov |
|
||||||
|
| [9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688) | feat(calibration): browser-driven auto edge-calibration UI (phase 3) | alexei.dolgolyov |
|
||||||
|
| [9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d) | feat(setup): one-call setup scaffold + onboarding flag (phase 2) | alexei.dolgolyov |
|
||||||
|
| [0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8) | feat(calibration): auto edge-calibration backend core (phase 1) | alexei.dolgolyov |
|
||||||
|
| [6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569) | wip(dashboard): in-progress dashboard customization changes | alexei.dolgolyov |
|
||||||
|
| [f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e) | feat(scenes): scene playlists with timed auto-cycling | alexei.dolgolyov |
|
||||||
|
| [ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546) | feat(capture): region-of-interest (ROI) crop for screen sampling | alexei.dolgolyov |
|
||||||
|
| [1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac) | feat(automations): weekday + timezone scheduling for time-of-day rule | alexei.dolgolyov |
|
||||||
|
| [e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c) | feat(processing): built-in 'look' presets (Cinematic/Vivid/Cozy/Soft/Cool) | alexei.dolgolyov |
|
||||||
|
| [7728aec](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7728aec) | feat(wled): native realtime UDP output (DRGB/DRGBW/DNRGB) with auto-revert | alexei.dolgolyov |
|
||||||
|
| [ffee156](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ffee156) | feat(targets): automatic brightness limiting (ABL) / per-LED power budget | alexei.dolgolyov |
|
||||||
|
| [02e2ea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02e2ea3) | fix(scenes): sync brightness value-source change to live processor | alexei.dolgolyov |
|
||||||
|
| [fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201) | fix(api): remove broken legacy /system/mqtt/settings route | alexei.dolgolyov |
|
||||||
|
| [5686ae5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5686ae5) | fix(security): remove active weak default API key from shipped config | alexei.dolgolyov |
|
||||||
|
| [9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15) | docs(android): remove ANDROID-REVIEW planning/review docs | alexei.dolgolyov |
|
||||||
|
| [1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2) | feat(android): foreground-app automation condition | alexei.dolgolyov |
|
||||||
|
| [4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6) | feat(android): on-device webcam capture via Camera2 (AndroidCameraEngine) | alexei.dolgolyov |
|
||||||
|
| [0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83) | feat(android): on-device OS notification capture (NotificationListenerService) | alexei.dolgolyov |
|
||||||
|
| [4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc) | docs(android): add audio-capture design + missing-functionality review | alexei.dolgolyov |
|
||||||
|
| [fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1) | feat(audio): Android on-device system playback capture | alexei.dolgolyov |
|
||||||
|
| [669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20) | feat(value-sources): optional normalization for magnitude sources | alexei.dolgolyov |
|
||||||
|
| [6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9) | feat(value-sources): add sandboxed-Jinja template combinator | alexei.dolgolyov |
|
||||||
|
| [12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6) | docs: actualize README and API reference, embed screenshots | alexei.dolgolyov |
|
||||||
|
| [498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f) | refactor(storage): gate clone() behind an opt-in allowlist; expand duplicate tests | alexei.dolgolyov |
|
||||||
|
| [15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82) | feat(graph): duplicate a selected subgraph server-side | alexei.dolgolyov |
|
||||||
|
| [2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46) | feat(graph): make the visual editor a full wiring control surface | alexei.dolgolyov |
|
||||||
|
| [05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121) | fix(installer): open WebUI once after "Launch LedGrab" | alexei.dolgolyov |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ android {
|
|||||||
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
||||||
// sideload updates silently refused to install.
|
// sideload updates silently refused to install.
|
||||||
versionCode = ledgrabVersionCode
|
versionCode = ledgrabVersionCode
|
||||||
versionName = "0.8.1"
|
versionName = "0.8.2"
|
||||||
|
|
||||||
// ABI selection. Detect armeabi-v7a wheel presence and opt the
|
// ABI selection. Detect armeabi-v7a wheel presence and opt the
|
||||||
// ABI in only when the matching pydantic-core wheel is on disk —
|
// ABI in only when the matching pydantic-core wheel is on disk —
|
||||||
@@ -210,6 +210,10 @@ dependencies {
|
|||||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||||
// QR code generation for displaying server URL on TV
|
// QR code generation for displaying server URL on TV
|
||||||
implementation("com.google.zxing:core:3.5.3")
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
|
// EncryptedSharedPreferences (Android Keystore-backed) for the per-install
|
||||||
|
// server API key (see ApiKeyManager). Falls back to plain SharedPreferences
|
||||||
|
// when the keystore is unavailable.
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
|
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
|
||||||
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
|
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
|
||||||
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
|
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||||
android:usesPermissionFlags="neverForLocation"
|
android:usesPermissionFlags="neverForLocation"
|
||||||
tools:targetApi="s" />
|
tools:targetApi="s" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
|
||||||
|
tools:targetApi="s" />
|
||||||
|
|
||||||
<!-- BLE hardware — required=false so non-BT boxes still install. -->
|
<!-- BLE hardware — required=false so non-BT boxes still install. -->
|
||||||
<uses-feature
|
<uses-feature
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.ledgrab.android
|
package com.ledgrab.android
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,8 +26,23 @@ import java.security.SecureRandom
|
|||||||
*/
|
*/
|
||||||
class ApiKeyManager(context: Context) {
|
class ApiKeyManager(context: Context) {
|
||||||
|
|
||||||
private val prefs = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
|
// Prefer Android-Keystore-backed EncryptedSharedPreferences for the API
|
||||||
|
// key. If the keystore is unavailable (some OEM TV-box ROMs ship a broken
|
||||||
|
// or absent keystore, or a key got corrupted), creation throws — fall back
|
||||||
|
// to plain SharedPreferences so a keystore failure NEVER bricks the local
|
||||||
|
// API key (which would 401 every LAN client). [encrypted] records which
|
||||||
|
// path we took so we don't repeatedly attempt migration.
|
||||||
|
private val encrypted: Boolean
|
||||||
|
private val prefs: SharedPreferences
|
||||||
|
|
||||||
|
init {
|
||||||
|
val (store, isEncrypted) = buildPrefs(appContext)
|
||||||
|
prefs = store
|
||||||
|
encrypted = isEncrypted
|
||||||
|
if (isEncrypted) migrateLegacyKeyIfPresent()
|
||||||
|
}
|
||||||
|
|
||||||
// Once we've materialised a key in this process, cache it so
|
// Once we've materialised a key in this process, cache it so
|
||||||
// subsequent reads don't hit prefs and don't risk re-checking
|
// subsequent reads don't hit prefs and don't risk re-checking
|
||||||
@@ -60,6 +78,20 @@ class ApiKeyManager(context: Context) {
|
|||||||
cached = existing
|
cached = existing
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
// Before minting a fresh key, fall back to any key still in the
|
||||||
|
// legacy plain store (covers a failed/partial encrypted migration:
|
||||||
|
// commit() can return false WITHOUT throwing, so migration may have
|
||||||
|
// left the live key only in the legacy file). Rotating the
|
||||||
|
// per-install key would 401 every already-paired client, so we
|
||||||
|
// generate a brand-new key ONLY when no key exists anywhere.
|
||||||
|
recoverLegacyKey()?.let { recovered ->
|
||||||
|
// Best-effort persist into the encrypted store; cache regardless
|
||||||
|
// so we still return the recovered key if the write keeps failing.
|
||||||
|
runCatching { prefs.edit().putString(KEY_API_KEY, recovered).commit() }
|
||||||
|
cached = recovered
|
||||||
|
Log.i(TAG, "Recovered existing API key from legacy storage")
|
||||||
|
return recovered
|
||||||
|
}
|
||||||
val generated = generateKey()
|
val generated = generateKey()
|
||||||
// commit() (synchronous disk write) on the FIRST write so
|
// commit() (synchronous disk write) on the FIRST write so
|
||||||
// the key is durable before MainActivity encodes it into a
|
// the key is durable before MainActivity encodes it into a
|
||||||
@@ -74,6 +106,88 @@ class ApiKeyManager(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the backing store, preferring EncryptedSharedPreferences. Returns
|
||||||
|
* (store, isEncrypted). Any keystore failure falls back to the plain prefs
|
||||||
|
* file so the local API key is never lost on a broken-keystore device.
|
||||||
|
*/
|
||||||
|
private fun buildPrefs(context: Context): Pair<SharedPreferences, Boolean> {
|
||||||
|
return try {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
val store = EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
ENCRYPTED_PREFS_NAME,
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
store to true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Keystore unavailable/corrupt — degrade to plain prefs rather
|
||||||
|
// than crashing. Worst case the key is stored unencrypted on a
|
||||||
|
// single-user TV box, which is the pre-existing behaviour.
|
||||||
|
Log.w(TAG, "EncryptedSharedPreferences unavailable, using plain prefs: ${e.message}")
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration: if a key exists in the legacy plain-text prefs file
|
||||||
|
* (from before encrypted storage), copy it into the encrypted store and
|
||||||
|
* remove the plain copy. Preserves the existing key so already-scanned QR
|
||||||
|
* clients keep working — generating a fresh key here would silently 401
|
||||||
|
* every LAN client (see the Data Migration Policy in CLAUDE.md).
|
||||||
|
*/
|
||||||
|
private fun migrateLegacyKeyIfPresent() {
|
||||||
|
// Don't migrate if the encrypted store already holds a key.
|
||||||
|
if (!prefs.getString(KEY_API_KEY, null).isNullOrEmpty()) return
|
||||||
|
runCatching {
|
||||||
|
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val legacyKey = legacy.getString(KEY_API_KEY, null)
|
||||||
|
if (legacyKey != null && legacyKey.length >= MIN_KEY_LENGTH) {
|
||||||
|
// commit() returns false on write failure WITHOUT throwing, so the
|
||||||
|
// runCatching wrapper alone does NOT protect this path. Verify the
|
||||||
|
// encrypted store both committed AND reads back the identical value
|
||||||
|
// before touching the legacy copy — otherwise a silent write
|
||||||
|
// failure could delete the only surviving copy of the key and
|
||||||
|
// rotate it on next launch (401s every paired client — the exact
|
||||||
|
// silent-data-loss the Data Migration Policy forbids).
|
||||||
|
val ok = prefs.edit().putString(KEY_API_KEY, legacyKey).commit()
|
||||||
|
if (ok && prefs.getString(KEY_API_KEY, null) == legacyKey) {
|
||||||
|
// Keep the value as a .migrated backup (don't hard-delete) per
|
||||||
|
// the migration policy; remove only the live legacy key so the
|
||||||
|
// plaintext copy no longer answers reads.
|
||||||
|
legacy.edit()
|
||||||
|
.putString(KEY_API_KEY_MIGRATED, legacyKey)
|
||||||
|
.remove(KEY_API_KEY)
|
||||||
|
.apply()
|
||||||
|
Log.i(TAG, "Migrated API key from plain to encrypted storage")
|
||||||
|
} else {
|
||||||
|
// Leave the legacy key untouched; getOrCreateKey() will recover
|
||||||
|
// it via recoverLegacyKey() rather than minting a fresh one.
|
||||||
|
Log.w(TAG, "Encrypted key write unverified — keeping legacy key, not migrating")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { Log.w(TAG, "Legacy API key migration failed: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover a still-present key from the legacy plain store — either the live
|
||||||
|
* key (failed/never-run migration) or the `.migrated` backup. Returns null
|
||||||
|
* when on the plain-prefs path (no legacy/encrypted split) or no valid key
|
||||||
|
* survives. Guarantees [getOrCreateKey] never rotates an existing key as long
|
||||||
|
* as the legacy file survives.
|
||||||
|
*/
|
||||||
|
private fun recoverLegacyKey(): String? {
|
||||||
|
if (!encrypted) return null
|
||||||
|
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val candidate = legacy.getString(KEY_API_KEY, null)
|
||||||
|
?: legacy.getString(KEY_API_KEY_MIGRATED, null)
|
||||||
|
return candidate?.takeIf { it.length >= MIN_KEY_LENGTH }
|
||||||
|
}
|
||||||
|
|
||||||
private fun generateKey(): String {
|
private fun generateKey(): String {
|
||||||
val bytes = ByteArray(KEY_BYTES)
|
val bytes = ByteArray(KEY_BYTES)
|
||||||
SecureRandom().nextBytes(bytes)
|
SecureRandom().nextBytes(bytes)
|
||||||
@@ -88,7 +202,11 @@ class ApiKeyManager(context: Context) {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ApiKeyManager"
|
private const val TAG = "ApiKeyManager"
|
||||||
private const val PREFS_NAME = "ledgrab_auth"
|
private const val PREFS_NAME = "ledgrab_auth"
|
||||||
|
private const val ENCRYPTED_PREFS_NAME = "ledgrab_auth_enc"
|
||||||
private const val KEY_API_KEY = "api_key"
|
private const val KEY_API_KEY = "api_key"
|
||||||
|
// Backup of a migrated legacy key, kept in the plain store per the
|
||||||
|
// Data Migration Policy (never hard-delete user data on rename/move).
|
||||||
|
private const val KEY_API_KEY_MIGRATED = "api_key_migrated"
|
||||||
private const val KEY_BYTES = 32
|
private const val KEY_BYTES = 32
|
||||||
private const val MIN_KEY_LENGTH = 32
|
private const val MIN_KEY_LENGTH = 32
|
||||||
|
|
||||||
|
|||||||
@@ -103,12 +103,32 @@ object BleBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bleHandler.post { scanner.startScan(callback) }
|
// startScan runs on the BLE handler thread; a denied
|
||||||
|
// BLUETOOTH_SCAN throws SecurityException there, which would
|
||||||
|
// crash the whole process (an uncaught exception on a handler
|
||||||
|
// thread is fatal). Catch it inside the posted body and report.
|
||||||
|
bleHandler.post {
|
||||||
|
try {
|
||||||
|
scanner.startScan(callback)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "BLUETOOTH_SCAN permission denied — scan skipped", e)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "BLE startScan failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
Thread.sleep(timeoutMs)
|
Thread.sleep(timeoutMs)
|
||||||
} catch (_: InterruptedException) {
|
} catch (_: InterruptedException) {
|
||||||
Thread.currentThread().interrupt()
|
Thread.currentThread().interrupt()
|
||||||
} finally {
|
} finally {
|
||||||
try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {}
|
bleHandler.post {
|
||||||
|
try {
|
||||||
|
scanner.stopScan(callback)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "BLUETOOTH_SCAN permission denied — stopScan skipped", e)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "BLE stopScan failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return seen.values.toList()
|
return seen.values.toList()
|
||||||
}
|
}
|
||||||
@@ -136,7 +156,18 @@ object BleBridge {
|
|||||||
newState == BluetoothProfile.STATE_CONNECTED
|
newState == BluetoothProfile.STATE_CONNECTED
|
||||||
&& status == BluetoothGatt.GATT_SUCCESS -> {
|
&& status == BluetoothGatt.GATT_SUCCESS -> {
|
||||||
Log.d(TAG, "GATT connected to $address, discovering services")
|
Log.d(TAG, "GATT connected to $address, discovering services")
|
||||||
|
// Runs on the BLE handler thread; a denied
|
||||||
|
// BLUETOOTH_CONNECT throws SecurityException here, which
|
||||||
|
// would crash the process. Catch and fail the connect.
|
||||||
|
try {
|
||||||
gatt.discoverServices()
|
gatt.discoverServices()
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "BLUETOOTH_CONNECT denied during discoverServices", e)
|
||||||
|
readyDeferred.complete(false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "discoverServices failed: ${e.message}")
|
||||||
|
readyDeferred.complete(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
newState == BluetoothProfile.STATE_DISCONNECTED -> {
|
newState == BluetoothProfile.STATE_DISCONNECTED -> {
|
||||||
Log.w(TAG, "GATT disconnected from $address (status=$status)")
|
Log.w(TAG, "GATT disconnected from $address (status=$status)")
|
||||||
|
|||||||
@@ -105,12 +105,48 @@ class CaptureService : Service() {
|
|||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
||||||
|
|
||||||
// CRITICAL: startForeground must be called IMMEDIATELY — before
|
// CRITICAL (Android 14+): for the MediaProjection path, validate the
|
||||||
// any other work, especially before getMediaProjection(). The
|
// projection token BEFORE promoting to a foreground service with the
|
||||||
// service type must match the work; pass it explicitly via
|
// mediaProjection FGS type. On service recreation (system redelivery
|
||||||
// ServiceCompat so we stay compatible back to API 24.
|
// or a stale relaunch) the consent token is gone — promoting first and
|
||||||
|
// then discovering the dead token causes a spurious foreground-service
|
||||||
|
// start + immediate stop, which on strict OEMs flickers the
|
||||||
|
// notification or trips a stopSelf loop. Bail out cleanly here, before
|
||||||
|
// startForeground, when the MediaProjection consent data is missing.
|
||||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "—"
|
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "—"
|
||||||
val url = "http://$localIp:$SERVER_PORT"
|
val url = "http://$localIp:$SERVER_PORT"
|
||||||
|
|
||||||
|
val mediaProjectionResultData: Intent? =
|
||||||
|
if (!useRoot) extractProjectionResultData(intent) else null
|
||||||
|
if (!useRoot && (intent == null || mediaProjectionResultData == null)) {
|
||||||
|
// MediaProjection mode can't recover from a redelivery —
|
||||||
|
// the consent token in the original intent is single-use.
|
||||||
|
//
|
||||||
|
// We were launched via startForegroundService(), so the OS REQUIRES
|
||||||
|
// a startForeground() within ~5s even on this immediate-stop path,
|
||||||
|
// or it raises the fatal ForegroundServiceDidNotStartInTimeException.
|
||||||
|
// Promote with a benign SPECIAL_USE type (NOT mediaProjection — we
|
||||||
|
// have no valid consent token, and requesting that type without an
|
||||||
|
// active projection is exactly what we're avoiding) just long enough
|
||||||
|
// to satisfy the contract, then stop.
|
||||||
|
Log.w(TAG, "MediaProjection start without a valid consent token — stopping")
|
||||||
|
runCatching {
|
||||||
|
val bailType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(url), bailType)
|
||||||
|
}.onFailure { Log.w(TAG, "Bail-path startForeground failed: ${it.message}") }
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
// startForeground must be called IMMEDIATELY after the token check —
|
||||||
|
// before any heavier work like getMediaProjection(). The service type
|
||||||
|
// must match the work; pass it explicitly via ServiceCompat so we stay
|
||||||
|
// compatible back to API 24. The MEDIA_PROJECTION type is only used
|
||||||
|
// here once resultData is confirmed non-null (checked above).
|
||||||
try {
|
try {
|
||||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
var t = if (useRoot) {
|
var t = if (useRoot) {
|
||||||
@@ -152,20 +188,13 @@ class CaptureService : Service() {
|
|||||||
// otherwise `isRunning=true` sticks forever when startForeground throws.
|
// otherwise `isRunning=true` sticks forever when startForeground throws.
|
||||||
isRunning = true
|
isRunning = true
|
||||||
|
|
||||||
if (intent == null && !useRoot) {
|
|
||||||
// MediaProjection mode can't recover from a redelivery —
|
|
||||||
// the consent token in the original intent is single-use.
|
|
||||||
Log.w(TAG, "Service restarted without intent (MediaProjection mode) — stopping")
|
|
||||||
isRunning = false
|
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (useRoot) {
|
if (useRoot) {
|
||||||
startRootCapture(url)
|
startRootCapture(url)
|
||||||
} else {
|
} else {
|
||||||
startMediaProjectionCapture(intent!!, url)
|
// mediaProjectionResultData is guaranteed non-null here — the
|
||||||
|
// token was validated before startForeground above.
|
||||||
|
startMediaProjectionCapture(intent!!, mediaProjectionResultData!!, url)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to start capture", e)
|
Log.e(TAG, "Failed to start capture", e)
|
||||||
@@ -294,21 +323,25 @@ class CaptureService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startMediaProjectionCapture(intent: Intent, url: String) {
|
/**
|
||||||
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
* Extract the single-use MediaProjection consent token from the start
|
||||||
|
* intent, or null if the intent is missing/redelivered without it.
|
||||||
|
* Called BEFORE startForeground so the mediaProjection FGS type is only
|
||||||
|
* ever requested when a valid token is present (see onStartCommand).
|
||||||
|
*/
|
||||||
|
private fun extractProjectionResultData(intent: Intent?): Intent? {
|
||||||
|
if (intent == null) return null
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
|
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
|
||||||
} else {
|
} else {
|
||||||
intent.getParcelableExtra(EXTRA_RESULT_DATA)
|
intent.getParcelableExtra(EXTRA_RESULT_DATA)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resultData == null) {
|
|
||||||
Log.e(TAG, "No MediaProjection result data")
|
|
||||||
stopSelf()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startMediaProjectionCapture(intent: Intent, resultData: Intent, url: String) {
|
||||||
|
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||||
|
|
||||||
val projectionManager =
|
val projectionManager =
|
||||||
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||||
val projection = projectionManager.getMediaProjection(resultCode, resultData)
|
val projection = projectionManager.getMediaProjection(resultCode, resultData)
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import android.service.notification.StatusBarNotification
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.chaquo.python.Python
|
import com.chaquo.python.Python
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.RejectedExecutionException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captures posted OS notifications and forwards the posting app's display
|
* Captures posted OS notifications and forwards the posting app's display
|
||||||
@@ -25,7 +27,20 @@ class LedGrabNotificationListener : NotificationListenerService() {
|
|||||||
// Serial executor: the Python receiver does a (non-concurrency-safe) history
|
// Serial executor: the Python receiver does a (non-concurrency-safe) history
|
||||||
// disk write and may play a sound, so pushes must not overlap. Off the main
|
// disk write and may play a sound, so pushes must not overlap. Off the main
|
||||||
// looper to keep the system service responsive.
|
// looper to keep the system service responsive.
|
||||||
private val pushExecutor = Executors.newSingleThreadExecutor()
|
//
|
||||||
|
// Tied to the listener-connection lifecycle (onListenerConnected /
|
||||||
|
// onListenerDisconnected), NOT onDestroy: this is a system-rebindable
|
||||||
|
// service, so it can be connected/disconnected multiple times across a
|
||||||
|
// single onCreate..onDestroy span. Managing the executor here — combined
|
||||||
|
// with the runCatching guard at the submit site — keeps a notification
|
||||||
|
// that races teardown from triggering RejectedExecutionException on a
|
||||||
|
// shut-down executor. @Volatile so the connect/disconnect callbacks (which
|
||||||
|
// may run on a different thread than onNotificationPosted) publish safely.
|
||||||
|
@Volatile private var pushExecutor: ExecutorService? = null
|
||||||
|
|
||||||
|
// Guards executor creation so the lazy submit-site fallback and
|
||||||
|
// onListenerConnected can't race two executors into existence.
|
||||||
|
private val executorLock = Any()
|
||||||
|
|
||||||
// packageName -> resolved human-readable label. Matches the app_name the
|
// packageName -> resolved human-readable label. Matches the app_name the
|
||||||
// Windows/Linux backends pass, so per-app colors/filters keep working.
|
// Windows/Linux backends pass, so per-app colors/filters keep working.
|
||||||
@@ -51,7 +66,17 @@ class LedGrabNotificationListener : NotificationListenerService() {
|
|||||||
|
|
||||||
val label = resolveAppLabel(notification.packageName)
|
val label = resolveAppLabel(notification.packageName)
|
||||||
|
|
||||||
pushExecutor.execute {
|
// Obtain (creating if needed) the executor. onListenerConnected normally
|
||||||
|
// creates it, but that callback is not reliably invoked on every
|
||||||
|
// OEM/version (re)bind, and a notification can arrive before it fires —
|
||||||
|
// lazily creating here keeps a missing/late onListenerConnected from
|
||||||
|
// permanently disabling notification forwarding. A late submit onto an
|
||||||
|
// executor that onListenerDisconnected is shutting down throws
|
||||||
|
// RejectedExecutionException — guard with runCatching so a notification
|
||||||
|
// racing teardown can never crash this system-bound service.
|
||||||
|
val executor = ensureExecutor()
|
||||||
|
runCatching {
|
||||||
|
executor.execute {
|
||||||
try {
|
try {
|
||||||
Python.getInstance()
|
Python.getInstance()
|
||||||
.getModule(PY_MODULE)
|
.getModule(PY_MODULE)
|
||||||
@@ -64,29 +89,71 @@ class LedGrabNotificationListener : NotificationListenerService() {
|
|||||||
Log.d(TAG, "push_notification failed: ${t.message}")
|
Log.d(TAG, "push_notification failed: ${t.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
if (e is RejectedExecutionException) {
|
||||||
|
Log.d(TAG, "push rejected — listener disconnecting")
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve (and cache) a package's human-readable label; fall back to the package name. */
|
/** Resolve (and cache) a package's human-readable label; fall back to the package name. */
|
||||||
private fun resolveAppLabel(pkg: String): String {
|
private fun resolveAppLabel(pkg: String): String {
|
||||||
labelCache[pkg]?.let { return it }
|
labelCache[pkg]?.let { return it }
|
||||||
|
// Only cache SUCCESSFUL resolutions. Caching the package-name fallback
|
||||||
|
// would permanently pin a wrong label if the PackageManager lookup
|
||||||
|
// failed transiently (e.g. the app was mid-install / still updating).
|
||||||
val resolved = runCatching {
|
val resolved = runCatching {
|
||||||
val info = packageManager.getApplicationInfo(pkg, 0)
|
val info = packageManager.getApplicationInfo(pkg, 0)
|
||||||
packageManager.getApplicationLabel(info).toString()
|
packageManager.getApplicationLabel(info).toString()
|
||||||
}.getOrDefault(pkg)
|
}.getOrNull()
|
||||||
|
if (resolved != null) {
|
||||||
labelCache[pkg] = resolved
|
labelCache[pkg] = resolved
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the push executor, creating it under [executorLock] if absent.
|
||||||
|
* Safe against a concurrent onListenerConnected/onNotificationPosted race
|
||||||
|
* (single executor) and against a missing onListenerConnected callback.
|
||||||
|
*/
|
||||||
|
private fun ensureExecutor(): ExecutorService {
|
||||||
|
pushExecutor?.let { return it }
|
||||||
|
synchronized(executorLock) {
|
||||||
|
return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onListenerConnected() {
|
override fun onListenerConnected() {
|
||||||
Log.i(TAG, "Notification listener connected")
|
Log.i(TAG, "Notification listener connected")
|
||||||
|
// Spin up the push executor on connect. The system can disconnect and
|
||||||
|
// later reconnect this service without destroying it, so own the
|
||||||
|
// executor here rather than in onCreate/onDestroy. onNotificationPosted
|
||||||
|
// also lazily creates it (via ensureExecutor) in case this callback is
|
||||||
|
// late or skipped on some ROMs.
|
||||||
|
ensureExecutor()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onListenerDisconnected() {
|
override fun onListenerDisconnected() {
|
||||||
Log.i(TAG, "Notification listener disconnected")
|
Log.i(TAG, "Notification listener disconnected")
|
||||||
|
// Tear the executor down on disconnect; a fresh one is created on the
|
||||||
|
// next onListenerConnected. Null out first so any in-flight
|
||||||
|
// onNotificationPosted snapshots see null (skips submit) rather than
|
||||||
|
// racing a shutdown executor.
|
||||||
|
pushExecutor?.let { exec ->
|
||||||
|
pushExecutor = null
|
||||||
|
exec.shutdown()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
pushExecutor.shutdown()
|
// Defensive: onListenerDisconnected normally clears this first, but
|
||||||
|
// shut down here too in case onDestroy fires without a prior disconnect.
|
||||||
|
pushExecutor?.shutdown()
|
||||||
|
pushExecutor = null
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +57,7 @@ class MainActivity : Activity() {
|
|||||||
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
||||||
private const val REQUEST_RECORD_AUDIO = 1003
|
private const val REQUEST_RECORD_AUDIO = 1003
|
||||||
private const val REQUEST_CAMERA = 1004
|
private const val REQUEST_CAMERA = 1004
|
||||||
|
private const val REQUEST_BLUETOOTH = 1005
|
||||||
private const val QR_SIZE_PX = 560
|
private const val QR_SIZE_PX = 560
|
||||||
private const val NOTIF_PREFS = "ledgrab_notif"
|
private const val NOTIF_PREFS = "ledgrab_notif"
|
||||||
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
|
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
|
||||||
@@ -189,7 +191,13 @@ class MainActivity : Activity() {
|
|||||||
toggleButton.text = getString(R.string.btn_starting)
|
toggleButton.text = getString(R.string.btn_starting)
|
||||||
statusText.text = getString(R.string.status_checking_root)
|
statusText.text = getString(R.string.status_checking_root)
|
||||||
uiScope.launch(Dispatchers.IO) {
|
uiScope.launch(Dispatchers.IO) {
|
||||||
val rooted = Root.requestGrant()
|
// runInterruptible so a config change (rotation) during the
|
||||||
|
// up-to-10s `su` probe cancels the coroutine AND interrupts the
|
||||||
|
// blocking probe thread — Root.requestGrant honours the interrupt,
|
||||||
|
// destroys the su child, and rethrows, so we don't leak the
|
||||||
|
// process + drain thread. Without this, IO-dispatcher cancellation
|
||||||
|
// would not interrupt the blocking waitFor().
|
||||||
|
val rooted = runInterruptible { Root.requestGrant() }
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
toggleButton.isEnabled = true
|
toggleButton.isEnabled = true
|
||||||
toggleButton.text = originalText
|
toggleButton.text = originalText
|
||||||
@@ -214,6 +222,7 @@ class MainActivity : Activity() {
|
|||||||
ensureNotificationPermission()
|
ensureNotificationPermission()
|
||||||
ensureNotificationListenerAccess()
|
ensureNotificationListenerAccess()
|
||||||
ensureCameraPermission()
|
ensureCameraPermission()
|
||||||
|
ensureBluetoothPermissions()
|
||||||
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
|
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
|
||||||
updateUI()
|
updateUI()
|
||||||
}
|
}
|
||||||
@@ -236,6 +245,7 @@ class MainActivity : Activity() {
|
|||||||
ensureNotificationListenerAccess()
|
ensureNotificationListenerAccess()
|
||||||
ensureAudioPermission()
|
ensureAudioPermission()
|
||||||
ensureCameraPermission()
|
ensureCameraPermission()
|
||||||
|
ensureBluetoothPermissions()
|
||||||
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
||||||
ContextCompat.startForegroundService(this, intent)
|
ContextCompat.startForegroundService(this, intent)
|
||||||
updateUI()
|
updateUI()
|
||||||
@@ -536,6 +546,30 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request BLUETOOTH_SCAN + BLUETOOTH_CONNECT (API 31+) so the embedded
|
||||||
|
* server can discover and drive BLE LED controllers (SP110E / Triones /
|
||||||
|
* Zengge). On API < 31 these are install-time legacy permissions
|
||||||
|
* (BLUETOOTH / BLUETOOTH_ADMIN / ACCESS_FINE_LOCATION, maxSdk=30) and
|
||||||
|
* need no runtime grant — so this is a no-op there. Fire-and-forget,
|
||||||
|
* like [ensureAudioPermission]: screen capture works without BLE, and
|
||||||
|
* BleBridge degrades gracefully (empty scan / failed connect) when the
|
||||||
|
* grant is denied, so we don't block on the result. If first granted
|
||||||
|
* here, BLE devices become reachable on the next scan/connect.
|
||||||
|
*/
|
||||||
|
private fun ensureBluetoothPermissions() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||||
|
val needed = listOf(
|
||||||
|
Manifest.permission.BLUETOOTH_SCAN,
|
||||||
|
Manifest.permission.BLUETOOTH_CONNECT,
|
||||||
|
).filter {
|
||||||
|
checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (needed.isEmpty()) return
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
requestPermissions(needed.toTypedArray(), REQUEST_BLUETOOTH)
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether the user has granted notification-listener access to this app. */
|
/** Whether the user has granted notification-listener access to this app. */
|
||||||
private fun isNotificationAccessGranted(): Boolean =
|
private fun isNotificationAccessGranted(): Boolean =
|
||||||
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
|
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ import java.util.concurrent.TimeUnit
|
|||||||
object Root {
|
object Root {
|
||||||
private const val TAG = "Root"
|
private const val TAG = "Root"
|
||||||
|
|
||||||
|
// Slice length for the cancellation-aware su probe wait loop. Short
|
||||||
|
// enough that coroutine cancellation is honoured promptly, long enough
|
||||||
|
// to avoid busy-spinning while Magisk's grant dialog is up.
|
||||||
|
private const val POLL_SLICE_MS = 100L
|
||||||
|
|
||||||
private val SU_PATHS = listOf(
|
private val SU_PATHS = listOf(
|
||||||
"/system/bin/su",
|
"/system/bin/su",
|
||||||
"/system/xbin/su",
|
"/system/xbin/su",
|
||||||
@@ -49,17 +54,19 @@ object Root {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var process: Process? = null
|
||||||
val granted = try {
|
val granted = try {
|
||||||
// redirectErrorStream merges stderr into stdout so a single
|
// redirectErrorStream merges stderr into stdout so a single
|
||||||
// drain thread is enough — avoids the classic pipe-buffer
|
// drain thread is enough — avoids the classic pipe-buffer
|
||||||
// deadlock where waitFor() blocks because stderr filled up.
|
// deadlock where waitFor() blocks because stderr filled up.
|
||||||
val process = ProcessBuilder("su", "-c", "id")
|
val proc = ProcessBuilder("su", "-c", "id")
|
||||||
.redirectErrorStream(true)
|
.redirectErrorStream(true)
|
||||||
.start()
|
.start()
|
||||||
|
process = proc
|
||||||
val outputBuilder = StringBuilder()
|
val outputBuilder = StringBuilder()
|
||||||
val drain = Thread({
|
val drain = Thread({
|
||||||
try {
|
try {
|
||||||
BufferedReader(InputStreamReader(process.inputStream)).use { r ->
|
BufferedReader(InputStreamReader(proc.inputStream)).use { r ->
|
||||||
val buf = CharArray(512)
|
val buf = CharArray(512)
|
||||||
while (true) {
|
while (true) {
|
||||||
val n = r.read(buf)
|
val n = r.read(buf)
|
||||||
@@ -72,17 +79,35 @@ object Root {
|
|||||||
}
|
}
|
||||||
}, "Root-su-drain").apply { isDaemon = true; start() }
|
}, "Root-su-drain").apply { isDaemon = true; start() }
|
||||||
|
|
||||||
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
// Cancellation-aware wait: callers run this on a coroutine
|
||||||
|
// (MainActivity wraps it in runInterruptible), so a config change
|
||||||
|
// mid-probe cancels the coroutine and interrupts this thread.
|
||||||
|
// Poll waitFor() in short slices and honour interruption so we
|
||||||
|
// don't leak the `su` child + its drain thread for up to 10s.
|
||||||
|
// The catch(InterruptedException) below destroys the process; we
|
||||||
|
// re-arm the interrupt and rethrow so coroutine cancellation
|
||||||
|
// propagates cleanly.
|
||||||
|
val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
|
||||||
|
var finished = false
|
||||||
|
while (System.nanoTime() < deadlineNanos) {
|
||||||
|
if (proc.waitFor(POLL_SLICE_MS, TimeUnit.MILLISECONDS)) {
|
||||||
|
finished = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Throws InterruptedException if the thread was interrupted
|
||||||
|
// by coroutine cancellation — handled below to tear down.
|
||||||
|
if (Thread.interrupted()) throw InterruptedException("su probe cancelled")
|
||||||
|
}
|
||||||
if (!finished) {
|
if (!finished) {
|
||||||
process.destroyForcibly()
|
proc.destroyForcibly()
|
||||||
drain.join(500)
|
drain.join(500)
|
||||||
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
|
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
drain.join(500)
|
drain.join(500)
|
||||||
val output = synchronized(outputBuilder) { outputBuilder.toString() }
|
val output = synchronized(outputBuilder) { outputBuilder.toString() }
|
||||||
if (process.exitValue() != 0) {
|
if (proc.exitValue() != 0) {
|
||||||
Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'")
|
Log.w(TAG, "su -c id exited with ${proc.exitValue()} output='${output.trim()}'")
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
val rooted = output.contains("uid=0")
|
val rooted = output.contains("uid=0")
|
||||||
@@ -90,8 +115,17 @@ object Root {
|
|||||||
rooted
|
rooted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
// Coroutine cancelled mid-probe (e.g. config change). Kill the
|
||||||
|
// su child so it doesn't outlive the cancelled work, re-arm the
|
||||||
|
// interrupt flag, and rethrow so the coroutine cancels cleanly.
|
||||||
|
// Do NOT cache a result — the probe never completed.
|
||||||
|
runCatching { process?.destroyForcibly() }
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "su invocation failed: ${e.message}")
|
Log.w(TAG, "su invocation failed: ${e.message}")
|
||||||
|
runCatching { process?.destroyForcibly() }
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,14 +89,15 @@ class RootScreenrecord(
|
|||||||
running = true
|
running = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
imageReader = buildImageReader()
|
val reader = buildImageReader().also { imageReader = it }
|
||||||
decoder = buildDecoder(imageReader!!)
|
val codec = buildDecoder(reader).also { decoder = it }
|
||||||
process = spawnScreenrecord() ?: run {
|
val proc = spawnScreenrecord() ?: run {
|
||||||
stop()
|
stop()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
startInputPump(process!!.inputStream, decoder!!)
|
process = proc
|
||||||
startOutputDrain(decoder!!)
|
startInputPump(proc.inputStream, codec)
|
||||||
|
startOutputDrain(codec)
|
||||||
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
|
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
|
||||||
return true
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -178,6 +179,14 @@ class RootScreenrecord(
|
|||||||
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
|
||||||
|
// reader callback — no copy here). Safety depends on the Python
|
||||||
|
// receiver copying the bytes before this callback returns and
|
||||||
|
// overwrites the buffer for the next frame. It does:
|
||||||
|
// PythonBridge.pushRootFrame → root_screenrecord_engine.push_frame
|
||||||
|
// (server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py)
|
||||||
|
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
|
||||||
|
// pixels independently of this buffer. Do NOT remove that copy.
|
||||||
bridge.pushRootFrame(frameBuffer, width, height)
|
bridge.pushRootFrame(frameBuffer, width, height)
|
||||||
framesDeliveredCounter.incrementAndGet()
|
framesDeliveredCounter.incrementAndGet()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -147,6 +147,14 @@ class ScreenCapture(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
|
||||||
|
// capture handler — no copy here). Safety depends on the Python
|
||||||
|
// receiver copying the bytes before this callback returns and
|
||||||
|
// overwrites the buffer for the next frame. It does:
|
||||||
|
// PythonBridge.pushFrame → mediaprojection_engine.push_frame
|
||||||
|
// (server/src/ledgrab/core/capture_engines/mediaprojection_engine.py)
|
||||||
|
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
|
||||||
|
// pixels independently of this buffer. Do NOT remove that copy.
|
||||||
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
|
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
|
||||||
|
|
||||||
// Advance the pacing accumulator. If we fell badly behind
|
// Advance the pacing accumulator. If we fell badly behind
|
||||||
|
|||||||
+156
-2
@@ -42,6 +42,7 @@ Complete REST + WebSocket API reference for the LedGrab server.
|
|||||||
- [Weather sources](#weather-sources)
|
- [Weather sources](#weather-sources)
|
||||||
- [Automations](#automations)
|
- [Automations](#automations)
|
||||||
- [Scene presets](#scene-presets)
|
- [Scene presets](#scene-presets)
|
||||||
|
- [Scene playlists](#scene-playlists)
|
||||||
- [Sync clocks](#sync-clocks)
|
- [Sync clocks](#sync-clocks)
|
||||||
- [Webhooks](#webhooks)
|
- [Webhooks](#webhooks)
|
||||||
- [HTTP endpoints](#http-endpoints)
|
- [HTTP endpoints](#http-endpoints)
|
||||||
@@ -184,7 +185,7 @@ Server configuration: MQTT broker, external URL, shutdown action, log level, ADB
|
|||||||
|
|
||||||
## User preferences
|
## User preferences
|
||||||
|
|
||||||
Dashboard layout, notification settings, card display modes, and the global daylight timezone.
|
Dashboard layout, notification settings, card display modes, the global daylight timezone, and the first-run onboarding flag.
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | ---- | ----------- |
|
| ------ | ---- | ----------- |
|
||||||
@@ -198,6 +199,19 @@ Dashboard layout, notification settings, card display modes, and the global dayl
|
|||||||
| DELETE | `/api/v1/preferences/card-modes` | Delete card-mode preferences; revert to defaults. |
|
| DELETE | `/api/v1/preferences/card-modes` | Delete card-mode preferences; revert to defaults. |
|
||||||
| GET | `/api/v1/preferences/daylight-timezone` | Read the global IANA timezone for daylight cycles. |
|
| GET | `/api/v1/preferences/daylight-timezone` | Read the global IANA timezone for daylight cycles. |
|
||||||
| PUT | `/api/v1/preferences/daylight-timezone` | Persist the daylight-cycle timezone (empty = server local). |
|
| PUT | `/api/v1/preferences/daylight-timezone` | Persist the daylight-cycle timezone (empty = server local). |
|
||||||
|
| GET | `/api/v1/preferences/onboarding` | Read the first-run onboarding flag (`onboarded: bool`, `completed_at: str\|null`). Defaults to `false`. |
|
||||||
|
| PUT | `/api/v1/preferences/onboarding` | Persist the onboarding flag. Server auto-stamps `completed_at` when `onboarded` is set to `true` without a timestamp. |
|
||||||
|
|
||||||
|
**Onboarding flag response shape:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"onboarded": true,
|
||||||
|
"completed_at": "2026-06-08T12:00:00.000000+00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults to `{"onboarded": false, "completed_at": null}` when never set.
|
||||||
|
|
||||||
## Backup, restore & server control
|
## Backup, restore & server control
|
||||||
|
|
||||||
@@ -237,7 +251,7 @@ A single aggregated poll endpoint for low-overhead clients.
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | ---- | ----------- |
|
| ------ | ---- | ----------- |
|
||||||
| GET | `/api/v1/snapshot` | Full poll payload (targets, states, metrics, devices, brightness, color/value sources, scene presets, sync clocks, system) in one response. Use `?include=` to request a subset; per-section fault isolation. |
|
| GET | `/api/v1/snapshot` | Full poll payload (targets, states, metrics, devices, brightness, color/value sources, scene presets, scene playlists + cycling state, sync clocks, system) in one response. Use `?include=` to request a subset; per-section fault isolation. |
|
||||||
|
|
||||||
## Devices
|
## Devices
|
||||||
|
|
||||||
@@ -518,6 +532,25 @@ Captured snapshots of target state that can be restored.
|
|||||||
| POST | `/api/v1/scene-presets/{preset_id}/recapture` | Re-capture current state into the preset. |
|
| POST | `/api/v1/scene-presets/{preset_id}/recapture` | Re-capture current state into the preset. |
|
||||||
| POST | `/api/v1/scene-presets/{preset_id}/activate` | Activate the preset (restore captured state). |
|
| POST | `/api/v1/scene-presets/{preset_id}/activate` | Activate the preset (restore captured state). |
|
||||||
|
|
||||||
|
## Scene playlists
|
||||||
|
|
||||||
|
Ordered, timed sequences of scene presets that auto-cycle. The engine drives
|
||||||
|
**one** playlist at a time — starting a playlist stops any other. Each item
|
||||||
|
references a scene preset and holds it for its `duration_seconds` (min 1s)
|
||||||
|
before advancing; `loop` repeats from the start and `shuffle` randomises the
|
||||||
|
order each cycle.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ---- | ----------- |
|
||||||
|
| POST | `/api/v1/scene-playlists` | Create a playlist (items reference scene presets). |
|
||||||
|
| GET | `/api/v1/scene-playlists` | List all playlists plus the current cycling `state`. |
|
||||||
|
| GET | `/api/v1/scene-playlists/state` | Get the current cycling state (idle if nothing runs). |
|
||||||
|
| GET | `/api/v1/scene-playlists/{playlist_id}` | Get a playlist by ID. |
|
||||||
|
| PUT | `/api/v1/scene-playlists/{playlist_id}` | Update metadata, items, and `loop`/`shuffle`. |
|
||||||
|
| DELETE | `/api/v1/scene-playlists/{playlist_id}` | Delete a playlist (stops it first if running). |
|
||||||
|
| POST | `/api/v1/scene-playlists/{playlist_id}/start` | Start cycling (stops any other playlist first). |
|
||||||
|
| POST | `/api/v1/scene-playlists/stop` | Stop the active playlist (leaves the last scene applied). |
|
||||||
|
|
||||||
## Sync clocks
|
## Sync clocks
|
||||||
|
|
||||||
Shared clocks that drive linked animations with configurable speed.
|
Shared clocks that drive linked animations with configurable speed.
|
||||||
@@ -629,6 +662,127 @@ The wiring-graph: schema registry, topology, dependents, validation, and subgrap
|
|||||||
| POST | `/api/v1/graph/validate-connection` | Validate a proposed wiring edit (existence, kind, no cycle). |
|
| POST | `/api/v1/graph/validate-connection` | Validate a proposed wiring edit (existence, kind, no cycle). |
|
||||||
| POST | `/api/v1/graph/duplicate` | Deep-clone selected value/color-strip sources with remapped wiring. |
|
| POST | `/api/v1/graph/duplicate` | Deep-clone selected value/color-strip sources with remapped wiring. |
|
||||||
|
|
||||||
|
## Calibration
|
||||||
|
|
||||||
|
Guided LED chase and auto-solver for the `CalibrationConfig` stored on a
|
||||||
|
color-strip source. The flow is:
|
||||||
|
|
||||||
|
1. **Start** a session (`POST /session`) — stops any running target on the
|
||||||
|
device and remembers it for restore on stop.
|
||||||
|
2. **Position** the chase pixel (`POST /session/position`) to walk through
|
||||||
|
each physical corner and record the LED index.
|
||||||
|
3. **Solve** (`POST /solve`) — the server computes per-edge LED counts.
|
||||||
|
4. **Persist** — call `PUT /api/v1/color-strip-sources/{id}` with the solved
|
||||||
|
`calibration` object to save and hot-reload.
|
||||||
|
5. **Stop** (`POST /session/stop`) — clears the device and restores the prior
|
||||||
|
target.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ---- | ----------- |
|
||||||
|
| POST | `/api/v1/calibration/session` | Start a calibration session on a device (stops the running target, clears to black). |
|
||||||
|
| POST | `/api/v1/calibration/session/position` | Advance the chase pixel to LED `index` (± `window` dim neighbours). |
|
||||||
|
| POST | `/api/v1/calibration/session/stop` | End the session: clear to black and restore the prior target. |
|
||||||
|
| POST | `/api/v1/calibration/session/cancel` | Alias for stop — no calibration is applied. |
|
||||||
|
| GET | `/api/v1/calibration/session/state` | Current session state (active, device_id, led_count, last_activity). |
|
||||||
|
| POST | `/api/v1/calibration/solve` | Solve per-edge LED counts from 4 corner tap indices. Returns solved config dict (does NOT persist). |
|
||||||
|
|
||||||
|
**Session state** response shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"device_id": "dev_abc123",
|
||||||
|
"led_count": 100,
|
||||||
|
"prior_target_id": "ot_xyz456",
|
||||||
|
"last_activity": "2026-06-08T12:34:56.789Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solve request** (body):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_id": "dev_abc123",
|
||||||
|
"start_position": "bottom_left",
|
||||||
|
"layout": "clockwise",
|
||||||
|
"corner_indices": [0, 30, 60, 80],
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`corner_indices` must be exactly 4 integers, one per screen corner, in the
|
||||||
|
strip-walk order defined by `(start_position, layout)`. Provide either
|
||||||
|
`device_id` (preferred — server derives `led_count`) or `led_count` directly.
|
||||||
|
|
||||||
|
**Important session behavior:**
|
||||||
|
|
||||||
|
- **Stops the running output target** — starting a calibration session immediately
|
||||||
|
stops any output target currently running on that device. Other clients driving
|
||||||
|
that device will lose their output for the duration of the session.
|
||||||
|
- **Single session only** — only one calibration session runs at a time across the
|
||||||
|
whole server. Starting a new session automatically ends the previous one (clearing
|
||||||
|
and restoring its device first), regardless of which device each session is on.
|
||||||
|
- **Idle auto-end** — a session that receives no `position` calls for ~60 seconds is
|
||||||
|
automatically stopped and the prior target restored, so devices are never left dark
|
||||||
|
indefinitely.
|
||||||
|
|
||||||
|
**Idle timeout:** a session that receives no `position` calls for 60 seconds
|
||||||
|
is automatically stopped and the prior target restored.
|
||||||
|
|
||||||
|
## Setup scaffold
|
||||||
|
|
||||||
|
One-call first-run helper that creates the full capture-to-output chain and
|
||||||
|
returns all entity ids. The wizard calls this, then starts the output target
|
||||||
|
after optional calibration.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ---- | ----------- |
|
||||||
|
| POST | `/api/v1/setup/scaffold` | Create capture template + picture source + color-strip source + LED output target in one atomic call with rollback on partial failure. Does NOT auto-start the target. |
|
||||||
|
|
||||||
|
**Wizard sequence (Phase 4):**
|
||||||
|
|
||||||
|
1. Discover or create the device via `POST /api/v1/devices` (full URL
|
||||||
|
normalisation + provider validation runs there).
|
||||||
|
2. Call `POST /api/v1/setup/scaffold` with the resulting `device_id`.
|
||||||
|
3. Calibrate (Phase 1 endpoints).
|
||||||
|
4. Start the output target via `POST /api/v1/output-targets/{id}/start`.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_id": "device_abc123",
|
||||||
|
"display_index": 0,
|
||||||
|
"calibration": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`device_id` is **required** and must reference an existing device (created via
|
||||||
|
`POST /api/v1/devices`). `display_index` selects the monitor to capture
|
||||||
|
(0 = primary; range 0–63). `calibration` is an optional `CalibrationConfig`
|
||||||
|
dict; when omitted, `create_default_calibration(led_count)` is used.
|
||||||
|
|
||||||
|
**Response (201 Created):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_id": "device_abc123",
|
||||||
|
"capture_template_id": "tpl_11223344",
|
||||||
|
"picture_source_id": "ps_aabbccdd",
|
||||||
|
"color_strip_source_id": "css_11223344",
|
||||||
|
"output_target_id": "pt_aabbccdd",
|
||||||
|
"capture_template_reused": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`capture_template_reused` is `true` when an existing template matched the
|
||||||
|
platform engine (no new template was created).
|
||||||
|
|
||||||
|
**Rollback:** if any step fails, all entities created within the same call are
|
||||||
|
deleted in reverse order so no orphans remain. The pre-existing device and any
|
||||||
|
reused template are never deleted. Entity "created" events are emitted only
|
||||||
|
after the full chain succeeds, so a rollback never produces ghost UI cards.
|
||||||
|
|
||||||
## Web UI & PWA
|
## Web UI & PWA
|
||||||
|
|
||||||
App-level routes served by FastAPI (not under `/api/v1`).
|
App-level routes served by FastAPI (not under `/api/v1`).
|
||||||
|
|||||||
+42
-1
@@ -54,7 +54,48 @@ When you attach a device, a default calibration is created:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Custom Calibration
|
## Automatic Calibration
|
||||||
|
|
||||||
|
The easiest way to calibrate your strip is the **Auto-Calibrate** wizard, available directly
|
||||||
|
from the calibration modal. No LED counting required — just answer three questions and tap four
|
||||||
|
corners.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- A **Color Strip Source** (not a device-only target) associated with the strip.
|
||||||
|
- A **WLED device** connected and reachable by LedGrab.
|
||||||
|
|
||||||
|
### How to Start
|
||||||
|
|
||||||
|
1. Open the **Calibration** modal for your strip source (pencil icon → Calibration tab).
|
||||||
|
2. Click the **Auto-calibrate** button in the modal footer.
|
||||||
|
3. Follow the five-step wizard.
|
||||||
|
|
||||||
|
### Wizard Steps
|
||||||
|
|
||||||
|
| Step | What you do |
|
||||||
|
| ---- | ----------- |
|
||||||
|
| 1. Device | Select the WLED device that drives the strip. |
|
||||||
|
| 2. Start corner | LED #0 lights up on your device. Tap the corner where you see it. |
|
||||||
|
| 3. Direction | Sweep a few LEDs light up in sequence. Tap the direction they move. |
|
||||||
|
| 4. Mark corners | Use the step buttons to sweep to each remaining corner, then tap **Mark corner**. Repeat for all 4 corners. |
|
||||||
|
| 5. Preview & Save | Review the detected layout (start position, direction, LED counts per edge). Click **Save** to apply. |
|
||||||
|
|
||||||
|
### What Happens in the Background
|
||||||
|
|
||||||
|
- A calibration session takes exclusive control of the device for the duration of the wizard;
|
||||||
|
any previously running effect is paused and automatically restored when the wizard exits
|
||||||
|
(whether by saving, cancelling, or closing the modal).
|
||||||
|
- The solved `CalibrationConfig` is written directly to the Color Strip Source via the existing
|
||||||
|
PUT endpoint and takes effect immediately (no restart needed).
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- If LED #0 is hard to see, reduce ambient lighting briefly.
|
||||||
|
- The wizard works in the browser — desktop and Android TV app both supported.
|
||||||
|
- If you make a mistake in step 4, use **Step back** to re-mark the previous corner.
|
||||||
|
|
||||||
|
## Manual Calibration
|
||||||
|
|
||||||
### Step 1: Identify Your LED Layout
|
### Step 1: Identify Your LED Layout
|
||||||
|
|
||||||
|
|||||||
@@ -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 5–6).
|
||||||
|
|
||||||
|
## 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` (0–3650), `max_entries` (0–10_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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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=0–3650, max_entries=0–10_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.
|
||||||
@@ -17,7 +17,8 @@ auth:
|
|||||||
# with a secret you generated yourself (e.g. `openssl rand -hex 32`).
|
# with a secret you generated yourself (e.g. `openssl rand -hex 32`).
|
||||||
# Do NOT ship a hard-coded key here — a publicly-known token grants full
|
# Do NOT ship a hard-coded key here — a publicly-known token grants full
|
||||||
# LAN access to anyone on the network.
|
# LAN access to anyone on the network.
|
||||||
api_keys: {}
|
api_keys:
|
||||||
|
default: "development-key-change-in-production"
|
||||||
# api_keys:
|
# api_keys:
|
||||||
# my-client: "replace-with-output-of-openssl-rand-hex-32"
|
# my-client: "replace-with-output-of-openssl-rand-hex-32"
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ledgrab"
|
name = "ledgrab"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from .routes.audio_templates import router as audio_templates_router
|
|||||||
from .routes.value_sources import router as value_sources_router
|
from .routes.value_sources import router as value_sources_router
|
||||||
from .routes.automations import router as automations_router
|
from .routes.automations import router as automations_router
|
||||||
from .routes.scene_presets import router as scene_presets_router
|
from .routes.scene_presets import router as scene_presets_router
|
||||||
|
from .routes.scene_playlists import router as scene_playlists_router
|
||||||
from .routes.webhooks import router as webhooks_router
|
from .routes.webhooks import router as webhooks_router
|
||||||
from .routes.sync_clocks import router as sync_clocks_router
|
from .routes.sync_clocks import router as sync_clocks_router
|
||||||
from .routes.color_strip_processing import router as cspt_router
|
from .routes.color_strip_processing import router as cspt_router
|
||||||
@@ -35,6 +36,9 @@ from .routes.pattern_templates import router as pattern_templates_router
|
|||||||
from .routes.preferences import router as preferences_router
|
from .routes.preferences import router as preferences_router
|
||||||
from .routes.snapshot import router as snapshot_router
|
from .routes.snapshot import router as snapshot_router
|
||||||
from .routes.graph import router as graph_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 = APIRouter()
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
@@ -53,6 +57,7 @@ router.include_router(output_targets_router)
|
|||||||
router.include_router(output_targets_control_router)
|
router.include_router(output_targets_control_router)
|
||||||
router.include_router(automations_router)
|
router.include_router(automations_router)
|
||||||
router.include_router(scene_presets_router)
|
router.include_router(scene_presets_router)
|
||||||
|
router.include_router(scene_playlists_router)
|
||||||
router.include_router(webhooks_router)
|
router.include_router(webhooks_router)
|
||||||
router.include_router(sync_clocks_router)
|
router.include_router(sync_clocks_router)
|
||||||
router.include_router(cspt_router)
|
router.include_router(cspt_router)
|
||||||
@@ -70,5 +75,8 @@ router.include_router(pattern_templates_router)
|
|||||||
router.include_router(preferences_router)
|
router.include_router(preferences_router)
|
||||||
router.include_router(snapshot_router)
|
router.include_router(snapshot_router)
|
||||||
router.include_router(graph_router)
|
router.include_router(graph_router)
|
||||||
|
router.include_router(calibration_router)
|
||||||
|
router.include_router(setup_router)
|
||||||
|
router.include_router(activity_log_router)
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -3,18 +3,111 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, Security, status
|
from fastapi import Depends, HTTPException, Request, Security, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from ledgrab.config import get_config
|
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 import get_logger
|
||||||
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
|
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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 scheme for Bearer token
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
@@ -81,10 +174,12 @@ def verify_api_key(
|
|||||||
# No keys configured — allow loopback only.
|
# No keys configured — allow loopback only.
|
||||||
if _is_loopback(client_host):
|
if _is_loopback(client_host):
|
||||||
request.state.auth_label = "anonymous"
|
request.state.auth_label = "anonymous"
|
||||||
|
current_actor.set("anonymous")
|
||||||
return "anonymous"
|
return "anonymous"
|
||||||
# Allow caller to authenticate explicitly even without configured keys?
|
# Allow caller to authenticate explicitly even without configured keys?
|
||||||
# No — there are no keys to compare against. Reject.
|
# No — there are no keys to compare against. Reject.
|
||||||
logger.warning("Rejected LAN request from %s: no API key configured", client_host)
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail=(
|
detail=(
|
||||||
@@ -97,13 +192,14 @@ def verify_api_key(
|
|||||||
# Check if credentials are provided
|
# Check if credentials are provided
|
||||||
if not credentials:
|
if not credentials:
|
||||||
logger.warning("Request missing Authorization header")
|
logger.warning("Request missing Authorization header")
|
||||||
|
_record_auth_failure("missing Bearer token", client_host)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Missing API key - authentication is required",
|
detail="Missing API key - authentication is required",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract token
|
# Extract token — NEVER log or record the token value itself.
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
|
|
||||||
# Find matching key and return its label using constant-time comparison
|
# Find matching key and return its label using constant-time comparison
|
||||||
@@ -115,6 +211,7 @@ def verify_api_key(
|
|||||||
|
|
||||||
if not authenticated_as:
|
if not authenticated_as:
|
||||||
logger.warning("Invalid API key attempt")
|
logger.warning("Invalid API key attempt")
|
||||||
|
_record_auth_failure("invalid Bearer token", client_host)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid API key",
|
detail="Invalid API key",
|
||||||
@@ -127,6 +224,9 @@ def verify_api_key(
|
|||||||
# Stash the friendly label so the access-log middleware can attribute the
|
# Stash the friendly label so the access-log middleware can attribute the
|
||||||
# request to a client without re-running the token comparison.
|
# request to a client without re-running the token comparison.
|
||||||
request.state.auth_label = authenticated_as
|
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
|
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
|
# a strong signal even before the token check. Non-browser clients
|
||||||
# legitimately omit Origin; those fall through to the auth handshake.
|
# legitimately omit Origin; those fall through to the auth handshake.
|
||||||
config = get_config()
|
config = get_config()
|
||||||
|
client_host = websocket.client.host if websocket.client else None
|
||||||
origin = websocket.headers.get("origin")
|
origin = websocket.headers.get("origin")
|
||||||
if not _is_origin_allowed(origin, config.server.cors_origins):
|
if not _is_origin_allowed(origin, config.server.cors_origins):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Rejected WebSocket from origin %r (not in cors_origins)",
|
"Rejected WebSocket from origin %r (not in cors_origins)",
|
||||||
origin,
|
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:
|
try:
|
||||||
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
|
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
|
||||||
except _WS_SEND_BENIGN_EXC:
|
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:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
_record_ws_auth_success(label, client_host)
|
||||||
return label
|
return label
|
||||||
|
|
||||||
|
|
||||||
@@ -275,6 +394,7 @@ async def verify_ws_auth(
|
|||||||
return None
|
return None
|
||||||
return "anonymous"
|
return "anonymous"
|
||||||
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
|
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
|
||||||
|
_record_auth_failure("WebSocket auth timeout", client_host)
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
|
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
|
||||||
except _WS_SEND_BENIGN_EXC:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
@@ -332,6 +452,7 @@ async def verify_ws_auth(
|
|||||||
await websocket.send_json({"type": "auth_ok"})
|
await websocket.send_json({"type": "auth_ok"})
|
||||||
return "anonymous"
|
return "anonymous"
|
||||||
logger.warning("Rejected LAN WebSocket from %s: no API key configured", client_host)
|
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:
|
try:
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
@@ -343,10 +464,11 @@ async def verify_ws_auth(
|
|||||||
pass
|
pass
|
||||||
return None
|
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 "")
|
label = _match_api_key(token or "")
|
||||||
if not label:
|
if not label:
|
||||||
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
|
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
|
||||||
|
_record_auth_failure("invalid WebSocket token", client_host)
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
|
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
|
||||||
except _WS_SEND_BENIGN_EXC:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ledgrab.storage.audio_template_store import AudioTemplateStore
|
|||||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||||
from ledgrab.storage.automation_store import AutomationStore
|
from ledgrab.storage.automation_store import AutomationStore
|
||||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||||
|
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||||
from ledgrab.storage.sync_clock_store import SyncClockStore
|
from ledgrab.storage.sync_clock_store import SyncClockStore
|
||||||
from ledgrab.storage.color_strip_processing_template_store import (
|
from ledgrab.storage.color_strip_processing_template_store import (
|
||||||
ColorStripProcessingTemplateStore,
|
ColorStripProcessingTemplateStore,
|
||||||
@@ -27,6 +28,7 @@ from ledgrab.storage.gradient_store import GradientStore
|
|||||||
from ledgrab.storage.weather_source_store import WeatherSourceStore
|
from ledgrab.storage.weather_source_store import WeatherSourceStore
|
||||||
from ledgrab.storage.asset_store import AssetStore
|
from ledgrab.storage.asset_store import AssetStore
|
||||||
from ledgrab.core.automations.automation_engine import AutomationEngine
|
from ledgrab.core.automations.automation_engine import AutomationEngine
|
||||||
|
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
|
||||||
from ledgrab.core.weather.weather_manager import WeatherManager
|
from ledgrab.core.weather.weather_manager import WeatherManager
|
||||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
||||||
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
||||||
@@ -40,6 +42,11 @@ from ledgrab.core.mqtt.mqtt_manager import MQTTManager
|
|||||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||||
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
||||||
from ledgrab.storage.pattern_template_store import PatternTemplateStore
|
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")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@@ -110,6 +117,14 @@ def get_automation_engine() -> AutomationEngine:
|
|||||||
return _get("automation_engine", "Automation engine")
|
return _get("automation_engine", "Automation engine")
|
||||||
|
|
||||||
|
|
||||||
|
def get_scene_playlist_store() -> ScenePlaylistStore:
|
||||||
|
return _get("scene_playlist_store", "Scene playlist store")
|
||||||
|
|
||||||
|
|
||||||
|
def get_playlist_engine() -> PlaylistEngine:
|
||||||
|
return _get("playlist_engine", "Playlist engine")
|
||||||
|
|
||||||
|
|
||||||
def get_auto_backup_engine() -> AutoBackupEngine:
|
def get_auto_backup_engine() -> AutoBackupEngine:
|
||||||
return _get("auto_backup_engine", "Auto-backup engine")
|
return _get("auto_backup_engine", "Auto-backup engine")
|
||||||
|
|
||||||
@@ -186,16 +201,83 @@ def get_update_service() -> UpdateService:
|
|||||||
return _get("update_service", "Update service")
|
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 ────────────────────────────────────────────────────────
|
# ── Event helper ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
def _resolve_entity_name(entity_type: str, entity_id: str) -> str | None:
|
||||||
"""Fire an entity_changed event via the ProcessorManager event bus.
|
"""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:
|
Args:
|
||||||
entity_type: e.g. "device", "output_target", "color_strip_source"
|
entity_type: e.g. "device", "output_target", "color_strip_source"
|
||||||
action: "created", "updated", or "deleted"
|
action: "created", "updated", or "deleted"
|
||||||
entity_id: The entity's unique ID
|
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")
|
pm = _deps.get("processor_manager")
|
||||||
if pm is not None:
|
if pm is not None:
|
||||||
@@ -208,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 ──────────────────────────────────────────────────────
|
# ── Initialization ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -226,7 +340,9 @@ def init_dependencies(
|
|||||||
value_source_store: ValueSourceStore | None = None,
|
value_source_store: ValueSourceStore | None = None,
|
||||||
automation_store: AutomationStore | None = None,
|
automation_store: AutomationStore | None = None,
|
||||||
scene_preset_store: ScenePresetStore | None = None,
|
scene_preset_store: ScenePresetStore | None = None,
|
||||||
|
scene_playlist_store: ScenePlaylistStore | None = None,
|
||||||
automation_engine: AutomationEngine | None = None,
|
automation_engine: AutomationEngine | None = None,
|
||||||
|
playlist_engine: PlaylistEngine | None = None,
|
||||||
auto_backup_engine: AutoBackupEngine | None = None,
|
auto_backup_engine: AutoBackupEngine | None = None,
|
||||||
sync_clock_store: SyncClockStore | None = None,
|
sync_clock_store: SyncClockStore | None = None,
|
||||||
sync_clock_manager: SyncClockManager | None = None,
|
sync_clock_manager: SyncClockManager | None = None,
|
||||||
@@ -245,6 +361,9 @@ def init_dependencies(
|
|||||||
http_endpoint_store: HTTPEndpointStore | None = None,
|
http_endpoint_store: HTTPEndpointStore | None = None,
|
||||||
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
|
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
|
||||||
pattern_template_store: PatternTemplateStore | 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."""
|
"""Initialize global dependencies."""
|
||||||
_deps.update(
|
_deps.update(
|
||||||
@@ -262,7 +381,9 @@ def init_dependencies(
|
|||||||
"value_source_store": value_source_store,
|
"value_source_store": value_source_store,
|
||||||
"automation_store": automation_store,
|
"automation_store": automation_store,
|
||||||
"scene_preset_store": scene_preset_store,
|
"scene_preset_store": scene_preset_store,
|
||||||
|
"scene_playlist_store": scene_playlist_store,
|
||||||
"automation_engine": automation_engine,
|
"automation_engine": automation_engine,
|
||||||
|
"playlist_engine": playlist_engine,
|
||||||
"auto_backup_engine": auto_backup_engine,
|
"auto_backup_engine": auto_backup_engine,
|
||||||
"sync_clock_store": sync_clock_store,
|
"sync_clock_store": sync_clock_store,
|
||||||
"sync_clock_manager": sync_clock_manager,
|
"sync_clock_manager": sync_clock_manager,
|
||||||
@@ -281,5 +402,8 @@ def init_dependencies(
|
|||||||
"http_endpoint_store": http_endpoint_store,
|
"http_endpoint_store": http_endpoint_store,
|
||||||
"audio_processing_template_store": audio_processing_template_store,
|
"audio_processing_template_store": audio_processing_template_store,
|
||||||
"pattern_template_store": pattern_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),
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
):
|
):
|
||||||
"""Delete an audio source."""
|
"""Delete an audio source."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_source(source_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if any CSS entities reference this audio source
|
# Check if any CSS entities reference this audio source
|
||||||
from ledgrab.storage.color_strip_source import AudioColorStripSource
|
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}'")
|
raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'")
|
||||||
|
|
||||||
store.delete_source(source_id)
|
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:
|
except EntityNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -329,6 +329,12 @@ async def delete_automation(
|
|||||||
engine: AutomationEngine = Depends(get_automation_engine),
|
engine: AutomationEngine = Depends(get_automation_engine),
|
||||||
):
|
):
|
||||||
"""Delete an automation."""
|
"""Delete an automation."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_automation(automation_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Deactivate first
|
# Deactivate first
|
||||||
await engine.deactivate_if_active(automation_id)
|
await engine.deactivate_if_active(automation_id)
|
||||||
|
|
||||||
@@ -337,7 +343,7 @@ async def delete_automation(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(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 =====
|
# ===== Enable/Disable =====
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from ledgrab.api.schemas.system import (
|
|||||||
)
|
)
|
||||||
from ledgrab.config import get_config
|
from ledgrab.config import get_config
|
||||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
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.asset_store import AssetStore
|
||||||
from ledgrab.storage.database import Database, freeze_writes
|
from ledgrab.storage.database import Database, freeze_writes
|
||||||
from ledgrab.utils import get_logger, read_upload_capped
|
from ledgrab.utils import get_logger, read_upload_capped
|
||||||
@@ -35,6 +36,22 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
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]
|
_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")
|
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||||
filename = f"ledgrab-backup-{timestamp}.zip"
|
filename = f"ledgrab-backup-{timestamp}.zip"
|
||||||
|
|
||||||
|
_record_system("backup.created", f"Backup downloaded: {filename}", {"filename": filename})
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
zip_buffer,
|
zip_buffer,
|
||||||
media_type="application/zip",
|
media_type="application/zip",
|
||||||
@@ -243,6 +262,7 @@ async def restore_config(
|
|||||||
|
|
||||||
freeze_writes()
|
freeze_writes()
|
||||||
logger.info("Database restored from uploaded backup. Scheduling restart...")
|
logger.info("Database restored from uploaded backup. Scheduling restart...")
|
||||||
|
_record_system("backup.restored", "Database restored from uploaded backup")
|
||||||
_schedule_restart()
|
_schedule_restart()
|
||||||
|
|
||||||
return RestoreResponse(
|
return RestoreResponse(
|
||||||
@@ -257,6 +277,7 @@ def restart_server(_: AuthRequired):
|
|||||||
"""Schedule a server restart and return immediately."""
|
"""Schedule a server restart and return immediately."""
|
||||||
from ledgrab.server_ref import _broadcast_restarting
|
from ledgrab.server_ref import _broadcast_restarting
|
||||||
|
|
||||||
|
_record_system("server.restarting", "Server restart requested by user")
|
||||||
_broadcast_restarting()
|
_broadcast_restarting()
|
||||||
_schedule_restart()
|
_schedule_restart()
|
||||||
return {"status": "restarting"}
|
return {"status": "restarting"}
|
||||||
@@ -267,6 +288,7 @@ def shutdown_server(_: AuthRequired):
|
|||||||
"""Gracefully shut down the server."""
|
"""Gracefully shut down the server."""
|
||||||
from ledgrab.server_ref import request_shutdown
|
from ledgrab.server_ref import request_shutdown
|
||||||
|
|
||||||
|
_record_system("server.shutdown_requested", "Server shutdown requested by user")
|
||||||
request_shutdown()
|
request_shutdown()
|
||||||
return {"status": "shutting_down"}
|
return {"status": "shutting_down"}
|
||||||
|
|
||||||
@@ -300,11 +322,17 @@ async def update_auto_backup_settings(
|
|||||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||||
):
|
):
|
||||||
"""Update auto-backup settings (enable/disable, interval, max backups)."""
|
"""Update auto-backup settings (enable/disable, interval, max backups)."""
|
||||||
return await engine.update_settings(
|
result = await engine.update_settings(
|
||||||
enabled=body.enabled,
|
enabled=body.enabled,
|
||||||
interval_hours=body.interval_hours,
|
interval_hours=body.interval_hours,
|
||||||
max_backups=body.max_backups,
|
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"])
|
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
|
||||||
@@ -365,4 +393,5 @@ async def delete_saved_backup(
|
|||||||
engine.delete_backup(filename)
|
engine.delete_backup(filename)
|
||||||
except (ValueError, FileNotFoundError) as e:
|
except (ValueError, FileNotFoundError) as e:
|
||||||
raise HTTPException(status_code=404, detail=str(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}
|
return {"status": "deleted", "filename": filename}
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""Calibration session and solver API routes.
|
||||||
|
|
||||||
|
Endpoints
|
||||||
|
---------
|
||||||
|
POST /api/v1/calibration/session
|
||||||
|
Start a calibration session on a device (stops any running target on that
|
||||||
|
device and remembers it for restore on stop).
|
||||||
|
|
||||||
|
POST /api/v1/calibration/session/position
|
||||||
|
Advance the chase pixel to a specific LED index on the active device.
|
||||||
|
|
||||||
|
POST /api/v1/calibration/session/stop
|
||||||
|
End the session: clear the device to black and restore the prior target.
|
||||||
|
|
||||||
|
POST /api/v1/calibration/session/cancel
|
||||||
|
Alias for stop (does not apply any solved calibration).
|
||||||
|
|
||||||
|
GET /api/v1/calibration/session/state
|
||||||
|
Return the current session state (active, device, last_activity, …).
|
||||||
|
|
||||||
|
POST /api/v1/calibration/solve
|
||||||
|
Pure-logic: solve a CalibrationConfig from 4 corner tap indices.
|
||||||
|
Does NOT persist — the caller must follow up with
|
||||||
|
``PUT /api/v1/color-strip-sources/{id}`` to persist.
|
||||||
|
|
||||||
|
Persist path
|
||||||
|
------------
|
||||||
|
The existing ``PUT /api/v1/color-strip-sources/{id}`` already accepts a
|
||||||
|
``calibration`` field on ``PictureCSSUpdate`` / ``PictureAdvancedCSSUpdate``
|
||||||
|
and hot-reloads running streams automatically (see
|
||||||
|
``api/routes/color_strip_sources/crud.py``). There is NO duplicate endpoint
|
||||||
|
here. Phase 3 UI calls the existing PUT to persist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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,
|
||||||
|
CalibrationSessionStateResponse,
|
||||||
|
CalibrationSolveRequest,
|
||||||
|
CalibrationSolvedResponse,
|
||||||
|
)
|
||||||
|
from ledgrab.core.capture.calibration import solve_calibration
|
||||||
|
from ledgrab.core.capture.calibration_session import get_calibration_session
|
||||||
|
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session endpoints ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/session",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def start_calibration_session(
|
||||||
|
body: CalibrationSessionStartRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""Start a calibration session on a device.
|
||||||
|
|
||||||
|
Stops any target currently processing on that device (it will be restored
|
||||||
|
when the session ends). Only one session can be active at a time; starting
|
||||||
|
a new one terminates the previous one first.
|
||||||
|
"""
|
||||||
|
session = get_calibration_session()
|
||||||
|
try:
|
||||||
|
await session.start(body.device_id, manager)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
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())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/session/position",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def calibration_session_position(
|
||||||
|
body: CalibrationSessionPositionRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""Advance the chase pixel to a specific LED index on the active device.
|
||||||
|
|
||||||
|
``index`` must be 0-based and < ``led_count``. Returns 422 when out of
|
||||||
|
range (Pydantic ``ge=0``) or 400 if the session is not active / index
|
||||||
|
exceeds led_count.
|
||||||
|
"""
|
||||||
|
session = get_calibration_session()
|
||||||
|
try:
|
||||||
|
await session.position(body.index, body.window)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to set calibration pixel index=%d: %s", body.index, exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
return CalibrationSessionStateResponse(**session.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/session/stop",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def stop_calibration_session(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""End the calibration session.
|
||||||
|
|
||||||
|
Clears the device to black and restores the previously-running target (if
|
||||||
|
any). Safe to call even when no session is active (returns inactive state).
|
||||||
|
"""
|
||||||
|
session = get_calibration_session()
|
||||||
|
try:
|
||||||
|
await session.stop()
|
||||||
|
except Exception as exc:
|
||||||
|
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())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/session/cancel",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def cancel_calibration_session(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""Cancel the calibration session (alias for stop — no calibration is applied)."""
|
||||||
|
session = get_calibration_session()
|
||||||
|
try:
|
||||||
|
await session.cancel()
|
||||||
|
except Exception as exc:
|
||||||
|
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())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/calibration/session/state",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def get_calibration_session_state(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""Return the current calibration session state."""
|
||||||
|
return CalibrationSessionStateResponse(**get_calibration_session().get_state())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Solver endpoint ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/solve",
|
||||||
|
response_model=CalibrationSolvedResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def solve_calibration_endpoint(
|
||||||
|
body: CalibrationSolveRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
) -> CalibrationSolvedResponse:
|
||||||
|
"""Solve a CalibrationConfig from 4 corner tap indices.
|
||||||
|
|
||||||
|
Returns the computed per-edge LED counts. Does NOT persist — call
|
||||||
|
``PUT /api/v1/color-strip-sources/{id}`` with ``calibration`` in the body
|
||||||
|
to save.
|
||||||
|
|
||||||
|
Provide either *device_id* (preferred, server derives led_count) or
|
||||||
|
*led_count* directly. Returns 404 if *device_id* is not found, 422 on
|
||||||
|
invalid enum values, 400 on logical errors (e.g. corner_indices length).
|
||||||
|
"""
|
||||||
|
# Resolve led_count
|
||||||
|
led_count = body.led_count
|
||||||
|
if body.device_id is not None:
|
||||||
|
if body.device_id not in manager._devices:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Device {body.device_id!r} not found",
|
||||||
|
)
|
||||||
|
ds = manager._devices[body.device_id]
|
||||||
|
led_count = ds.led_count
|
||||||
|
|
||||||
|
if led_count is None or led_count <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="led_count must be a positive integer",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = solve_calibration(
|
||||||
|
led_count=led_count,
|
||||||
|
start_position=body.start_position,
|
||||||
|
layout=body.layout,
|
||||||
|
corner_indices=body.corner_indices,
|
||||||
|
offset=body.offset,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to solve calibration: %s", exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
return CalibrationSolvedResponse(
|
||||||
|
mode="simple",
|
||||||
|
layout=cfg.layout,
|
||||||
|
start_position=cfg.start_position,
|
||||||
|
leds_top=cfg.leds_top,
|
||||||
|
leds_right=cfg.leds_right,
|
||||||
|
leds_bottom=cfg.leds_bottom,
|
||||||
|
leds_left=cfg.leds_left,
|
||||||
|
offset=cfg.offset,
|
||||||
|
)
|
||||||
@@ -167,6 +167,12 @@ async def delete_color_strip_source(
|
|||||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
):
|
):
|
||||||
"""Delete a color strip source. Returns 409 if referenced by any LED target."""
|
"""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:
|
try:
|
||||||
target_names = target_store.get_targets_referencing_css(source_id)
|
target_names = target_store.get_targets_referencing_css(source_id)
|
||||||
if target_names:
|
if target_names:
|
||||||
@@ -201,7 +207,7 @@ async def delete_color_strip_source(
|
|||||||
"Delete or reassign the processed source(s) first.",
|
"Delete or reassign the processed source(s) first.",
|
||||||
)
|
)
|
||||||
store.delete_source(source_id)
|
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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -701,6 +701,13 @@ async def delete_device(
|
|||||||
):
|
):
|
||||||
"""Delete/detach a device. Returns 409 if referenced by a target."""
|
"""Delete/detach a device. Returns 409 if referenced by a target."""
|
||||||
try:
|
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
|
# Check if any target references this device
|
||||||
refs = target_store.get_targets_for_device(device_id)
|
refs = target_store.get_targets_for_device(device_id)
|
||||||
if refs:
|
if refs:
|
||||||
@@ -728,7 +735,7 @@ async def delete_device(
|
|||||||
# Delete from storage
|
# Delete from storage
|
||||||
store.delete_device(device_id)
|
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}")
|
logger.info(f"Deleted device {device_id}")
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ adapter metadata, and diagnostics.
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
@@ -57,6 +58,73 @@ _prev_states: dict[str, dict[str, Any]] = {}
|
|||||||
_integration_stats: dict[str, dict[str, Any]] = {}
|
_integration_stats: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Failed-auth rate limiter (brute-force defence on the ingest route) ─────
|
||||||
|
#
|
||||||
|
# The ingest route is high-frequency (games push at 16-64 Hz), so we do NOT
|
||||||
|
# rate-limit every event — that would throttle legitimate gameplay traffic.
|
||||||
|
# Instead we throttle only FAILED-auth attempts per source IP (the only thing
|
||||||
|
# an attacker without the token can produce). This mirrors the IP-based
|
||||||
|
# limiter in routes/webhooks.py (~30/min) but scopes it to failures so a
|
||||||
|
# brute-forcer is locked out after _AUTH_FAIL_LIMIT bad tokens per minute
|
||||||
|
# while authenticated high-rate ingestion is completely unaffected.
|
||||||
|
_AUTH_FAIL_LIMIT = 30
|
||||||
|
_AUTH_FAIL_WINDOW = 60.0 # seconds
|
||||||
|
_AUTH_FAIL_HITS_HARD_CAP = 1024
|
||||||
|
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||||
|
_auth_fail_hits: dict[str, list[float]] = defaultdict(list)
|
||||||
|
_auth_fail_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limit_key(request: Request) -> str:
|
||||||
|
"""Pick a stable client identifier for rate-limiting.
|
||||||
|
|
||||||
|
When the immediate peer is loopback (assumed reverse-proxy), use the
|
||||||
|
first ``X-Forwarded-For`` entry; otherwise use the peer's IP.
|
||||||
|
"""
|
||||||
|
peer = request.client.host if request.client else "unknown"
|
||||||
|
if peer in _LOOPBACK_HOSTS:
|
||||||
|
xff = request.headers.get("x-forwarded-for", "")
|
||||||
|
if xff:
|
||||||
|
return xff.split(",", 1)[0].strip() or peer
|
||||||
|
return peer
|
||||||
|
|
||||||
|
|
||||||
|
def _check_auth_fail_rate_limit(client_ip: str) -> None:
|
||||||
|
"""Raise 429 if *client_ip* exceeded the failed-auth attempt limit."""
|
||||||
|
now = time.time()
|
||||||
|
window_start = now - _AUTH_FAIL_WINDOW
|
||||||
|
with _auth_fail_lock:
|
||||||
|
timestamps = [t for t in _auth_fail_hits[client_ip] if t > window_start]
|
||||||
|
_auth_fail_hits[client_ip] = timestamps
|
||||||
|
if len(timestamps) >= _AUTH_FAIL_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many failed authentication attempts. Try again later.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_auth_failure(client_ip: str) -> None:
|
||||||
|
"""Record a failed-auth attempt for *client_ip* (bounded memory)."""
|
||||||
|
now = time.time()
|
||||||
|
window_start = now - _AUTH_FAIL_WINDOW
|
||||||
|
with _auth_fail_lock:
|
||||||
|
_auth_fail_hits[client_ip].append(now)
|
||||||
|
# Periodic cleanup of stale IPs to prevent unbounded growth.
|
||||||
|
if len(_auth_fail_hits) > 100:
|
||||||
|
stale = [ip for ip, ts in _auth_fail_hits.items() if not ts or ts[-1] < window_start]
|
||||||
|
for ip in stale:
|
||||||
|
del _auth_fail_hits[ip]
|
||||||
|
# Hard cap against an attacker spraying many distinct X-Forwarded-For
|
||||||
|
# values; drop the oldest-touched IPs.
|
||||||
|
if len(_auth_fail_hits) > _AUTH_FAIL_HITS_HARD_CAP:
|
||||||
|
ordered = sorted(
|
||||||
|
_auth_fail_hits.items(),
|
||||||
|
key=lambda kv: kv[1][-1] if kv[1] else 0.0,
|
||||||
|
)
|
||||||
|
for ip, _ in ordered[: len(ordered) - _AUTH_FAIL_HITS_HARD_CAP]:
|
||||||
|
_auth_fail_hits.pop(ip, None)
|
||||||
|
|
||||||
|
|
||||||
def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
|
def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
"""Convert a JSON Schema object into a flat list of field descriptors.
|
"""Convert a JSON Schema object into a flat list of field descriptors.
|
||||||
|
|
||||||
@@ -387,7 +455,16 @@ async def ingest_event(
|
|||||||
called before standard API auth.
|
called before standard API auth.
|
||||||
|
|
||||||
No AuthRequired dependency — adapter-level auth is used instead.
|
No AuthRequired dependency — adapter-level auth is used instead.
|
||||||
|
|
||||||
|
Rate limiting is scoped to FAILED-auth attempts per source IP (see
|
||||||
|
``_check_auth_fail_rate_limit``) so legitimate high-rate ingestion is
|
||||||
|
never throttled, but a brute-forcer is locked out after the threshold.
|
||||||
"""
|
"""
|
||||||
|
client_ip = _rate_limit_key(request)
|
||||||
|
# Block IPs that have already burned through the failed-auth budget,
|
||||||
|
# before doing any work (cheap brute-force lockout).
|
||||||
|
_check_auth_fail_rate_limit(client_ip)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = store.get_integration(integration_id)
|
config = store.get_integration(integration_id)
|
||||||
except EntityNotFoundError:
|
except EntityNotFoundError:
|
||||||
@@ -405,6 +482,7 @@ async def ingest_event(
|
|||||||
# Adapter-level auth check
|
# Adapter-level auth check
|
||||||
headers = dict(request.headers)
|
headers = dict(request.headers)
|
||||||
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
|
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
|
||||||
|
_record_auth_failure(client_ip)
|
||||||
raise HTTPException(status_code=403, detail="Adapter authentication failed")
|
raise HTTPException(status_code=403, detail="Adapter authentication failed")
|
||||||
|
|
||||||
# Parse payload through adapter
|
# Parse payload through adapter
|
||||||
|
|||||||
@@ -152,13 +152,19 @@ async def delete_gradient(
|
|||||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
):
|
):
|
||||||
"""Delete a gradient (fails if built-in or referenced by sources)."""
|
"""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:
|
try:
|
||||||
# Check references
|
# Check references
|
||||||
for source in css_store.get_all_sources():
|
for source in css_store.get_all_sources():
|
||||||
if getattr(source, "gradient_id", None) == gradient_id:
|
if getattr(source, "gradient_id", None) == gradient_id:
|
||||||
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
|
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
|
||||||
store.delete_gradient(gradient_id)
|
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:
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
status = 404 if "not found" in str(e).lower() else 400
|
status = 404 if "not found" in str(e).lower() else 400
|
||||||
raise HTTPException(status_code=status, detail=str(e))
|
raise HTTPException(status_code=status, detail=str(e))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ from ledgrab.storage.base_store import EntityNotFoundError
|
|||||||
from ledgrab.storage.home_assistant_source import HomeAssistantSource
|
from ledgrab.storage.home_assistant_source import HomeAssistantSource
|
||||||
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
from ledgrab.utils.net_classify import validate_lan_host
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -37,6 +39,23 @@ router = APIRouter()
|
|||||||
_REDACTED_TOKEN = "***"
|
_REDACTED_TOKEN = "***"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_ha_host(host: str | None) -> None:
|
||||||
|
"""Reject literal public/link-local/metadata IPs for a HA source host.
|
||||||
|
|
||||||
|
HA sources are LAN-by-design (loopback + private ranges allowed), so we
|
||||||
|
gate the user-supplied ``host`` with the same shared classifier the LED
|
||||||
|
device providers use (``validate_lan_host``). The HA host is stored as
|
||||||
|
``host:port`` (e.g. ``192.168.1.100:8123``), so strip the port first via
|
||||||
|
``urlparse`` — which also handles bracketed IPv6 literals. Hostnames /
|
||||||
|
mDNS labels pass through (classified UNPARSEABLE). Raises ``ValueError``
|
||||||
|
on a literal public IP, which the callers translate to HTTP 400.
|
||||||
|
"""
|
||||||
|
if not host:
|
||||||
|
return
|
||||||
|
bare_host = urlparse(f"//{host.strip()}").hostname or host.strip()
|
||||||
|
validate_lan_host(bare_host)
|
||||||
|
|
||||||
|
|
||||||
def _to_response(
|
def _to_response(
|
||||||
source: HomeAssistantSource,
|
source: HomeAssistantSource,
|
||||||
manager: HomeAssistantManager,
|
manager: HomeAssistantManager,
|
||||||
@@ -99,6 +118,7 @@ async def create_ha_source(
|
|||||||
manager: HomeAssistantManager = Depends(get_ha_manager),
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
_validate_ha_host(data.host)
|
||||||
source = store.create_source(
|
source = store.create_source(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
host=data.host,
|
host=data.host,
|
||||||
@@ -153,6 +173,7 @@ async def update_ha_source(
|
|||||||
manager: HomeAssistantManager = Depends(get_ha_manager),
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
_validate_ha_host(data.host)
|
||||||
source = store.update_source(
|
source = store.update_source(
|
||||||
source_id,
|
source_id,
|
||||||
name=data.name,
|
name=data.name,
|
||||||
|
|||||||
@@ -624,6 +624,13 @@ async def delete_target(
|
|||||||
):
|
):
|
||||||
"""Delete a output target. Stops processing first if active."""
|
"""Delete a output target. Stops processing first if active."""
|
||||||
try:
|
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
|
# Stop processing if running
|
||||||
try:
|
try:
|
||||||
await manager.stop_processing(target_id)
|
await manager.stop_processing(target_id)
|
||||||
@@ -641,7 +648,7 @@ async def delete_target(
|
|||||||
# Delete from store
|
# Delete from store
|
||||||
target_store.delete_target(target_id)
|
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}")
|
logger.info(f"Deleted target {target_id}")
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from ledgrab.api.dependencies import (
|
|||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
)
|
)
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
from ledgrab.api.schemas.output_targets import (
|
from ledgrab.api.schemas.output_targets import (
|
||||||
BulkTargetRequest,
|
BulkTargetRequest,
|
||||||
BulkTargetResponse,
|
BulkTargetResponse,
|
||||||
@@ -28,6 +29,7 @@ from ledgrab.storage.color_strip_source import (
|
|||||||
from ledgrab.storage.picture_source_store import PictureSourceStore
|
from ledgrab.storage.picture_source_store import PictureSourceStore
|
||||||
from ledgrab.storage.wled_output_target import WledOutputTarget
|
from ledgrab.storage.wled_output_target import WledOutputTarget
|
||||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -35,6 +37,23 @@ logger = get_logger(__name__)
|
|||||||
router = APIRouter()
|
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 =====
|
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
|
||||||
|
|
||||||
|
|
||||||
@@ -53,10 +72,18 @@ async def bulk_start_processing(
|
|||||||
|
|
||||||
for target_id in body.ids:
|
for target_id in body.ids:
|
||||||
try:
|
try:
|
||||||
target_store.get_target(target_id)
|
_tgt = target_store.get_target(target_id)
|
||||||
await manager.start_processing(target_id)
|
await manager.start_processing(target_id)
|
||||||
started.append(target_id)
|
started.append(target_id)
|
||||||
logger.info(f"Bulk start: started processing for target {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:
|
except ValueError as e:
|
||||||
errors[target_id] = str(e)
|
errors[target_id] = str(e)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
@@ -78,6 +105,7 @@ async def bulk_start_processing(
|
|||||||
async def bulk_stop_processing(
|
async def bulk_stop_processing(
|
||||||
body: BulkTargetRequest,
|
body: BulkTargetRequest,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
):
|
):
|
||||||
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
|
"""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)
|
await manager.stop_processing(target_id)
|
||||||
stopped.append(target_id)
|
stopped.append(target_id)
|
||||||
logger.info(f"Bulk stop: stopped processing for target {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:
|
except ValueError as e:
|
||||||
errors[target_id] = str(e)
|
errors[target_id] = str(e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -112,11 +152,19 @@ async def start_processing(
|
|||||||
logger.info("Start processing requested for target %s", target_id)
|
logger.info("Start processing requested for target %s", target_id)
|
||||||
try:
|
try:
|
||||||
# Verify target exists in store
|
# Verify target exists in store
|
||||||
target_store.get_target(target_id)
|
target = target_store.get_target(target_id)
|
||||||
|
|
||||||
await manager.start_processing(target_id)
|
await manager.start_processing(target_id)
|
||||||
|
|
||||||
logger.info(f"Started processing for target {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}
|
return {"status": "started", "target_id": target_id}
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -137,6 +185,7 @@ async def start_processing(
|
|||||||
async def stop_processing(
|
async def stop_processing(
|
||||||
target_id: str,
|
target_id: str,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
):
|
):
|
||||||
"""Stop processing for a output target."""
|
"""Stop processing for a output target."""
|
||||||
@@ -144,6 +193,18 @@ async def stop_processing(
|
|||||||
await manager.stop_processing(target_id)
|
await manager.stop_processing(target_id)
|
||||||
|
|
||||||
logger.info(f"Stopped processing for target {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}
|
return {"status": "stopped", "target_id": target_id}
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -374,6 +374,12 @@ async def delete_picture_source(
|
|||||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
):
|
):
|
||||||
"""Delete a picture source."""
|
"""Delete a picture source."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_stream(stream_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if any target transitively references this stream via a CSS
|
# Check if any target transitively references this stream via a CSS
|
||||||
target_names = store.get_targets_referencing(stream_id, target_store, css_store)
|
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.",
|
f"{css_names}. Please reassign or delete those first.",
|
||||||
)
|
)
|
||||||
store.delete_stream(stream_id)
|
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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ daylight value-source / color-strip-source. Stored as
|
|||||||
empty/missing meaning "use system local time".
|
empty/missing meaning "use system local time".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||||
@@ -38,6 +39,7 @@ router = APIRouter()
|
|||||||
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
||||||
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
||||||
_CARD_MODES_KEY = "card_modes"
|
_CARD_MODES_KEY = "card_modes"
|
||||||
|
_ONBOARDING_KEY = "onboarded"
|
||||||
|
|
||||||
|
|
||||||
class DaylightTimezonePreference(BaseModel):
|
class DaylightTimezonePreference(BaseModel):
|
||||||
@@ -285,4 +287,75 @@ async def put_daylight_timezone_preference(
|
|||||||
return DaylightTimezonePreference(timezone=saved)
|
return DaylightTimezonePreference(timezone=saved)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Onboarding flag
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingPreference(BaseModel):
|
||||||
|
"""Persistent first-run onboarding flag."""
|
||||||
|
|
||||||
|
onboarded: bool = Field(
|
||||||
|
False,
|
||||||
|
description="True once the user has completed the first-run wizard.",
|
||||||
|
)
|
||||||
|
completed_at: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="ISO timestamp of when onboarding was first marked complete; null otherwise.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/preferences/onboarding",
|
||||||
|
response_model=OnboardingPreference,
|
||||||
|
tags=["Preferences"],
|
||||||
|
)
|
||||||
|
async def get_onboarding(
|
||||||
|
_: AuthRequired,
|
||||||
|
db: Database = Depends(get_database),
|
||||||
|
) -> OnboardingPreference:
|
||||||
|
"""Return the first-run onboarding status.
|
||||||
|
|
||||||
|
Defaults to ``{onboarded: false, completed_at: null}`` when the flag has
|
||||||
|
never been set.
|
||||||
|
"""
|
||||||
|
raw = db.get_setting(_ONBOARDING_KEY)
|
||||||
|
if not raw:
|
||||||
|
return OnboardingPreference()
|
||||||
|
try:
|
||||||
|
return OnboardingPreference.model_validate(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Stored onboarding preference invalid (%s); using default", exc)
|
||||||
|
return OnboardingPreference()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/preferences/onboarding",
|
||||||
|
response_model=OnboardingPreference,
|
||||||
|
tags=["Preferences"],
|
||||||
|
)
|
||||||
|
async def put_onboarding(
|
||||||
|
_: AuthRequired,
|
||||||
|
body: OnboardingPreference,
|
||||||
|
db: Database = Depends(get_database),
|
||||||
|
) -> OnboardingPreference:
|
||||||
|
"""Persist the onboarding flag.
|
||||||
|
|
||||||
|
When ``onboarded`` is set to ``true`` and ``completed_at`` is not provided,
|
||||||
|
the server stamps the current UTC time automatically.
|
||||||
|
When ``onboarded`` is ``false``, ``completed_at`` is cleared.
|
||||||
|
"""
|
||||||
|
if body.onboarded and body.completed_at is None:
|
||||||
|
body = OnboardingPreference(
|
||||||
|
onboarded=True,
|
||||||
|
completed_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
elif not body.onboarded:
|
||||||
|
body = OnboardingPreference(onboarded=False, completed_at=None)
|
||||||
|
|
||||||
|
db.set_setting(_ONBOARDING_KEY, body.model_dump())
|
||||||
|
logger.info("Onboarding flag updated: onboarded=%s", body.onboarded)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"]
|
__all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"]
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""Scene playlist API routes — CRUD plus start/stop/state cycling control."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
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,
|
||||||
|
get_scene_playlist_store,
|
||||||
|
get_scene_preset_store,
|
||||||
|
)
|
||||||
|
from ledgrab.api.schemas.scene_playlists import (
|
||||||
|
PlaylistRuntimeStateSchema,
|
||||||
|
ScenePlaylistCreate,
|
||||||
|
ScenePlaylistListResponse,
|
||||||
|
ScenePlaylistResponse,
|
||||||
|
ScenePlaylistUpdate,
|
||||||
|
)
|
||||||
|
from ledgrab.core.scenes.playlist_engine import PlaylistEngine, PlaylistError
|
||||||
|
from ledgrab.storage.base_store import EntityNotFoundError
|
||||||
|
from ledgrab.storage.scene_playlist import PlaylistItem, ScenePlaylist
|
||||||
|
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||||
|
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _playlist_to_response(playlist: ScenePlaylist, engine: PlaylistEngine) -> ScenePlaylistResponse:
|
||||||
|
return ScenePlaylistResponse(
|
||||||
|
id=playlist.id,
|
||||||
|
name=playlist.name,
|
||||||
|
description=playlist.description,
|
||||||
|
items=[
|
||||||
|
{"scene_preset_id": i.scene_preset_id, "duration_seconds": i.duration_seconds}
|
||||||
|
for i in playlist.items
|
||||||
|
],
|
||||||
|
loop=playlist.loop,
|
||||||
|
shuffle=playlist.shuffle,
|
||||||
|
order=playlist.order,
|
||||||
|
tags=playlist.tags,
|
||||||
|
icon=getattr(playlist, "icon", "") or "",
|
||||||
|
icon_color=getattr(playlist, "icon_color", "") or "",
|
||||||
|
is_running=engine.get_running_playlist_id() == playlist.id,
|
||||||
|
created_at=playlist.created_at,
|
||||||
|
updated_at=playlist.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _items_from_schema(items) -> list[PlaylistItem]:
|
||||||
|
return [
|
||||||
|
PlaylistItem(scene_preset_id=i.scene_preset_id, duration_seconds=i.duration_seconds)
|
||||||
|
for i in items
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_preset_refs(items, preset_store: ScenePresetStore) -> None:
|
||||||
|
"""Reject playlist items that reference a non-existent scene preset."""
|
||||||
|
for item in items:
|
||||||
|
try:
|
||||||
|
preset_store.get_preset(item.scene_preset_id)
|
||||||
|
except (ValueError, EntityNotFoundError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Scene preset not found: {item.scene_preset_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== CRUD =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-playlists",
|
||||||
|
response_model=ScenePlaylistResponse,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def create_scene_playlist(
|
||||||
|
data: ScenePlaylistCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
preset_store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Create a new scene playlist."""
|
||||||
|
_validate_preset_refs(data.items, preset_store)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
playlist = ScenePlaylist(
|
||||||
|
id=f"playlist_{uuid.uuid4().hex[:8]}",
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
items=_items_from_schema(data.items),
|
||||||
|
loop=data.loop,
|
||||||
|
shuffle=data.shuffle,
|
||||||
|
order=store.count(),
|
||||||
|
tags=data.tags if data.tags is not None else [],
|
||||||
|
icon=data.icon or "",
|
||||||
|
icon_color=data.icon_color or "",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
playlist = store.create_playlist(playlist)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
fire_entity_event("scene_playlist", "created", playlist.id)
|
||||||
|
return _playlist_to_response(playlist, engine)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/scene-playlists",
|
||||||
|
response_model=ScenePlaylistListResponse,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def list_scene_playlists(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""List all scene playlists plus the current cycling state."""
|
||||||
|
playlists = store.get_all_playlists()
|
||||||
|
return ScenePlaylistListResponse(
|
||||||
|
playlists=[_playlist_to_response(p, engine) for p in playlists],
|
||||||
|
count=len(playlists),
|
||||||
|
state=PlaylistRuntimeStateSchema(**engine.get_state()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: the static ``/state`` path is declared before ``/{playlist_id}`` so it
|
||||||
|
# is matched first and not swallowed by the path parameter.
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/scene-playlists/state",
|
||||||
|
response_model=PlaylistRuntimeStateSchema,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def get_playlist_state(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Get the current playlist cycling state (idle if nothing is running)."""
|
||||||
|
return PlaylistRuntimeStateSchema(**engine.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/scene-playlists/{playlist_id}",
|
||||||
|
response_model=ScenePlaylistResponse,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def get_scene_playlist(
|
||||||
|
playlist_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Get a single scene playlist."""
|
||||||
|
try:
|
||||||
|
playlist = store.get_playlist(playlist_id)
|
||||||
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
return _playlist_to_response(playlist, engine)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/scene-playlists/{playlist_id}",
|
||||||
|
response_model=ScenePlaylistResponse,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def update_scene_playlist(
|
||||||
|
playlist_id: str,
|
||||||
|
data: ScenePlaylistUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
preset_store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Update a scene playlist's metadata, items, and playback flags."""
|
||||||
|
new_items = None
|
||||||
|
if data.items is not None:
|
||||||
|
_validate_preset_refs(data.items, preset_store)
|
||||||
|
new_items = _items_from_schema(data.items)
|
||||||
|
|
||||||
|
try:
|
||||||
|
playlist = store.update_playlist(
|
||||||
|
playlist_id,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
items=new_items,
|
||||||
|
loop=data.loop,
|
||||||
|
shuffle=data.shuffle,
|
||||||
|
order=data.order,
|
||||||
|
tags=data.tags,
|
||||||
|
icon=data.icon,
|
||||||
|
icon_color=data.icon_color,
|
||||||
|
)
|
||||||
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404 if "not found" in str(e).lower() else 400, detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
fire_entity_event("scene_playlist", "updated", playlist_id)
|
||||||
|
return _playlist_to_response(playlist, engine)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/api/v1/scene-playlists/{playlist_id}",
|
||||||
|
status_code=204,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def delete_scene_playlist(
|
||||||
|
playlist_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
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, entity_name=_entity_name)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Cycling control =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-playlists/{playlist_id}/start",
|
||||||
|
response_model=PlaylistRuntimeStateSchema,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def start_scene_playlist(
|
||||||
|
playlist_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Start cycling a playlist (stops any currently-running playlist first)."""
|
||||||
|
try:
|
||||||
|
store.get_playlist(playlist_id)
|
||||||
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await engine.start_playlist(playlist_id)
|
||||||
|
except PlaylistError as e:
|
||||||
|
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())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-playlists/stop",
|
||||||
|
response_model=PlaylistRuntimeStateSchema,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
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())
|
||||||
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from ledgrab.api.auth import AuthRequired
|
from ledgrab.api.auth import AuthRequired
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
from ledgrab.api.dependencies import (
|
from ledgrab.api.dependencies import (
|
||||||
fire_entity_event,
|
fire_entity_event,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
@@ -208,12 +209,18 @@ async def delete_scene_preset(
|
|||||||
store: ScenePresetStore = Depends(get_scene_preset_store),
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
):
|
):
|
||||||
"""Delete a scene preset."""
|
"""Delete a scene preset."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_preset(preset_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
store.delete_preset(preset_id)
|
store.delete_preset(preset_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(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 =====
|
# ===== Recapture =====
|
||||||
@@ -282,4 +289,21 @@ async def activate_scene_preset(
|
|||||||
logger.info(f"Scene preset '{preset.name}' activated successfully")
|
logger.info(f"Scene preset '{preset.name}' activated successfully")
|
||||||
|
|
||||||
fire_entity_event("scene_preset", "updated", preset_id)
|
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)
|
return ActivateResponse(status=status, errors=errors)
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""Setup scaffold endpoint.
|
||||||
|
|
||||||
|
Wires a complete capture → color-strip → output chain in one call, with
|
||||||
|
automatic rollback if any step fails so no orphan entities are left behind.
|
||||||
|
|
||||||
|
POST /api/v1/setup/scaffold
|
||||||
|
Body: ScaffoldRequest — device_id (required, must already exist),
|
||||||
|
display_index, optional calibration dict.
|
||||||
|
Returns: ScaffoldResponse — ids of every created/reused entity.
|
||||||
|
Fires ``entity_changed`` events for every entity created in this call,
|
||||||
|
but ONLY after the full chain succeeds (no mid-chain events).
|
||||||
|
Does NOT auto-start the target (the frontend starts it after calibration).
|
||||||
|
|
||||||
|
Rollback contract
|
||||||
|
-----------------
|
||||||
|
Entities created during THIS request are tracked in a local list. If any
|
||||||
|
step raises, they are deleted in reverse-creation order before re-raising.
|
||||||
|
Because "created" events are deferred until after the chain completes, a
|
||||||
|
rollback never produces ghost UI cards — no event for a rolled-back entity
|
||||||
|
is ever emitted.
|
||||||
|
|
||||||
|
The device is never part of the rollback set: scaffold requires an existing
|
||||||
|
device (created via ``POST /api/v1/devices`` which runs full validation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from ledgrab.api.auth import AuthRequired
|
||||||
|
from ledgrab.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
|
get_color_strip_store,
|
||||||
|
get_device_store,
|
||||||
|
get_output_target_store,
|
||||||
|
get_picture_source_store,
|
||||||
|
get_processor_manager,
|
||||||
|
get_template_store,
|
||||||
|
)
|
||||||
|
from ledgrab.api.schemas.setup import ScaffoldRequest, ScaffoldResponse
|
||||||
|
from ledgrab.core.capture.calibration import calibration_from_dict, create_default_calibration
|
||||||
|
from ledgrab.core.capture_engines.factory import EngineRegistry
|
||||||
|
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||||
|
from ledgrab.storage.base_store import EntityNotFoundError
|
||||||
|
from ledgrab.storage.color_strip_store import ColorStripStore
|
||||||
|
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||||
|
from ledgrab.storage.picture_source_store import PictureSourceStore
|
||||||
|
from ledgrab.storage import DeviceStore
|
||||||
|
from ledgrab.storage.template_store import TemplateStore
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_DEFAULT_TARGET_FPS = 30
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: capture template
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_capture_template(
|
||||||
|
template_store: TemplateStore,
|
||||||
|
created_ids: list[tuple[str, str]],
|
||||||
|
) -> tuple[str, bool]:
|
||||||
|
"""Return (template_id, reused).
|
||||||
|
|
||||||
|
Tries to find an existing template whose engine_type matches the platform's
|
||||||
|
best available engine. Falls back to creating a fresh one.
|
||||||
|
"""
|
||||||
|
best_engine = EngineRegistry.get_best_available_engine()
|
||||||
|
if not best_engine:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="No capture engine available on this platform; cannot scaffold.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to reuse an existing template with the same engine
|
||||||
|
for tpl in template_store.get_all_templates():
|
||||||
|
if tpl.engine_type == best_engine:
|
||||||
|
logger.info(
|
||||||
|
"Scaffold: reusing existing capture template %s (engine=%s)",
|
||||||
|
tpl.id,
|
||||||
|
best_engine,
|
||||||
|
)
|
||||||
|
return tpl.id, True
|
||||||
|
|
||||||
|
# None found — create a fresh one
|
||||||
|
engine_class = EngineRegistry.get_engine(best_engine)
|
||||||
|
default_config = engine_class.get_default_config()
|
||||||
|
try:
|
||||||
|
tpl = template_store.create_template(
|
||||||
|
name=f"Scaffold capture ({best_engine})",
|
||||||
|
engine_type=best_engine,
|
||||||
|
engine_config=default_config,
|
||||||
|
description="Auto-created by first-run scaffold",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
created_ids.append(("capture_template", tpl.id))
|
||||||
|
logger.info("Scaffold: created capture template %s (engine=%s)", tpl.id, best_engine)
|
||||||
|
return tpl.id, False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: rollback
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _rollback(
|
||||||
|
created_ids: list[tuple[str, str]],
|
||||||
|
*,
|
||||||
|
template_store: TemplateStore,
|
||||||
|
picture_source_store: PictureSourceStore,
|
||||||
|
css_store: ColorStripStore,
|
||||||
|
output_target_store: OutputTargetStore,
|
||||||
|
manager: ProcessorManager | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Delete entities created during this call, in reverse order.
|
||||||
|
|
||||||
|
Only entities listed in ``created_ids`` are deleted; reused/pre-existing
|
||||||
|
entities (including the device) are never touched.
|
||||||
|
|
||||||
|
If *manager* is provided, any ``output_target`` entity in the rollback set
|
||||||
|
is also unregistered from the ProcessorManager before store deletion, so no
|
||||||
|
half-registered target is left behind.
|
||||||
|
"""
|
||||||
|
store_map: dict[str, Any] = {
|
||||||
|
"capture_template": template_store,
|
||||||
|
"picture_source": picture_source_store,
|
||||||
|
"color_strip_source": css_store,
|
||||||
|
"output_target": output_target_store,
|
||||||
|
}
|
||||||
|
for entity_type, entity_id in reversed(created_ids):
|
||||||
|
# Unregister output targets from the processor manager first
|
||||||
|
if entity_type == "output_target" and manager is not None:
|
||||||
|
try:
|
||||||
|
manager.remove_target(entity_id)
|
||||||
|
logger.info("Scaffold rollback: unregistered target %s from manager", entity_id)
|
||||||
|
except (ValueError, RuntimeError) as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Scaffold rollback: manager unregister skipped for %s — %s",
|
||||||
|
entity_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
store = store_map.get(entity_type)
|
||||||
|
if store is None:
|
||||||
|
logger.warning("Scaffold rollback: unknown entity type %r — skipping", entity_type)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
store.delete(entity_id)
|
||||||
|
logger.info("Scaffold rollback: deleted %s %s", entity_type, entity_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Scaffold rollback: failed to delete %s %s — %s",
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Route
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/setup/scaffold",
|
||||||
|
response_model=ScaffoldResponse,
|
||||||
|
status_code=201,
|
||||||
|
tags=["Setup"],
|
||||||
|
)
|
||||||
|
async def scaffold_setup(
|
||||||
|
data: ScaffoldRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
device_store: DeviceStore = Depends(get_device_store),
|
||||||
|
template_store: TemplateStore = Depends(get_template_store),
|
||||||
|
picture_source_store: PictureSourceStore = Depends(get_picture_source_store),
|
||||||
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
|
output_target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
) -> ScaffoldResponse:
|
||||||
|
"""Create a ready-to-start LED capture chain.
|
||||||
|
|
||||||
|
Steps (each uses the real store create method for validation and ID gen):
|
||||||
|
|
||||||
|
1. Look up the existing device (404 if not found).
|
||||||
|
2. Find or create a capture template for the platform-best engine.
|
||||||
|
3. Create a raw picture source (``display_index`` + ``capture_template_id``).
|
||||||
|
4. Create a picture color-strip source with either the provided calibration
|
||||||
|
or ``create_default_calibration(led_count)``.
|
||||||
|
5. Create a LED output target linking the device to the CSS.
|
||||||
|
|
||||||
|
All created entities emit ``entity_changed`` events, but ONLY after the
|
||||||
|
full chain succeeds — events are collected and fired at the very end.
|
||||||
|
On any error the entities created so far are deleted in reverse order
|
||||||
|
(rollback), and no "created" events are emitted (no ghost UI cards).
|
||||||
|
The output target is NOT started — the frontend starts it after the
|
||||||
|
optional calibration step.
|
||||||
|
"""
|
||||||
|
created_ids: list[tuple[str, str]] = []
|
||||||
|
# Deferred "created" events: (entity_type, entity_id) — fired only on success.
|
||||||
|
pending_events: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
rollback_stores = dict(
|
||||||
|
template_store=template_store,
|
||||||
|
picture_source_store=picture_source_store,
|
||||||
|
css_store=css_store,
|
||||||
|
output_target_store=output_target_store,
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ── Step 1: resolve existing device ─────────────────────────────────
|
||||||
|
try:
|
||||||
|
device = device_store.get(data.device_id)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Device not found: {data.device_id}")
|
||||||
|
device_id = device.id
|
||||||
|
led_count = device.led_count
|
||||||
|
|
||||||
|
# ── Step 2: capture template ─────────────────────────────────────────
|
||||||
|
capture_template_id, template_reused = _get_or_create_capture_template(
|
||||||
|
template_store, created_ids
|
||||||
|
)
|
||||||
|
if not template_reused:
|
||||||
|
pending_events.append(("capture_template", capture_template_id))
|
||||||
|
|
||||||
|
# ── Step 3: picture source ───────────────────────────────────────────
|
||||||
|
ps_name = f"Screen {data.display_index} (scaffold)"
|
||||||
|
try:
|
||||||
|
picture_source = picture_source_store.create_stream(
|
||||||
|
name=ps_name,
|
||||||
|
stream_type="raw",
|
||||||
|
display_index=data.display_index,
|
||||||
|
capture_template_id=capture_template_id,
|
||||||
|
target_fps=_DEFAULT_TARGET_FPS,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
created_ids.append(("picture_source", picture_source.id))
|
||||||
|
pending_events.append(("picture_source", picture_source.id))
|
||||||
|
logger.info("Scaffold: created picture source %s", picture_source.id)
|
||||||
|
|
||||||
|
# ── Step 4: color-strip source ───────────────────────────────────────
|
||||||
|
if data.calibration is not None:
|
||||||
|
try:
|
||||||
|
calibration = calibration_from_dict(data.calibration)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Invalid calibration dict: {exc}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
calibration = create_default_calibration(led_count)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
css_name = "Screen capture (scaffold)"
|
||||||
|
try:
|
||||||
|
css = css_store.create_source(
|
||||||
|
name=css_name,
|
||||||
|
source_type="picture",
|
||||||
|
picture_source_id=picture_source.id,
|
||||||
|
calibration=calibration,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
created_ids.append(("color_strip_source", css.id))
|
||||||
|
pending_events.append(("color_strip_source", css.id))
|
||||||
|
logger.info("Scaffold: created color-strip source %s", css.id)
|
||||||
|
|
||||||
|
# ── Step 5: LED output target ────────────────────────────────────────
|
||||||
|
target_name = "LED output (scaffold)"
|
||||||
|
try:
|
||||||
|
target = output_target_store.create_wled_target(
|
||||||
|
name=target_name,
|
||||||
|
device_id=device_id,
|
||||||
|
color_strip_source_id=css.id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
created_ids.append(("output_target", target.id))
|
||||||
|
pending_events.append(("output_target", target.id))
|
||||||
|
logger.info("Scaffold: created output target %s", target.id)
|
||||||
|
|
||||||
|
# ── Step 5b: register target with ProcessorManager ───────────────────
|
||||||
|
try:
|
||||||
|
target.register_with_manager(manager)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Scaffold: could not register target %s in processor manager: %s",
|
||||||
|
target.id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
_rollback(created_ids, **rollback_stores)
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Scaffold: unexpected error — rolling back: %s", exc, exc_info=True)
|
||||||
|
_rollback(created_ids, **rollback_stores)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error during scaffold")
|
||||||
|
|
||||||
|
# ── Full chain succeeded — fire all deferred "created" events ───────────
|
||||||
|
for entity_type, entity_id in pending_events:
|
||||||
|
fire_entity_event(entity_type, "created", entity_id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Scaffold complete: device=%s tpl=%s ps=%s css=%s target=%s",
|
||||||
|
device_id,
|
||||||
|
capture_template_id,
|
||||||
|
picture_source.id,
|
||||||
|
css.id,
|
||||||
|
target.id,
|
||||||
|
)
|
||||||
|
return ScaffoldResponse(
|
||||||
|
device_id=device_id,
|
||||||
|
capture_template_id=capture_template_id,
|
||||||
|
picture_source_id=picture_source.id,
|
||||||
|
color_strip_source_id=css.id,
|
||||||
|
output_target_id=target.id,
|
||||||
|
capture_template_reused=template_reused,
|
||||||
|
)
|
||||||
@@ -30,7 +30,9 @@ from ledgrab.api.dependencies import (
|
|||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
get_device_store,
|
get_device_store,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
|
get_playlist_engine,
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
|
get_scene_playlist_store,
|
||||||
get_scene_preset_store,
|
get_scene_preset_store,
|
||||||
get_sync_clock_manager,
|
get_sync_clock_manager,
|
||||||
get_sync_clock_store,
|
get_sync_clock_store,
|
||||||
@@ -43,6 +45,7 @@ from ledgrab.utils import get_logger
|
|||||||
from .color_strip_sources.crud import list_color_strip_sources
|
from .color_strip_sources.crud import list_color_strip_sources
|
||||||
from .devices import list_devices, resolve_device_brightness
|
from .devices import list_devices, resolve_device_brightness
|
||||||
from .output_targets import batch_target_metrics, batch_target_states, list_targets
|
from .output_targets import batch_target_metrics, batch_target_states, list_targets
|
||||||
|
from .scene_playlists import list_scene_playlists
|
||||||
from .scene_presets import list_scene_presets
|
from .scene_presets import list_scene_presets
|
||||||
from .sync_clocks import list_sync_clocks
|
from .sync_clocks import list_sync_clocks
|
||||||
from .system import get_system_performance, health_check
|
from .system import get_system_performance, health_check
|
||||||
@@ -53,7 +56,9 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Selectable snapshot sections — these are exactly the response top-level keys.
|
# Selectable snapshot sections — these are exactly the response top-level keys,
|
||||||
|
# except ``scene_playlists`` which also emits a companion ``playlist_state`` key
|
||||||
|
# (the single global cycling state; see the handler).
|
||||||
SNAPSHOT_SECTIONS = (
|
SNAPSHOT_SECTIONS = (
|
||||||
"targets",
|
"targets",
|
||||||
"target_states",
|
"target_states",
|
||||||
@@ -63,6 +68,7 @@ SNAPSHOT_SECTIONS = (
|
|||||||
"css_sources",
|
"css_sources",
|
||||||
"value_sources",
|
"value_sources",
|
||||||
"scene_presets",
|
"scene_presets",
|
||||||
|
"scene_playlists",
|
||||||
"sync_clocks",
|
"sync_clocks",
|
||||||
"system",
|
"system",
|
||||||
)
|
)
|
||||||
@@ -135,6 +141,8 @@ async def get_snapshot(
|
|||||||
css_store=Depends(get_color_strip_store),
|
css_store=Depends(get_color_strip_store),
|
||||||
value_store=Depends(get_value_source_store),
|
value_store=Depends(get_value_source_store),
|
||||||
preset_store=Depends(get_scene_preset_store),
|
preset_store=Depends(get_scene_preset_store),
|
||||||
|
playlist_store=Depends(get_scene_playlist_store),
|
||||||
|
playlist_engine=Depends(get_playlist_engine),
|
||||||
clock_store=Depends(get_sync_clock_store),
|
clock_store=Depends(get_sync_clock_store),
|
||||||
clock_manager=Depends(get_sync_clock_manager),
|
clock_manager=Depends(get_sync_clock_manager),
|
||||||
update_service=Depends(get_update_service),
|
update_service=Depends(get_update_service),
|
||||||
@@ -152,6 +160,8 @@ async def get_snapshot(
|
|||||||
"css_sources": [...],
|
"css_sources": [...],
|
||||||
"value_sources": [...],
|
"value_sources": [...],
|
||||||
"scene_presets": [...],
|
"scene_presets": [...],
|
||||||
|
"scene_playlists": [...],
|
||||||
|
"playlist_state": {...}, # companion to scene_playlists
|
||||||
"sync_clocks": [...],
|
"sync_clocks": [...],
|
||||||
"system": {"performance": {...}, "health": {...}, "update": {...}}
|
"system": {"performance": {...}, "health": {...}, "update": {...}}
|
||||||
}
|
}
|
||||||
@@ -184,6 +194,14 @@ async def get_snapshot(
|
|||||||
result["value_sources"] = (await list_value_sources(_auth, None, value_store)).sources
|
result["value_sources"] = (await list_value_sources(_auth, None, value_store)).sources
|
||||||
if "scene_presets" in sections:
|
if "scene_presets" in sections:
|
||||||
result["scene_presets"] = (await list_scene_presets(_auth, preset_store)).presets
|
result["scene_presets"] = (await list_scene_presets(_auth, preset_store)).presets
|
||||||
|
if "scene_playlists" in sections:
|
||||||
|
# One call returns both the playlist list (each with ``is_running``) and
|
||||||
|
# the single global cycling state (current index / preset / dwell). The
|
||||||
|
# state is emitted as a companion top-level key because it describes the
|
||||||
|
# one running playlist, not any individual list entry.
|
||||||
|
playlists = await list_scene_playlists(_auth, playlist_store, playlist_engine)
|
||||||
|
result["scene_playlists"] = playlists.playlists
|
||||||
|
result["playlist_state"] = playlists.state
|
||||||
if "sync_clocks" in sections:
|
if "sync_clocks" in sections:
|
||||||
clocks = await list_sync_clocks(_auth, clock_store, clock_manager)
|
clocks = await list_sync_clocks(_auth, clock_store, clock_manager)
|
||||||
result["sync_clocks"] = clocks.clocks
|
result["sync_clocks"] = clocks.clocks
|
||||||
|
|||||||
@@ -149,6 +149,12 @@ async def delete_sync_clock(
|
|||||||
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
||||||
):
|
):
|
||||||
"""Delete a synchronization clock (fails if referenced by CSS or value sources)."""
|
"""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:
|
try:
|
||||||
# Check references
|
# Check references
|
||||||
for source in css_store.get_all_sources():
|
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}'")
|
raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'")
|
||||||
manager.release_all_for(clock_id)
|
manager.release_all_for(clock_id)
|
||||||
store.delete_clock(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:
|
except EntityNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,29 @@ from ledgrab.api.schemas.system import (
|
|||||||
ShutdownActionRequest,
|
ShutdownActionRequest,
|
||||||
ShutdownActionResponse,
|
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.storage.database import Database
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -117,6 +135,11 @@ async def update_shutdown_action(
|
|||||||
"""Set what happens to LED targets when the server shuts down."""
|
"""Set what happens to LED targets when the server shuts down."""
|
||||||
db.set_setting("shutdown_action", {"action": body.action})
|
db.set_setting("shutdown_action", {"action": body.action})
|
||||||
logger.info("Shutdown action updated: %s", 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)
|
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)
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||||
output = (stdout.decode() + stderr.decode()).strip()
|
output = (stdout.decode() + stderr.decode()).strip()
|
||||||
if "connected" in output.lower():
|
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}
|
return {"status": "connected", "address": address, "message": output}
|
||||||
raise HTTPException(status_code=400, detail=output or "Connection failed")
|
raise HTTPException(status_code=400, detail=output or "Connection failed")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -276,6 +310,17 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
|
|||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
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()}
|
return {"status": "disconnected", "message": stdout.decode().strip()}
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=500, detail="adb not found on PATH")
|
raise HTTPException(status_code=500, detail="adb not found on PATH")
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ async def delete_template(
|
|||||||
|
|
||||||
Validates that no streams are currently using this template before deletion.
|
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:
|
try:
|
||||||
# Check if any streams are using this template
|
# Check if any streams are using this template
|
||||||
streams_using_template = []
|
streams_using_template = []
|
||||||
@@ -203,7 +209,7 @@ async def delete_template(
|
|||||||
|
|
||||||
# Proceed with deletion
|
# Proceed with deletion
|
||||||
template_store.delete_template(template_id)
|
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:
|
except HTTPException:
|
||||||
raise # Re-raise HTTP exceptions as-is
|
raise # Re-raise HTTP exceptions as-is
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from ledgrab.api.schemas.update import (
|
|||||||
UpdateStatusResponse,
|
UpdateStatusResponse,
|
||||||
)
|
)
|
||||||
from ledgrab.core.update.update_service import UpdateService
|
from ledgrab.core.update.update_service import UpdateService
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -42,6 +43,17 @@ async def dismiss_update(
|
|||||||
service: UpdateService = Depends(get_update_service),
|
service: UpdateService = Depends(get_update_service),
|
||||||
):
|
):
|
||||||
service.dismiss(body.version)
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +75,18 @@ async def apply_update(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await service.apply_update()
|
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"}
|
return {"ok": True, "message": "Update applied, server shutting down"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to apply update: %s", exc, exc_info=True)
|
logger.error("Failed to apply update: %s", exc, exc_info=True)
|
||||||
@@ -83,8 +107,20 @@ async def update_update_settings(
|
|||||||
body: UpdateSettingsRequest,
|
body: UpdateSettingsRequest,
|
||||||
service: UpdateService = Depends(get_update_service),
|
service: UpdateService = Depends(get_update_service),
|
||||||
):
|
):
|
||||||
return await service.update_settings(
|
result = await service.update_settings(
|
||||||
enabled=body.enabled,
|
enabled=body.enabled,
|
||||||
check_interval_hours=body.check_interval_hours,
|
check_interval_hours=body.check_interval_hours,
|
||||||
include_prerelease=body.include_prerelease,
|
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,104 @@
|
|||||||
|
"""Pydantic schemas for the calibration session and solver API."""
|
||||||
|
|
||||||
|
from typing import Annotated, List, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSessionStartRequest(BaseModel):
|
||||||
|
"""Request to start a calibration session on a device."""
|
||||||
|
|
||||||
|
device_id: str = Field(description="ID of the device to drive during calibration")
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSessionPositionRequest(BaseModel):
|
||||||
|
"""Request to advance the chase pixel to a specific LED index."""
|
||||||
|
|
||||||
|
index: int = Field(ge=0, description="LED index to illuminate (0-based)")
|
||||||
|
window: int = Field(
|
||||||
|
default=1,
|
||||||
|
ge=0,
|
||||||
|
le=10,
|
||||||
|
description="Number of dim neighbour LEDs to show on each side (0 = centre only)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSessionStateResponse(BaseModel):
|
||||||
|
"""Current calibration session state."""
|
||||||
|
|
||||||
|
active: bool = Field(description="Whether a session is currently active")
|
||||||
|
device_id: str | None = Field(None, description="Device being driven (null if inactive)")
|
||||||
|
led_count: int = Field(0, description="LED count of the active device")
|
||||||
|
prior_target_id: str | None = Field(
|
||||||
|
None, description="Target that was running before the session (will be restored on stop)"
|
||||||
|
)
|
||||||
|
last_activity: str | None = Field(
|
||||||
|
None, description="ISO timestamp of the last position call (null if inactive)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Solver ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSolveRequest(BaseModel):
|
||||||
|
"""Request to solve a CalibrationConfig from 4 corner tap indices.
|
||||||
|
|
||||||
|
Provide either *device_id* (the server derives led_count from the device)
|
||||||
|
or *led_count* directly. *device_id* takes precedence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
device_id: str | None = Field(
|
||||||
|
None,
|
||||||
|
description=("Device ID to derive led_count from (preferred over led_count field)"),
|
||||||
|
)
|
||||||
|
led_count: int | None = Field(
|
||||||
|
None,
|
||||||
|
ge=1,
|
||||||
|
description="Total LED count (used when device_id is not provided)",
|
||||||
|
)
|
||||||
|
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = Field(
|
||||||
|
description="Starting corner of the strip"
|
||||||
|
)
|
||||||
|
layout: Literal["clockwise", "counterclockwise"] = Field(
|
||||||
|
description="Winding direction of the strip"
|
||||||
|
)
|
||||||
|
corner_indices: List[Annotated[int, Field(ge=0)]] = Field(
|
||||||
|
description=(
|
||||||
|
"Four strip indices — one per screen corner — in the strip-walk order "
|
||||||
|
"defined by (start_position, layout). Index 0 of the strip is the "
|
||||||
|
"start corner; the four tap positions are recorded in strip order "
|
||||||
|
"beginning from that start corner (the solver lays edges out from "
|
||||||
|
"led_start=0, so a non-zero physical start would require the `offset` "
|
||||||
|
"field rather than a shifted corner_indices[0]). Each element must be "
|
||||||
|
"non-negative (ge=0); out-of-range values yield a 422."
|
||||||
|
),
|
||||||
|
min_length=4,
|
||||||
|
max_length=4,
|
||||||
|
)
|
||||||
|
offset: int = Field(
|
||||||
|
default=0,
|
||||||
|
ge=0,
|
||||||
|
description="Physical LED offset (0 = no offset)",
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _require_device_or_led_count(self) -> "CalibrationSolveRequest":
|
||||||
|
if self.device_id is None and self.led_count is None:
|
||||||
|
raise ValueError("Either 'device_id' or 'led_count' must be provided")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSolvedResponse(BaseModel):
|
||||||
|
"""Solved calibration config in simple-mode dict form."""
|
||||||
|
|
||||||
|
mode: Literal["simple"] = "simple"
|
||||||
|
layout: str = Field(description="Winding direction")
|
||||||
|
start_position: str = Field(description="Starting corner")
|
||||||
|
leds_top: int = Field(ge=0, description="LEDs on the top edge")
|
||||||
|
leds_right: int = Field(ge=0, description="LEDs on the right edge")
|
||||||
|
leds_bottom: int = Field(ge=0, description="LEDs on the bottom edge")
|
||||||
|
leds_left: int = Field(ge=0, description="LEDs on the left edge")
|
||||||
|
offset: int = Field(ge=0, description="Physical LED offset")
|
||||||
@@ -344,6 +344,18 @@ class Calibration(BaseModel):
|
|||||||
border_width: int = Field(
|
border_width: int = Field(
|
||||||
default=10, ge=1, le=100, description="Border width in pixels for edge sampling"
|
default=10, ge=1, le=100, description="Border width in pixels for edge sampling"
|
||||||
)
|
)
|
||||||
|
roi_x: float = Field(
|
||||||
|
default=0.0, ge=0.0, le=1.0, description="ROI left edge as a fraction of width (0..1)"
|
||||||
|
)
|
||||||
|
roi_y: float = Field(
|
||||||
|
default=0.0, ge=0.0, le=1.0, description="ROI top edge as a fraction of height (0..1)"
|
||||||
|
)
|
||||||
|
roi_width: float = Field(
|
||||||
|
default=1.0, gt=0.0, le=1.0, description="ROI width as a fraction of width (0..1)"
|
||||||
|
)
|
||||||
|
roi_height: float = Field(
|
||||||
|
default=1.0, gt=0.0, le=1.0, description="ROI height as a fraction of height (0..1)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalibrationTestModeRequest(BaseModel):
|
class CalibrationTestModeRequest(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Scene playlist API schemas."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ledgrab.storage.scene_playlist import (
|
||||||
|
MAX_DURATION_SECONDS,
|
||||||
|
MIN_DURATION_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistItemSchema(BaseModel):
|
||||||
|
scene_preset_id: str = Field(min_length=1, description="Referenced scene preset id")
|
||||||
|
duration_seconds: float = Field(
|
||||||
|
default=30.0,
|
||||||
|
ge=MIN_DURATION_SECONDS,
|
||||||
|
le=MAX_DURATION_SECONDS,
|
||||||
|
description="How long to hold this scene before advancing",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePlaylistCreate(BaseModel):
|
||||||
|
"""Create a scene playlist."""
|
||||||
|
|
||||||
|
name: str = Field(description="Playlist name", min_length=1, max_length=100)
|
||||||
|
description: str = Field(default="", max_length=500)
|
||||||
|
items: List[PlaylistItemSchema] = Field(
|
||||||
|
default_factory=list, description="Ordered playlist items"
|
||||||
|
)
|
||||||
|
loop: bool = Field(default=True, description="Restart from the first item after the last")
|
||||||
|
shuffle: bool = Field(default=False, description="Randomise item order each cycle")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
icon: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=64,
|
||||||
|
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||||
|
)
|
||||||
|
icon_color: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=32,
|
||||||
|
description="Optional CSS color override for the icon.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePlaylistUpdate(BaseModel):
|
||||||
|
"""Update scene playlist metadata, items, and playback flags."""
|
||||||
|
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
description: str | None = Field(None, max_length=500)
|
||||||
|
items: List[PlaylistItemSchema] | None = Field(None, description="Replace the item list")
|
||||||
|
loop: bool | None = None
|
||||||
|
shuffle: bool | None = None
|
||||||
|
order: int | None = None
|
||||||
|
tags: List[str] | None = None
|
||||||
|
icon: str | None = Field(None, max_length=64)
|
||||||
|
icon_color: str | None = Field(None, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistRuntimeStateSchema(BaseModel):
|
||||||
|
is_running: bool = False
|
||||||
|
playlist_id: str | None = None
|
||||||
|
playlist_name: str | None = None
|
||||||
|
current_index: int = 0
|
||||||
|
item_count: int = 0
|
||||||
|
current_preset_id: str | None = None
|
||||||
|
started_at: datetime | None = None
|
||||||
|
step_started_at: datetime | None = None
|
||||||
|
step_duration: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePlaylistResponse(BaseModel):
|
||||||
|
"""Scene playlist with items and runtime running flag."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
items: List[PlaylistItemSchema]
|
||||||
|
loop: bool
|
||||||
|
shuffle: bool
|
||||||
|
order: int
|
||||||
|
tags: List[str] = Field(default_factory=list)
|
||||||
|
icon: str | None = Field(None, max_length=64)
|
||||||
|
icon_color: str | None = Field(None, max_length=32)
|
||||||
|
is_running: bool = Field(default=False, description="True if this playlist is cycling now")
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePlaylistListResponse(BaseModel):
|
||||||
|
playlists: List[ScenePlaylistResponse]
|
||||||
|
count: int
|
||||||
|
state: PlaylistRuntimeStateSchema
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Pydantic schemas for the setup scaffold endpoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ScaffoldRequest(BaseModel):
|
||||||
|
"""Request body for ``POST /api/v1/setup/scaffold``.
|
||||||
|
|
||||||
|
Creates a full capture-to-output chain:
|
||||||
|
capture template → picture source → picture color-strip source → LED output target
|
||||||
|
|
||||||
|
``device_id`` must reference an existing, validated device (created via
|
||||||
|
``POST /api/v1/devices``). The wizard flow is: discover/create the device
|
||||||
|
via the canonical device endpoint first, then call scaffold with the
|
||||||
|
resulting ``device_id``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Existing device (required) ────────────────────────────────────────────
|
||||||
|
device_id: str = Field(
|
||||||
|
description="ID of an existing device to wire into the chain.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Capture / picture source ──────────────────────────────────────────────
|
||||||
|
display_index: int = Field(
|
||||||
|
0,
|
||||||
|
ge=0,
|
||||||
|
le=63,
|
||||||
|
description="Index of the monitor to capture (0 = primary; max 63).",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Optional calibration override ─────────────────────────────────────────
|
||||||
|
calibration: dict[str, Any] | None = Field(
|
||||||
|
None,
|
||||||
|
description=(
|
||||||
|
"Optional CalibrationConfig dict to use for the color-strip source. "
|
||||||
|
"When omitted, ``create_default_calibration(led_count)`` is used."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScaffoldResponse(BaseModel):
|
||||||
|
"""IDs of every entity created (or reused) by the scaffold.
|
||||||
|
|
||||||
|
``capture_template_reused`` is ``True`` when the scaffold matched an
|
||||||
|
existing template by engine type instead of creating a new one.
|
||||||
|
The device is always pre-existing (created via the canonical device endpoint
|
||||||
|
before calling scaffold).
|
||||||
|
"""
|
||||||
|
|
||||||
|
device_id: str = Field(description="Device id (pre-existing).")
|
||||||
|
capture_template_id: str = Field(description="Capture template id.")
|
||||||
|
picture_source_id: str = Field(description="Raw picture source id.")
|
||||||
|
color_strip_source_id: str = Field(description="Picture color-strip source id.")
|
||||||
|
output_target_id: str = Field(description="LED output target id.")
|
||||||
|
|
||||||
|
capture_template_reused: bool = Field(
|
||||||
|
False,
|
||||||
|
description="True when an existing matching capture template was reused.",
|
||||||
|
)
|
||||||
@@ -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:
|
else:
|
||||||
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
|
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:
|
async def _deactivate_automation(self, automation_id: str) -> None:
|
||||||
was_active = self._active_automations.pop(automation_id, False)
|
was_active = self._active_automations.pop(automation_id, False)
|
||||||
if not was_active:
|
if not was_active:
|
||||||
@@ -751,6 +773,33 @@ class AutomationEngine:
|
|||||||
# Clean up any leftover snapshot
|
# Clean up any leftover snapshot
|
||||||
self._pre_activation_snapshots.pop(automation_id, None)
|
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:
|
async def _deactivate_revert(self, automation_id: str) -> None:
|
||||||
"""Revert to pre-activation snapshot."""
|
"""Revert to pre-activation snapshot."""
|
||||||
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
||||||
|
|||||||
@@ -113,6 +113,18 @@ class CalibrationConfig:
|
|||||||
skip_leds_end: int = 0
|
skip_leds_end: int = 0
|
||||||
# Border width: how many pixels from the screen edge to sample
|
# Border width: how many pixels from the screen edge to sample
|
||||||
border_width: int = 10
|
border_width: int = 10
|
||||||
|
# Region of interest (simple mode): sample only this sub-rectangle of the
|
||||||
|
# frame (fractions 0..1). Defaults to the full frame. Lets a user exclude
|
||||||
|
# HUDs/taskbars/letterboxing from the sampled border colours.
|
||||||
|
roi_x: float = 0.0
|
||||||
|
roi_y: float = 0.0
|
||||||
|
roi_width: float = 1.0
|
||||||
|
roi_height: float = 1.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_roi(self) -> bool:
|
||||||
|
"""True when the ROI is narrower than the full frame."""
|
||||||
|
return self.roi_x > 0.0 or self.roi_y > 0.0 or self.roi_width < 1.0 or self.roi_height < 1.0
|
||||||
|
|
||||||
def build_segments(self) -> List[CalibrationSegment]:
|
def build_segments(self) -> List[CalibrationSegment]:
|
||||||
"""Derive segment list from core parameters."""
|
"""Derive segment list from core parameters."""
|
||||||
@@ -656,6 +668,98 @@ def create_pixel_mapper(
|
|||||||
return PixelMapper(calibration, interpolation_mode)
|
return PixelMapper(calibration, interpolation_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def solve_calibration(
|
||||||
|
led_count: int,
|
||||||
|
start_position: str,
|
||||||
|
layout: str,
|
||||||
|
corner_indices: List[int],
|
||||||
|
offset: int = 0,
|
||||||
|
) -> "CalibrationConfig":
|
||||||
|
"""Derive a CalibrationConfig from 4 corner tap indices.
|
||||||
|
|
||||||
|
Given the LED-strip indices where the user tapped each physical corner of
|
||||||
|
the screen (in strip-walk order matching *start_position* and *layout*),
|
||||||
|
compute per-edge LED counts that are consistent with
|
||||||
|
``EDGE_ORDER``/``EDGE_REVERSE`` and round-trip through
|
||||||
|
``build_segments()``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
led_count: Total number of LEDs on the strip.
|
||||||
|
start_position: Starting corner of the strip
|
||||||
|
(``"top_left"``, ``"top_right"``, ``"bottom_left"``,
|
||||||
|
``"bottom_right"``).
|
||||||
|
layout: Winding direction (``"clockwise"`` or
|
||||||
|
``"counterclockwise"``).
|
||||||
|
corner_indices: Four strip indices, one per screen corner, in the
|
||||||
|
same order as the strip walk defined by ``EDGE_ORDER`` for the
|
||||||
|
given *(start_position, layout)* pair. Index 0 is the start
|
||||||
|
corner, index 1 is the second corner reached while walking,
|
||||||
|
etc. Indices may wrap around (i.e. the last segment may
|
||||||
|
straddle the physical end of the strip).
|
||||||
|
offset: Physical LED offset stored directly on the config (0 = none).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``CalibrationConfig`` in simple mode with per-edge counts filled in.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If *start_position*, *layout*, or the number of
|
||||||
|
corner indices is invalid.
|
||||||
|
"""
|
||||||
|
key = (start_position, layout)
|
||||||
|
if key not in EDGE_ORDER:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid start_position/layout combination: {start_position!r}/{layout!r}"
|
||||||
|
)
|
||||||
|
if len(corner_indices) != 4:
|
||||||
|
raise ValueError(f"corner_indices must have exactly 4 entries, got {len(corner_indices)}")
|
||||||
|
if led_count <= 0:
|
||||||
|
raise ValueError(f"led_count must be positive, got {led_count}")
|
||||||
|
|
||||||
|
edge_order = EDGE_ORDER[key] # 4 edges in strip-walk order
|
||||||
|
|
||||||
|
# Compute per-edge LED counts from consecutive corner indices.
|
||||||
|
# The i-th edge spans from corner_indices[i] to corner_indices[(i+1) % 4],
|
||||||
|
# wrapping around led_count if necessary.
|
||||||
|
edge_counts: dict[str, int] = {}
|
||||||
|
for i, edge in enumerate(edge_order):
|
||||||
|
start_idx = corner_indices[i] % led_count
|
||||||
|
end_idx = corner_indices[(i + 1) % 4] % led_count
|
||||||
|
if end_idx > start_idx:
|
||||||
|
count = end_idx - start_idx
|
||||||
|
elif end_idx == start_idx:
|
||||||
|
# Adjacent taps on the same index → 0-LED edge
|
||||||
|
count = 0
|
||||||
|
else:
|
||||||
|
# Wrap-around: strip crosses the physical end
|
||||||
|
count = (led_count - start_idx) + end_idx
|
||||||
|
edge_counts[edge] = count
|
||||||
|
|
||||||
|
cfg = CalibrationConfig(
|
||||||
|
mode="simple",
|
||||||
|
layout=layout,
|
||||||
|
start_position=start_position,
|
||||||
|
leds_top=edge_counts.get("top", 0),
|
||||||
|
leds_right=edge_counts.get("right", 0),
|
||||||
|
leds_bottom=edge_counts.get("bottom", 0),
|
||||||
|
leds_left=edge_counts.get("left", 0),
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"solve_calibration: start=%s layout=%s corner_indices=%s "
|
||||||
|
"-> top=%d right=%d bottom=%d left=%d offset=%d",
|
||||||
|
start_position,
|
||||||
|
layout,
|
||||||
|
corner_indices,
|
||||||
|
cfg.leds_top,
|
||||||
|
cfg.leds_right,
|
||||||
|
cfg.leds_bottom,
|
||||||
|
cfg.leds_left,
|
||||||
|
offset,
|
||||||
|
)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def create_default_calibration(
|
def create_default_calibration(
|
||||||
led_count: int,
|
led_count: int,
|
||||||
aspect_width: int = 16,
|
aspect_width: int = 16,
|
||||||
@@ -720,6 +824,30 @@ def create_default_calibration(
|
|||||||
right_count = max(1, right_count)
|
right_count = max(1, right_count)
|
||||||
left_count = max(1, left_count)
|
left_count = max(1, left_count)
|
||||||
|
|
||||||
|
# The max(1, ...) floors above can push the total above led_count for
|
||||||
|
# small counts (e.g. led_count=5 -> top=2,right=1,bottom=2,left=1 = 6).
|
||||||
|
# Trim the largest edge that stays >= 1 until the total matches exactly.
|
||||||
|
edge_order = ["bottom", "top", "right", "left"]
|
||||||
|
counts = {
|
||||||
|
"bottom": bottom_count,
|
||||||
|
"top": top_count,
|
||||||
|
"right": right_count,
|
||||||
|
"left": left_count,
|
||||||
|
}
|
||||||
|
overshoot = sum(counts.values()) - led_count
|
||||||
|
while overshoot > 0:
|
||||||
|
# Pick the largest edge that can still be reduced (stays >= 1).
|
||||||
|
trimmable = [e for e in edge_order if counts[e] > 1]
|
||||||
|
if not trimmable:
|
||||||
|
break
|
||||||
|
target_edge = max(trimmable, key=lambda e: counts[e])
|
||||||
|
counts[target_edge] -= 1
|
||||||
|
overshoot -= 1
|
||||||
|
bottom_count = counts["bottom"]
|
||||||
|
top_count = counts["top"]
|
||||||
|
right_count = counts["right"]
|
||||||
|
left_count = counts["left"]
|
||||||
|
|
||||||
config = CalibrationConfig(
|
config = CalibrationConfig(
|
||||||
layout="clockwise",
|
layout="clockwise",
|
||||||
start_position="bottom_left",
|
start_position="bottom_left",
|
||||||
@@ -799,6 +927,10 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
|
|||||||
skip_leds_start=data.get("skip_leds_start", 0),
|
skip_leds_start=data.get("skip_leds_start", 0),
|
||||||
skip_leds_end=data.get("skip_leds_end", 0),
|
skip_leds_end=data.get("skip_leds_end", 0),
|
||||||
border_width=data.get("border_width", 10),
|
border_width=data.get("border_width", 10),
|
||||||
|
roi_x=data.get("roi_x", 0.0),
|
||||||
|
roi_y=data.get("roi_y", 0.0),
|
||||||
|
roi_width=data.get("roi_width", 1.0),
|
||||||
|
roi_height=data.get("roi_height", 1.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
config.validate()
|
config.validate()
|
||||||
@@ -870,4 +1002,10 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
|
|||||||
result["skip_leds_end"] = config.skip_leds_end
|
result["skip_leds_end"] = config.skip_leds_end
|
||||||
if config.border_width != 10:
|
if config.border_width != 10:
|
||||||
result["border_width"] = config.border_width
|
result["border_width"] = config.border_width
|
||||||
|
# Include ROI only when it is not the full frame
|
||||||
|
if config.has_roi:
|
||||||
|
result["roi_x"] = config.roi_x
|
||||||
|
result["roi_y"] = config.roi_y
|
||||||
|
result["roi_width"] = config.roi_width
|
||||||
|
result["roi_height"] = config.roi_height
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
"""Calibration session lifecycle and per-LED chase driver.
|
||||||
|
|
||||||
|
Provides two things:
|
||||||
|
1. ``set_calibration_pixel`` — direct per-index LED write for the chase
|
||||||
|
(added beside ``set_test_mode`` on ``ProcessorManager`` via the mixin, but
|
||||||
|
kept here to avoid growing device_test_mode.py further).
|
||||||
|
2. ``CalibrationSession`` — single-active-session guard with idle timeout and
|
||||||
|
guaranteed stop/restore contract.
|
||||||
|
|
||||||
|
Stop / restore contract (required by Phase 3 UI)
|
||||||
|
-------------------------------------------------
|
||||||
|
- ``start(device_id)``:
|
||||||
|
* If a target is currently processing on *device_id*, stop it and record
|
||||||
|
its ``target_id`` as ``_prior_target_id``.
|
||||||
|
* Send the device to black (chase start state).
|
||||||
|
* Record session as active with a fresh ``last_activity`` timestamp.
|
||||||
|
* Only one active session is allowed at a time; starting a new one on any
|
||||||
|
device while another is active calls ``stop()`` on the old one first.
|
||||||
|
- ``position(index, window)``:
|
||||||
|
* Validates ``index < led_count``; raises ``ValueError`` on out-of-range.
|
||||||
|
* Sends a chase pixel (bright white centre ±window dim neighbours).
|
||||||
|
* Updates ``last_activity``.
|
||||||
|
- ``stop()`` / ``cancel()``:
|
||||||
|
* Sends all-black to clear the device.
|
||||||
|
* If ``_prior_target_id`` was recorded, calls ``start_processing`` to
|
||||||
|
restart it.
|
||||||
|
* Clears the session state.
|
||||||
|
* NEVER leaves the device dark or stuck in chase.
|
||||||
|
- Idle timeout (``IDLE_TIMEOUT_SECONDS``, default 60 s):
|
||||||
|
* A background asyncio task checks ``last_activity``; if the session has
|
||||||
|
been idle longer than the timeout, ``stop()`` is called automatically.
|
||||||
|
* The timeout task is cancelled when ``stop()`` is called explicitly.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
-----
|
||||||
|
- ``set_calibration_pixel`` reuses ``_get_idle_client`` /
|
||||||
|
``_send_pixels_to_device`` from ``DeviceTestModeMixin``; no new connection
|
||||||
|
management is needed.
|
||||||
|
- The session holds a reference to the ``ProcessorManager`` so it can call
|
||||||
|
``stop_processing`` / ``start_processing``.
|
||||||
|
- Thread-safety: all public methods are ``async``; the idle-timeout callback
|
||||||
|
schedules itself on the running event loop via ``asyncio.ensure_future``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
IDLE_TIMEOUT_SECONDS: int = 60
|
||||||
|
"""Auto-stop a calibration session after this many seconds of inactivity."""
|
||||||
|
|
||||||
|
_CHASE_CENTER_COLOR: tuple[int, int, int] = (255, 255, 255)
|
||||||
|
"""Bright white for the chase centre pixel."""
|
||||||
|
|
||||||
|
_CHASE_WING_COLOR: tuple[int, int, int] = (60, 60, 60)
|
||||||
|
"""Dim grey for ±window neighbour pixels."""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mixin: per-index chase driver ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationChaseMixin:
|
||||||
|
"""Adds ``set_calibration_pixel`` to ``ProcessorManager``.
|
||||||
|
|
||||||
|
Requires the same host-class attributes as ``DeviceTestModeMixin``:
|
||||||
|
``_devices``, ``_processors``, ``_idle_clients``.
|
||||||
|
Inherits ``_send_pixels_to_device`` and ``_get_idle_client`` from
|
||||||
|
``DeviceTestModeMixin`` (both already on ``ProcessorManager``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def set_calibration_pixel(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
index: int,
|
||||||
|
color: tuple[int, int, int] = _CHASE_CENTER_COLOR,
|
||||||
|
window: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Light a single LED index (plus optional ±window neighbours) on a device.
|
||||||
|
|
||||||
|
Sends a full pixel array to avoid partial-frame artefacts. The centre
|
||||||
|
LED is set to *color*; the ``window`` neighbours on each side are set to
|
||||||
|
``_CHASE_WING_COLOR`` (dim grey) so the user can see which direction the
|
||||||
|
strip is wound.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: Target device ID (must be registered).
|
||||||
|
index: LED index to light (0-based). Must be < ``led_count``.
|
||||||
|
color: RGB tuple for the centre LED (default bright white).
|
||||||
|
window: Number of neighbouring LEDs to dim on each side (default 1).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If *device_id* is not registered or *index* is out of
|
||||||
|
range.
|
||||||
|
"""
|
||||||
|
if device_id not in self._devices:
|
||||||
|
raise ValueError(f"Device {device_id!r} not found")
|
||||||
|
ds = self._devices[device_id]
|
||||||
|
led_count = ds.led_count
|
||||||
|
if led_count <= 0:
|
||||||
|
raise ValueError(f"Device {device_id!r} has led_count={led_count}")
|
||||||
|
if not (0 <= index < led_count):
|
||||||
|
raise ValueError(
|
||||||
|
f"index {index} out of range for device {device_id!r} " f"(led_count={led_count})"
|
||||||
|
)
|
||||||
|
|
||||||
|
pixels: list[tuple[int, int, int]] = [(0, 0, 0)] * led_count
|
||||||
|
pixels[index] = color
|
||||||
|
for offset in range(1, window + 1):
|
||||||
|
left = (index - offset) % led_count
|
||||||
|
right = (index + offset) % led_count
|
||||||
|
pixels[left] = _CHASE_WING_COLOR
|
||||||
|
pixels[right] = _CHASE_WING_COLOR
|
||||||
|
# Re-assign center last so on tiny strips (window >= led_count) the
|
||||||
|
# center LED always shows the full color rather than a wrapped wing.
|
||||||
|
pixels[index] = color
|
||||||
|
|
||||||
|
await self._send_pixels_to_device(device_id, pixels)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"set_calibration_pixel: device=%s index=%d window=%d",
|
||||||
|
device_id,
|
||||||
|
index,
|
||||||
|
window,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSession:
|
||||||
|
"""Single-active calibration session with idle-timeout and stop/restore.
|
||||||
|
|
||||||
|
One instance is shared per application (singleton held by the API layer).
|
||||||
|
Only one session can be active at a time; starting a new session
|
||||||
|
automatically terminates the previous one.
|
||||||
|
|
||||||
|
All public methods that mutate session state acquire ``_lock`` so that
|
||||||
|
concurrent ``POST /session`` calls (or a ``stop`` racing with the idle
|
||||||
|
watchdog) cannot interleave and leave ``_prior_target_id`` stale. The
|
||||||
|
watchdog calls the internal ``_teardown_locked`` helper which must only be
|
||||||
|
invoked when the lock is already held; if the lock is already taken the
|
||||||
|
watchdog simply exits, letting the holder finish teardown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._manager: "ProcessorManager | None" = None
|
||||||
|
self._device_id: str | None = None
|
||||||
|
self._led_count: int = 0
|
||||||
|
self._prior_target_id: str | None = None
|
||||||
|
self._last_activity: datetime | None = None
|
||||||
|
self._timeout_task: asyncio.Task | None = None
|
||||||
|
self._active: bool = False
|
||||||
|
self._lock: asyncio.Lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# ── Public API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
return self._active
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_id(self) -> str | None:
|
||||||
|
return self._device_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def led_count(self) -> int:
|
||||||
|
return self._led_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_activity(self) -> datetime | None:
|
||||||
|
return self._last_activity
|
||||||
|
|
||||||
|
async def start(self, device_id: str, manager: "ProcessorManager") -> None:
|
||||||
|
"""Begin a calibration session on *device_id*.
|
||||||
|
|
||||||
|
If a session is already active (even on a different device), it is
|
||||||
|
stopped first. If a target is currently processing on *device_id*, it
|
||||||
|
is stopped and remembered so it can be restored when this session ends.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: The device to drive during calibration.
|
||||||
|
manager: Live ``ProcessorManager`` instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If *device_id* is not registered.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
# Validate device before touching any state or awaiting
|
||||||
|
if device_id not in manager._devices:
|
||||||
|
raise ValueError(f"Device {device_id!r} not found")
|
||||||
|
|
||||||
|
ds = manager._devices[device_id]
|
||||||
|
led_count = ds.led_count
|
||||||
|
|
||||||
|
# Capture the prior running target NOW — before any await — so the
|
||||||
|
# value cannot be mutated by a concurrent call that sneaks in after
|
||||||
|
# the lock is released between awaits.
|
||||||
|
prior_target_id = manager.get_processing_target_for_device(device_id)
|
||||||
|
|
||||||
|
# Terminate any existing session while we still hold the lock.
|
||||||
|
# Call _teardown_locked directly (we already hold the lock).
|
||||||
|
if self._active:
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession.start: stopping existing session on device=%s "
|
||||||
|
"to start new one on device=%s",
|
||||||
|
self._device_id,
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
await self._teardown_locked(cancelled=False)
|
||||||
|
|
||||||
|
# Stop any running target on this device and remember it for restore
|
||||||
|
if prior_target_id is not None:
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession.start: stopping target %s on device %s for calibration",
|
||||||
|
prior_target_id,
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
await manager.stop_processing(prior_target_id)
|
||||||
|
|
||||||
|
self._manager = manager
|
||||||
|
self._device_id = device_id
|
||||||
|
self._led_count = led_count
|
||||||
|
self._prior_target_id = prior_target_id
|
||||||
|
self._last_activity = datetime.now(timezone.utc)
|
||||||
|
self._active = True
|
||||||
|
|
||||||
|
# Clear the device to black so the chase starts from a clean state
|
||||||
|
await manager.send_clear_pixels(device_id)
|
||||||
|
|
||||||
|
# Start idle-timeout watchdog
|
||||||
|
self._timeout_task = asyncio.ensure_future(self._idle_watchdog())
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession.start: session started on device=%s led_count=%d "
|
||||||
|
"prior_target=%s",
|
||||||
|
device_id,
|
||||||
|
led_count,
|
||||||
|
prior_target_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def position(self, index: int, window: int = 1) -> None:
|
||||||
|
"""Drive the chase pixel to *index* on the active device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: LED index to illuminate (0-based, must be < led_count).
|
||||||
|
window: Number of dim neighbours on each side (default 1).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If no session is active.
|
||||||
|
ValueError: If *index* is out of range.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
if not self._active or self._manager is None or self._device_id is None:
|
||||||
|
raise RuntimeError("No active calibration session")
|
||||||
|
if not (0 <= index < self._led_count):
|
||||||
|
raise ValueError(f"index {index} out of range (led_count={self._led_count})")
|
||||||
|
|
||||||
|
self._last_activity = datetime.now(timezone.utc)
|
||||||
|
await self._manager.set_calibration_pixel(self._device_id, index, window=window)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"CalibrationSession.position: device=%s index=%d window=%d",
|
||||||
|
self._device_id,
|
||||||
|
index,
|
||||||
|
window,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""End the session: clear the device and restore the prior target.
|
||||||
|
|
||||||
|
Safe to call even if no session is active (no-op).
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
await self._teardown_locked(cancelled=False)
|
||||||
|
|
||||||
|
async def cancel(self) -> None:
|
||||||
|
"""Alias for ``stop()`` — ends the session without applying calibration."""
|
||||||
|
async with self._lock:
|
||||||
|
await self._teardown_locked(cancelled=True)
|
||||||
|
|
||||||
|
def get_state(self) -> dict:
|
||||||
|
"""Return a snapshot of the current session state for API responses."""
|
||||||
|
return {
|
||||||
|
"active": self._active,
|
||||||
|
"device_id": self._device_id,
|
||||||
|
"led_count": self._led_count,
|
||||||
|
"prior_target_id": self._prior_target_id,
|
||||||
|
"last_activity": (self._last_activity.isoformat() if self._last_activity else None),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Internal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _teardown_locked(self, cancelled: bool) -> None:
|
||||||
|
"""Clear the device, restore the prior target, and reset state.
|
||||||
|
|
||||||
|
MUST be called with ``self._lock`` already held by the caller.
|
||||||
|
Safe to call when already inactive (no-op).
|
||||||
|
"""
|
||||||
|
if not self._active:
|
||||||
|
return
|
||||||
|
|
||||||
|
device_id = self._device_id
|
||||||
|
manager = self._manager
|
||||||
|
prior_target_id = self._prior_target_id
|
||||||
|
|
||||||
|
# Cancel the idle watchdog — but only if we are NOT running inside it.
|
||||||
|
# Awaiting the current task would deadlock.
|
||||||
|
if (
|
||||||
|
self._timeout_task is not None
|
||||||
|
and self._timeout_task is not asyncio.current_task()
|
||||||
|
and not self._timeout_task.done()
|
||||||
|
):
|
||||||
|
self._timeout_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._timeout_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._timeout_task = None
|
||||||
|
|
||||||
|
# Reset state before side-effects so re-entrant calls are no-ops
|
||||||
|
self._active = False
|
||||||
|
self._device_id = None
|
||||||
|
self._led_count = 0
|
||||||
|
self._prior_target_id = None
|
||||||
|
self._last_activity = None
|
||||||
|
self._manager = None
|
||||||
|
|
||||||
|
if manager is None or device_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Clear the device to black
|
||||||
|
try:
|
||||||
|
await manager.send_clear_pixels(device_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"CalibrationSession._teardown: failed to clear pixels on %s: %s",
|
||||||
|
device_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Restore the prior target (if any)
|
||||||
|
if prior_target_id is not None:
|
||||||
|
try:
|
||||||
|
await manager.start_processing(prior_target_id)
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession._teardown: restored target %s on device %s",
|
||||||
|
prior_target_id,
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"CalibrationSession._teardown: failed to restore target %s on " "device %s: %s",
|
||||||
|
prior_target_id,
|
||||||
|
device_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
action = "cancel" if cancelled else "stop"
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession.%s: session ended on device=%s prior_target=%s",
|
||||||
|
action,
|
||||||
|
device_id,
|
||||||
|
prior_target_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _idle_watchdog(self) -> None:
|
||||||
|
"""Background task: auto-stop the session after IDLE_TIMEOUT_SECONDS.
|
||||||
|
|
||||||
|
Tries to acquire ``_lock`` when the timeout fires. If the lock is
|
||||||
|
already held (e.g. a concurrent ``stop()`` is in progress) the
|
||||||
|
``acquire`` will wait; once it gets the lock, ``_teardown_locked``
|
||||||
|
is a no-op if the session was already ended by the other caller.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
if not self._active or self._last_activity is None:
|
||||||
|
break
|
||||||
|
elapsed = (datetime.now(timezone.utc) - self._last_activity).total_seconds()
|
||||||
|
if elapsed >= IDLE_TIMEOUT_SECONDS:
|
||||||
|
logger.warning(
|
||||||
|
"CalibrationSession._idle_watchdog: session on device=%s "
|
||||||
|
"idle for %.0fs — auto-stopping",
|
||||||
|
self._device_id,
|
||||||
|
elapsed,
|
||||||
|
)
|
||||||
|
async with self._lock:
|
||||||
|
await self._teardown_locked(cancelled=False)
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Module-level singleton ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_session: CalibrationSession = CalibrationSession()
|
||||||
|
|
||||||
|
|
||||||
|
def get_calibration_session() -> CalibrationSession:
|
||||||
|
"""Return the module-level singleton ``CalibrationSession``."""
|
||||||
|
return _session
|
||||||
@@ -159,6 +159,37 @@ def capture_display(display_index: int = 0) -> ScreenCapture:
|
|||||||
raise RuntimeError(f"Screen capture failed: {e}")
|
raise RuntimeError(f"Screen capture failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def crop_screen_capture(
|
||||||
|
sc: ScreenCapture,
|
||||||
|
roi_x: float,
|
||||||
|
roi_y: float,
|
||||||
|
roi_width: float,
|
||||||
|
roi_height: float,
|
||||||
|
) -> ScreenCapture:
|
||||||
|
"""Crop a capture to a relative region-of-interest rectangle (fractions 0..1).
|
||||||
|
|
||||||
|
Sampling only a sub-rectangle of the frame lets a user exclude HUDs, task
|
||||||
|
bars, or letterboxing so they don't pollute the border colours. Returns the
|
||||||
|
original capture unchanged for a full-frame ROI (fast path). The cropped
|
||||||
|
image is a numpy view (no copy); out-of-range/degenerate ROIs are clamped so
|
||||||
|
at least a 1x1 region remains.
|
||||||
|
"""
|
||||||
|
if roi_x <= 0.0 and roi_y <= 0.0 and roi_width >= 1.0 and roi_height >= 1.0:
|
||||||
|
return sc
|
||||||
|
h, w = sc.image.shape[:2]
|
||||||
|
x0 = max(0, min(w - 1, int(round(roi_x * w))))
|
||||||
|
y0 = max(0, min(h - 1, int(round(roi_y * h))))
|
||||||
|
x1 = max(x0 + 1, min(w, int(round((roi_x + roi_width) * w))))
|
||||||
|
y1 = max(y0 + 1, min(h, int(round((roi_y + roi_height) * h))))
|
||||||
|
cropped = sc.image[y0:y1, x0:x1]
|
||||||
|
return ScreenCapture(
|
||||||
|
image=cropped,
|
||||||
|
width=x1 - x0,
|
||||||
|
height=y1 - y0,
|
||||||
|
display_index=sc.display_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def extract_border_pixels(screen_capture: ScreenCapture, border_width: int = 10) -> BorderPixels:
|
def extract_border_pixels(screen_capture: ScreenCapture, border_width: int = 10) -> BorderPixels:
|
||||||
"""Extract border pixels from screen capture.
|
"""Extract border pixels from screen capture.
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,15 @@ class DDPClient:
|
|||||||
all buses (observed in multi-bus setups). We reorder pixel channels
|
all buses (observed in multi-bus setups). We reorder pixel channels
|
||||||
here so the hardware receives the correct byte order directly.
|
here so the hardware receives the correct byte order directly.
|
||||||
|
|
||||||
|
TODO(ddp-multibus): currently UNUSED — ``send_pixels_numpy`` (the hot
|
||||||
|
send path) does NOT call this, so the per-bus color-order config
|
||||||
|
captured by ``set_buses`` is never applied to outgoing pixels. This
|
||||||
|
is intentionally left in place (not deleted) because it encodes real
|
||||||
|
multi-bus handling: if a multi-bus WLED setup needs per-bus byte
|
||||||
|
reordering, wire this into ``send_pixels_numpy`` before the payload
|
||||||
|
view is built (note it allocates a copy, so only call it when
|
||||||
|
``self._buses`` actually requires reordering).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pixel_array: (N, 3) uint8 numpy array in RGB order
|
pixel_array: (N, 3) uint8 numpy array in RGB order
|
||||||
|
|
||||||
|
|||||||
@@ -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.serial_transport import list_serial_ports
|
||||||
from ledgrab.core.devices.wled_provider import WLED_MDNS_TYPE
|
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 import get_logger
|
||||||
from ledgrab.utils.platform import is_android
|
from ledgrab.utils.platform import is_android
|
||||||
|
|
||||||
@@ -286,3 +287,34 @@ class DiscoveryWatcher:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Discovery watcher: fire_event failed: %s", 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)
|
||||||
|
|||||||
@@ -260,7 +260,10 @@ class CS2Adapter(GameAdapter):
|
|||||||
|
|
||||||
auth_section = payload.get("auth", {})
|
auth_section = payload.get("auth", {})
|
||||||
actual_token = auth_section.get("token", "")
|
actual_token = auth_section.get("token", "")
|
||||||
return bool(actual_token and actual_token == expected_token)
|
if not actual_token:
|
||||||
|
return False
|
||||||
|
# Constant-time comparison to avoid a timing oracle.
|
||||||
|
return secrets.compare_digest(actual_token, expected_token)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_config_schema(cls) -> dict[str, Any]:
|
def get_config_schema(cls) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -177,7 +177,10 @@ class Dota2Adapter(GameAdapter):
|
|||||||
|
|
||||||
auth_section = payload.get("auth", {})
|
auth_section = payload.get("auth", {})
|
||||||
actual_token = auth_section.get("token", "")
|
actual_token = auth_section.get("token", "")
|
||||||
return bool(actual_token and actual_token == expected_token)
|
if not actual_token:
|
||||||
|
return False
|
||||||
|
# Constant-time comparison to avoid a timing oracle.
|
||||||
|
return secrets.compare_digest(actual_token, expected_token)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_config_schema(cls) -> dict[str, Any]:
|
def get_config_schema(cls) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Allows users to define custom JSON path mappings via the adapter_config
|
|||||||
rather than a YAML file. Delegates all parsing logic to MappingAdapter.
|
rather than a YAML file. Delegates all parsing logic to MappingAdapter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from ledgrab.core.game_integration.base_adapter import GameAdapter
|
from ledgrab.core.game_integration.base_adapter import GameAdapter
|
||||||
@@ -54,11 +55,18 @@ class GenericWebhookAdapter(GameAdapter):
|
|||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
adapter_config: dict[str, Any],
|
adapter_config: dict[str, Any],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Validate auth using a configurable header token."""
|
"""Validate auth using a configurable header token.
|
||||||
|
|
||||||
|
Secure-by-default: this adapter is explicitly network-facing
|
||||||
|
(it accepts unauthenticated HTTP POSTs from anywhere on the LAN),
|
||||||
|
so a missing/empty ``auth_token`` REJECTS the request rather than
|
||||||
|
accepting it. A token must be configured for ingestion to work.
|
||||||
|
"""
|
||||||
expected_token = adapter_config.get("auth_token")
|
expected_token = adapter_config.get("auth_token")
|
||||||
if not expected_token:
|
if not expected_token:
|
||||||
# No auth configured
|
# No token configured — reject (secure-by-default for a
|
||||||
return True
|
# network-facing adapter; an open webhook is a LAN attack surface).
|
||||||
|
return False
|
||||||
|
|
||||||
auth_header = adapter_config.get("auth_header", "Authorization")
|
auth_header = adapter_config.get("auth_header", "Authorization")
|
||||||
actual_value = headers.get(auth_header, "")
|
actual_value = headers.get(auth_header, "")
|
||||||
@@ -67,18 +75,27 @@ class GenericWebhookAdapter(GameAdapter):
|
|||||||
if actual_value.startswith("Bearer "):
|
if actual_value.startswith("Bearer "):
|
||||||
actual_value = actual_value[7:]
|
actual_value = actual_value[7:]
|
||||||
|
|
||||||
return bool(actual_value and actual_value == expected_token)
|
if not actual_value:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Constant-time comparison to avoid a token-length/timing oracle.
|
||||||
|
return secrets.compare_digest(actual_value, expected_token)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_config_schema(cls) -> dict[str, Any]:
|
def get_config_schema(cls) -> dict[str, Any]:
|
||||||
"""Return generic webhook config schema."""
|
"""Return generic webhook config schema."""
|
||||||
return {
|
return {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"required": ["auth_token"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"auth_token": {
|
"auth_token": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Auth Token",
|
"title": "Auth Token",
|
||||||
"description": "Optional token for authenticating incoming webhooks.",
|
"description": (
|
||||||
|
"Required token for authenticating incoming webhooks. "
|
||||||
|
"Without it, ingestion is rejected (this adapter is "
|
||||||
|
"network-facing and secure-by-default)."
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"auth_header": {
|
"auth_header": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -136,15 +153,17 @@ class GenericWebhookAdapter(GameAdapter):
|
|||||||
"HTTP POST requests with JSON payloads.\n\n"
|
"HTTP POST requests with JSON payloads.\n\n"
|
||||||
"**Steps:**\n"
|
"**Steps:**\n"
|
||||||
"1. Configure your event mappings above — map JSON paths to standard events\n"
|
"1. Configure your event mappings above — map JSON paths to standard events\n"
|
||||||
"2. Set an auth token (optional but recommended)\n"
|
"2. Set an auth token (REQUIRED — without it incoming webhooks are rejected)\n"
|
||||||
"3. Point your game/application to:\n"
|
"3. Point your game/application to:\n"
|
||||||
" `POST http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event`\n\n"
|
" `POST http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event`\n\n"
|
||||||
"**Mapping example:**\n"
|
"**Mapping example:**\n"
|
||||||
"- Source path: `player.stats.health` → Event: `health` (min: 0, max: 100)\n"
|
"- Source path: `player.stats.health` → Event: `health` (min: 0, max: 100)\n"
|
||||||
"- Source path: `events.kill_count` → Event: `kill` (trigger: on_increase)\n\n"
|
"- Source path: `events.kill_count` → Event: `kill` (trigger: on_increase)\n\n"
|
||||||
"**Auth:**\n"
|
"**Auth (required):**\n"
|
||||||
"- Set `Authorization: Bearer <token>` header in your webhook sender\n"
|
"- Set `Authorization: Bearer <token>` header in your webhook sender\n"
|
||||||
"- Or configure a custom auth header name in the adapter config\n"
|
"- Or configure a custom auth header name in the adapter config\n"
|
||||||
|
"- A token is mandatory: this endpoint is reachable from the LAN, so\n"
|
||||||
|
" an unconfigured token rejects all incoming events.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ The MappingAdapter class is a concrete GameAdapter whose behavior is
|
|||||||
entirely driven by the parsed YAML definition.
|
entirely driven by the parsed YAML definition.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -241,7 +242,10 @@ class MappingAdapter(GameAdapter):
|
|||||||
expected_key = "auth_token"
|
expected_key = "auth_token"
|
||||||
expected_value = adapter_config.get(expected_key, "")
|
expected_value = adapter_config.get(expected_key, "")
|
||||||
actual_value = headers.get(header_name, "")
|
actual_value = headers.get(header_name, "")
|
||||||
return bool(expected_value and actual_value == expected_value)
|
if not (expected_value and actual_value):
|
||||||
|
return False
|
||||||
|
# Constant-time comparison to avoid a timing oracle.
|
||||||
|
return secrets.compare_digest(actual_value, expected_value)
|
||||||
|
|
||||||
logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
|
logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from ledgrab.core.capture.calibration import (
|
|||||||
CalibrationConfig,
|
CalibrationConfig,
|
||||||
create_pixel_mapper,
|
create_pixel_mapper,
|
||||||
)
|
)
|
||||||
from ledgrab.core.capture.screen_capture import extract_border_pixels
|
from ledgrab.core.capture.screen_capture import crop_screen_capture, extract_border_pixels
|
||||||
from ledgrab.storage.bindable import bfloat
|
from ledgrab.storage.bindable import bfloat
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
from ledgrab.utils.frame_limiter import FrameLimiter
|
from ledgrab.utils.frame_limiter import FrameLimiter
|
||||||
@@ -296,7 +296,19 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
t1 = time.perf_counter()
|
t1 = time.perf_counter()
|
||||||
led_colors = mapper.map_lines_to_leds(frames_dict)
|
led_colors = mapper.map_lines_to_leds(frames_dict)
|
||||||
else:
|
else:
|
||||||
border_pixels = extract_border_pixels(frame, calibration.border_width)
|
src = frame
|
||||||
|
bw = calibration.border_width
|
||||||
|
if calibration.has_roi:
|
||||||
|
src = crop_screen_capture(
|
||||||
|
frame,
|
||||||
|
calibration.roi_x,
|
||||||
|
calibration.roi_y,
|
||||||
|
calibration.roi_width,
|
||||||
|
calibration.roi_height,
|
||||||
|
)
|
||||||
|
# Border width must stay within the cropped size.
|
||||||
|
bw = max(1, min(bw, min(src.width, src.height) // 4))
|
||||||
|
border_pixels = extract_border_pixels(src, bw)
|
||||||
t1 = time.perf_counter()
|
t1 = time.perf_counter()
|
||||||
led_colors = mapper.map_border_to_leds(border_pixels)
|
led_colors = mapper.map_border_to_leds(border_pixels)
|
||||||
t2 = time.perf_counter()
|
t2 = time.perf_counter()
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
|
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
|
||||||
self._resize_cache: Dict[tuple, tuple] = {}
|
self._resize_cache: Dict[tuple, tuple] = {}
|
||||||
|
# (src_len, target_n) -> (src_x, dst_x) cache for full-strip resizing
|
||||||
|
# (output reuses the preallocated self._resize_buf from _ensure_pool)
|
||||||
|
self._resize_linspace_cache: Dict[tuple, tuple] = {}
|
||||||
# layer_index -> (source_id, consumer_id, stream)
|
# layer_index -> (source_id, consumer_id, stream)
|
||||||
self._sub_streams: Dict[int, tuple] = {}
|
self._sub_streams: Dict[int, tuple] = {}
|
||||||
# layer_index -> (vs_id, value_stream)
|
# layer_index -> (vs_id, value_stream)
|
||||||
@@ -314,8 +317,14 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
n_src = len(colors)
|
n_src = len(colors)
|
||||||
if n_src == target_n:
|
if n_src == target_n:
|
||||||
return colors
|
return colors
|
||||||
src_x = np.linspace(0, 1, n_src)
|
# Cache the (src_x, dst_x) linspace arrays keyed by (n_src, target_n)
|
||||||
dst_x = np.linspace(0, 1, target_n)
|
# exactly like the zone path, so they are not reallocated every frame.
|
||||||
|
lkey = (n_src, target_n)
|
||||||
|
linspaces = self._resize_linspace_cache.get(lkey)
|
||||||
|
if linspaces is None:
|
||||||
|
linspaces = (np.linspace(0, 1, n_src), np.linspace(0, 1, target_n))
|
||||||
|
self._resize_linspace_cache[lkey] = linspaces
|
||||||
|
src_x, dst_x = linspaces
|
||||||
buf = self._resize_buf
|
buf = self._resize_buf
|
||||||
for ch in range(3):
|
for ch in range(3):
|
||||||
np.copyto(
|
np.copyto(
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from ledgrab.core.devices.led_client import (
|
|||||||
check_device_health,
|
check_device_health,
|
||||||
get_device_capabilities,
|
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
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -128,6 +130,35 @@ class DeviceHealthMixin:
|
|||||||
"latency_ms": state.health.latency_ms,
|
"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
|
# Auto-sync LED count
|
||||||
reported = state.health.device_led_count
|
reported = state.health.device_led_count
|
||||||
|
|||||||
@@ -137,16 +137,34 @@ class DeviceTestModeMixin:
|
|||||||
await self._send_pixels_to_device(device_id, pixels)
|
await self._send_pixels_to_device(device_id, pixels)
|
||||||
|
|
||||||
async def _send_clear_pixels(self, device_id: str) -> None:
|
async def _send_clear_pixels(self, device_id: str) -> None:
|
||||||
"""Send all-black pixels to clear LED output."""
|
"""Send all-black pixels to clear LED output.
|
||||||
|
|
||||||
|
This is the explicit teardown path — unlike the per-frame
|
||||||
|
``_send_pixels_to_device`` swallow, a clear that must actually take
|
||||||
|
effect retries once before giving up, so a single transient send
|
||||||
|
error doesn't leave the device lit after a session ends.
|
||||||
|
"""
|
||||||
ds = self._devices[device_id]
|
ds = self._devices[device_id]
|
||||||
pixels = [(0, 0, 0)] * ds.led_count
|
pixels = [(0, 0, 0)] * ds.led_count
|
||||||
await self._send_pixels_to_device(device_id, pixels)
|
try:
|
||||||
|
client = await self._get_idle_client(device_id)
|
||||||
|
await client.send_pixels(pixels)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Clear send to {device_id} failed, retrying once: {e}")
|
||||||
|
client = await self._get_idle_client(device_id)
|
||||||
|
await client.send_pixels(pixels)
|
||||||
|
|
||||||
async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
|
async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
|
||||||
"""Send pixels to a device via cached idle client.
|
"""Send pixels to a device via cached idle client.
|
||||||
|
|
||||||
Reuses a cached connection to avoid repeated serial reconnections
|
Reuses a cached connection to avoid repeated serial reconnections
|
||||||
(which trigger Arduino bootloader reset on Adalight devices).
|
(which trigger Arduino bootloader reset on Adalight devices).
|
||||||
|
|
||||||
|
Send failures are logged and swallowed (best-effort per-frame send).
|
||||||
|
Callers that need a *guaranteed* clear — e.g. session teardown that
|
||||||
|
must "never leave the device dark" — must NOT rely on this returning
|
||||||
|
cleanly; use ``_send_clear_pixels`` (which retries once) and treat a
|
||||||
|
propagated exception as a failed clear.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = await self._get_idle_client(device_id)
|
client = await self._get_idle_client(device_id)
|
||||||
|
|||||||
@@ -973,13 +973,18 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
# Use noise at very low frequency for blob movement
|
# Use noise at very low frequency for blob movement
|
||||||
np.multiply(self._s_arange, scale * 0.03, out=self._s_f32_a)
|
np.multiply(self._s_arange, scale * 0.03, out=self._s_f32_a)
|
||||||
|
|
||||||
# Two blob layers at different speeds for organic movement
|
# Two blob layers at different speeds for organic movement.
|
||||||
|
# fbm() returns a shared internal buffer that the next fbm() call
|
||||||
|
# overwrites, so each layer must be copied out — write into the
|
||||||
|
# preallocated scratch buffers instead of allocating per frame.
|
||||||
self._s_f32_a += t * speed * 0.1
|
self._s_f32_a += t * speed * 0.1
|
||||||
layer1 = self._noise.fbm(self._s_f32_a, octaves=3).copy()
|
layer1 = self._s_layer1
|
||||||
|
np.copyto(layer1, self._noise.fbm(self._s_f32_a, octaves=3))
|
||||||
|
|
||||||
np.multiply(self._s_arange, scale * 0.05, out=self._s_f32_a)
|
np.multiply(self._s_arange, scale * 0.05, out=self._s_f32_a)
|
||||||
self._s_f32_a += t * speed * 0.07 + 100.0
|
self._s_f32_a += t * speed * 0.07 + 100.0
|
||||||
layer2 = self._noise.fbm(self._s_f32_a, octaves=2).copy()
|
layer2 = self._s_layer2
|
||||||
|
np.copyto(layer2, self._noise.fbm(self._s_f32_a, octaves=2))
|
||||||
|
|
||||||
# Combine: create blob-like shapes with soft edges
|
# Combine: create blob-like shapes with soft edges
|
||||||
combined = self._s_f32_a
|
combined = self._s_f32_a
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
|||||||
from ledgrab.core.weather.weather_manager import WeatherManager
|
from ledgrab.core.weather.weather_manager import WeatherManager
|
||||||
from ledgrab.core.processing.device_health import DeviceHealthMixin
|
from ledgrab.core.processing.device_health import DeviceHealthMixin
|
||||||
from ledgrab.core.processing.device_test_mode import DeviceTestModeMixin
|
from ledgrab.core.processing.device_test_mode import DeviceTestModeMixin
|
||||||
|
from ledgrab.core.capture.calibration_session import CalibrationChaseMixin
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -106,7 +107,9 @@ class DeviceState:
|
|||||||
zone_mode: str = "combined"
|
zone_mode: str = "combined"
|
||||||
|
|
||||||
|
|
||||||
class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin):
|
class ProcessorManager(
|
||||||
|
AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin, CalibrationChaseMixin
|
||||||
|
):
|
||||||
"""Manages devices and delegates target processing to TargetProcessor instances.
|
"""Manages devices and delegates target processing to TargetProcessor instances.
|
||||||
|
|
||||||
Devices are registered for health monitoring.
|
Devices are registered for health monitoring.
|
||||||
|
|||||||
@@ -739,10 +739,19 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
self._last_preview_data = data
|
self._last_preview_data = data
|
||||||
|
|
||||||
async def _send_safe(ws):
|
# Bound each per-client send to roughly one frame interval so a slow or
|
||||||
|
# backpressured preview WebSocket can never throttle the device send
|
||||||
|
# cadence. Clients that time out or error are dropped from the set.
|
||||||
|
eff_fps = self._effective_fps if self._effective_fps > 0 else 30
|
||||||
|
send_timeout = 1.0 / eff_fps
|
||||||
|
|
||||||
|
async def _send_safe(ws) -> bool:
|
||||||
try:
|
try:
|
||||||
await ws.send_bytes(data)
|
await asyncio.wait_for(ws.send_bytes(data), timeout=send_timeout)
|
||||||
return True
|
return True
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.debug("LED preview broadcast WS send timed out (slow client dropped)")
|
||||||
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("LED preview broadcast WS send failed: %s", e)
|
logger.debug("LED preview broadcast WS send failed: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
"""Playlist engine — background loop that auto-cycles a scene playlist.
|
||||||
|
|
||||||
|
A playlist is an ordered, timed sequence of scene presets. The engine drives
|
||||||
|
**at most one** playlist at a time: starting a new playlist transparently stops
|
||||||
|
any currently-running one. Each cycle re-reads the playlist from the store, so
|
||||||
|
edits (and deletion) take effect at the next cycle boundary without a restart.
|
||||||
|
|
||||||
|
The actual state application reuses ``scene_activator.apply_scene_state`` — the
|
||||||
|
same code path the scene-presets API and the automation engine use — so a
|
||||||
|
playlist step behaves exactly like manually activating that preset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ledgrab.storage.scene_playlist import ScenePlaylist, clamp_duration
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlaylistRuntimeState:
|
||||||
|
"""Volatile runtime state of the (single) active playlist. Not persisted."""
|
||||||
|
|
||||||
|
playlist_id: str
|
||||||
|
playlist_name: str
|
||||||
|
current_index: int
|
||||||
|
item_count: int
|
||||||
|
current_preset_id: str | None
|
||||||
|
started_at: datetime
|
||||||
|
step_started_at: datetime
|
||||||
|
step_duration: float
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"is_running": True,
|
||||||
|
"playlist_id": self.playlist_id,
|
||||||
|
"playlist_name": self.playlist_name,
|
||||||
|
"current_index": self.current_index,
|
||||||
|
"item_count": self.item_count,
|
||||||
|
"current_preset_id": self.current_preset_id,
|
||||||
|
"started_at": self.started_at.isoformat(),
|
||||||
|
"step_started_at": self.step_started_at.isoformat(),
|
||||||
|
"step_duration": self.step_duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_IDLE_STATE = {
|
||||||
|
"is_running": False,
|
||||||
|
"playlist_id": None,
|
||||||
|
"playlist_name": None,
|
||||||
|
"current_index": 0,
|
||||||
|
"item_count": 0,
|
||||||
|
"current_preset_id": None,
|
||||||
|
"started_at": None,
|
||||||
|
"step_started_at": None,
|
||||||
|
"step_duration": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistError(Exception):
|
||||||
|
"""Raised when a playlist cannot be started (empty / not found)."""
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistEngine:
|
||||||
|
"""Cycles a scene playlist's presets on a timer, one playlist at a time."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
playlist_store,
|
||||||
|
scene_preset_store,
|
||||||
|
target_store,
|
||||||
|
processor_manager,
|
||||||
|
):
|
||||||
|
self._playlist_store = playlist_store
|
||||||
|
self._scene_preset_store = scene_preset_store
|
||||||
|
self._target_store = target_store
|
||||||
|
self._manager = processor_manager
|
||||||
|
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._state: PlaylistRuntimeState | None = None
|
||||||
|
# Serialises start/stop so overlapping API calls can't leave two
|
||||||
|
# cycling tasks alive at once.
|
||||||
|
self._lifecycle_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# ===== Public control API =====
|
||||||
|
|
||||||
|
async def start_playlist(self, playlist_id: str) -> PlaylistRuntimeState:
|
||||||
|
"""Start cycling ``playlist_id``, stopping any current playlist first.
|
||||||
|
|
||||||
|
Raises ``PlaylistError`` if the playlist is unknown or has no items.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
playlist = self._playlist_store.get_playlist(playlist_id)
|
||||||
|
except Exception as exc: # EntityNotFoundError / ValueError
|
||||||
|
raise PlaylistError(f"Playlist not found: {playlist_id}") from exc
|
||||||
|
|
||||||
|
if not playlist.items:
|
||||||
|
raise PlaylistError(f"Playlist '{playlist.name}' has no items")
|
||||||
|
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
await self._cancel_task()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
first_item = playlist.items[0]
|
||||||
|
self._state = PlaylistRuntimeState(
|
||||||
|
playlist_id=playlist.id,
|
||||||
|
playlist_name=playlist.name,
|
||||||
|
current_index=0,
|
||||||
|
item_count=len(playlist.items),
|
||||||
|
current_preset_id=first_item.scene_preset_id,
|
||||||
|
started_at=now,
|
||||||
|
step_started_at=now,
|
||||||
|
step_duration=clamp_duration(first_item.duration_seconds),
|
||||||
|
)
|
||||||
|
self._task = asyncio.create_task(self._run(playlist.id))
|
||||||
|
|
||||||
|
self._fire_event("started")
|
||||||
|
logger.info("Playlist '%s' started (%d items)", playlist.name, len(playlist.items))
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the active playlist (if any). Leaves the last scene applied."""
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
was_running = self._task is not None
|
||||||
|
await self._cancel_task()
|
||||||
|
stopped_id = self._state.playlist_id if self._state else None
|
||||||
|
self._state = None
|
||||||
|
if was_running:
|
||||||
|
self._fire_event("stopped", playlist_id=stopped_id)
|
||||||
|
logger.info("Playlist stopped")
|
||||||
|
|
||||||
|
async def stop_if_running(self, playlist_id: str) -> None:
|
||||||
|
"""Stop the playlist only if ``playlist_id`` is the one running.
|
||||||
|
|
||||||
|
Used when a playlist is deleted or edited so a stale snapshot can't keep
|
||||||
|
cycling. The read-compare and the stop happen atomically under the
|
||||||
|
lifecycle lock so a concurrent natural-end / start can't slip a
|
||||||
|
different playlist in between the check and the stop.
|
||||||
|
"""
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
state = self._state
|
||||||
|
if state is None or state.playlist_id != playlist_id:
|
||||||
|
return
|
||||||
|
was_running = self._task is not None
|
||||||
|
await self._cancel_task()
|
||||||
|
stopped_id = self._state.playlist_id if self._state else playlist_id
|
||||||
|
self._state = None
|
||||||
|
if was_running:
|
||||||
|
self._fire_event("stopped", playlist_id=stopped_id)
|
||||||
|
logger.info("Playlist stopped")
|
||||||
|
|
||||||
|
# ===== Query API (used by routes) =====
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
# Snapshot the task ref so a concurrent clear (set to None at an await
|
||||||
|
# boundary) can't turn the deref into an attribute error mid-read.
|
||||||
|
task = self._task
|
||||||
|
return task is not None and not task.done()
|
||||||
|
|
||||||
|
def get_running_playlist_id(self) -> str | None:
|
||||||
|
state = self._state
|
||||||
|
return state.playlist_id if state else None
|
||||||
|
|
||||||
|
def get_state(self) -> dict:
|
||||||
|
# Snapshot both refs once: the event loop won't preempt this sync method
|
||||||
|
# between the reads, but snapshotting also guards against ever returning
|
||||||
|
# a half-cleared state (running True while _state is already None).
|
||||||
|
state = self._state
|
||||||
|
task = self._task
|
||||||
|
if state is not None and task is not None and not task.done():
|
||||||
|
return state.to_dict()
|
||||||
|
return dict(_IDLE_STATE)
|
||||||
|
|
||||||
|
# ===== Internal =====
|
||||||
|
|
||||||
|
async def _cancel_task(self) -> None:
|
||||||
|
"""Cancel and await the cycling task. Caller holds the lifecycle lock."""
|
||||||
|
task = self._task
|
||||||
|
self._task = None
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.error("Playlist task raised on cancel: %s", exc, exc_info=True)
|
||||||
|
|
||||||
|
async def _run(self, playlist_id: str) -> None:
|
||||||
|
"""Cycle the playlist until cancelled, the playlist ends, or it errors."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Re-read each cycle so edits/deletes apply at the boundary.
|
||||||
|
try:
|
||||||
|
playlist = self._playlist_store.get_playlist(playlist_id)
|
||||||
|
except Exception:
|
||||||
|
logger.info("Playlist %s removed while running; stopping", playlist_id)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not playlist.items:
|
||||||
|
logger.info("Playlist '%s' has no items; stopping", playlist.name)
|
||||||
|
break
|
||||||
|
|
||||||
|
applied_any = await self._run_cycle(playlist)
|
||||||
|
|
||||||
|
if not playlist.loop:
|
||||||
|
break
|
||||||
|
if not applied_any:
|
||||||
|
# Every item referenced a missing preset — a looping
|
||||||
|
# playlist would otherwise spin with no dwell. Bail out.
|
||||||
|
logger.warning(
|
||||||
|
"Playlist '%s' applied no valid presets this cycle; stopping",
|
||||||
|
playlist.name,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Natural end (non-loop or guard). Clear state without recursing
|
||||||
|
# through stop() (which would try to cancel this very task). Take
|
||||||
|
# the lifecycle lock so the clear + 'stopped' event are atomic with
|
||||||
|
# respect to a concurrent start/stop/stop_if_running — otherwise the
|
||||||
|
# two could interleave and emit duplicate or contradictory terminal
|
||||||
|
# events. _run never calls _cancel_task and never otherwise holds
|
||||||
|
# this lock, so acquiring it here cannot deadlock; if a canceller
|
||||||
|
# holding the lock cancels us while we wait to acquire it, the
|
||||||
|
# acquire raises CancelledError and we fall through to the handler.
|
||||||
|
ended_id = None
|
||||||
|
should_fire = False
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
# Re-check under the lock: a concurrent start_playlist may have
|
||||||
|
# replaced us while we waited. Only clear if we're still current.
|
||||||
|
if self._task is asyncio.current_task():
|
||||||
|
self._task = None
|
||||||
|
ended_id = self._state.playlist_id if self._state else None
|
||||||
|
self._state = None
|
||||||
|
should_fire = True
|
||||||
|
if should_fire:
|
||||||
|
self._fire_event("stopped", playlist_id=ended_id)
|
||||||
|
logger.info("Playlist '%s' finished", playlist_id)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.error("Playlist run loop error: %s", exc, exc_info=True)
|
||||||
|
|
||||||
|
async def _run_cycle(self, playlist: ScenePlaylist) -> bool:
|
||||||
|
"""Run one pass over the playlist's items. Returns True if any applied."""
|
||||||
|
order = self._resolve_order(playlist)
|
||||||
|
applied_any = False
|
||||||
|
|
||||||
|
for index, item in enumerate(order):
|
||||||
|
duration = clamp_duration(item.duration_seconds)
|
||||||
|
if self._state is not None:
|
||||||
|
self._state.current_index = index
|
||||||
|
self._state.current_preset_id = item.scene_preset_id
|
||||||
|
self._state.step_started_at = datetime.now(timezone.utc)
|
||||||
|
self._state.step_duration = duration
|
||||||
|
|
||||||
|
applied = await self._apply_item(item.scene_preset_id)
|
||||||
|
if applied:
|
||||||
|
applied_any = True
|
||||||
|
self._fire_event("advanced", index=index, preset_id=item.scene_preset_id)
|
||||||
|
# Only dwell on scenes we actually applied; skip missing ones
|
||||||
|
# immediately so the cycle doesn't stall on a dead reference.
|
||||||
|
await asyncio.sleep(duration)
|
||||||
|
|
||||||
|
return applied_any
|
||||||
|
|
||||||
|
def _resolve_order(self, playlist: ScenePlaylist) -> List:
|
||||||
|
if playlist.shuffle and len(playlist.items) > 1:
|
||||||
|
shuffled = list(playlist.items)
|
||||||
|
random.shuffle(shuffled) # noqa: S311 - cosmetic ordering, not security
|
||||||
|
return shuffled
|
||||||
|
return list(playlist.items)
|
||||||
|
|
||||||
|
async def _apply_item(self, preset_id: str) -> bool:
|
||||||
|
"""Apply one scene preset. Returns False if it could not be applied."""
|
||||||
|
if not self._scene_preset_store or not self._target_store or not self._manager:
|
||||||
|
logger.warning("Playlist engine missing stores; cannot apply %s", preset_id)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
preset = self._scene_preset_store.get_preset(preset_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Playlist references missing scene preset %s (skipped)", preset_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
from ledgrab.core.scenes.scene_activator import apply_scene_state
|
||||||
|
|
||||||
|
_status, errors = await apply_scene_state(preset, self._target_store, self._manager)
|
||||||
|
if errors:
|
||||||
|
logger.warning("Playlist step '%s' applied with errors: %s", preset.name, errors)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _fire_event(self, action: str, **extra) -> None:
|
||||||
|
if self._manager is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._manager.fire_event(
|
||||||
|
{
|
||||||
|
"type": "playlist_state_changed",
|
||||||
|
"action": action,
|
||||||
|
"playlist_id": extra.get("playlist_id")
|
||||||
|
or (self._state.playlist_id if self._state else None),
|
||||||
|
**{k: v for k, v in extra.items() if k != "playlist_id"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Playlist event fire failed: %s", exc, exc_info=True)
|
||||||
@@ -35,6 +35,7 @@ import ledgrab.core.audio # noqa: F401 — trigger engine auto-registration
|
|||||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||||
from ledgrab.storage.automation_store import AutomationStore
|
from ledgrab.storage.automation_store import AutomationStore
|
||||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||||
|
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||||
from ledgrab.storage.sync_clock_store import SyncClockStore
|
from ledgrab.storage.sync_clock_store import SyncClockStore
|
||||||
from ledgrab.storage.color_strip_processing_template_store import (
|
from ledgrab.storage.color_strip_processing_template_store import (
|
||||||
ColorStripProcessingTemplateStore,
|
ColorStripProcessingTemplateStore,
|
||||||
@@ -47,6 +48,7 @@ from ledgrab.core.weather.weather_manager import WeatherManager
|
|||||||
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
||||||
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
||||||
from ledgrab.core.automations.automation_engine import AutomationEngine
|
from ledgrab.core.automations.automation_engine import AutomationEngine
|
||||||
|
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
|
||||||
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
||||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||||
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
|
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
|
||||||
@@ -58,6 +60,9 @@ from ledgrab.storage.audio_processing_template_store import AudioProcessingTempl
|
|||||||
from ledgrab.storage.pattern_template_store import PatternTemplateStore
|
from ledgrab.storage.pattern_template_store import PatternTemplateStore
|
||||||
import ledgrab.core.audio.filters # noqa: F401 — trigger audio filter auto-registration
|
import ledgrab.core.audio.filters # noqa: F401 — trigger audio filter auto-registration
|
||||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
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.processing.os_notification_listener import OsNotificationListener
|
||||||
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher
|
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher
|
||||||
from ledgrab.core.update.update_service import UpdateService
|
from ledgrab.core.update.update_service import UpdateService
|
||||||
@@ -157,6 +162,7 @@ audio_template_store = AudioTemplateStore(db)
|
|||||||
value_source_store = ValueSourceStore(db)
|
value_source_store = ValueSourceStore(db)
|
||||||
automation_store = AutomationStore(db)
|
automation_store = AutomationStore(db)
|
||||||
scene_preset_store = ScenePresetStore(db)
|
scene_preset_store = ScenePresetStore(db)
|
||||||
|
scene_playlist_store = ScenePlaylistStore(db)
|
||||||
sync_clock_store = SyncClockStore(db)
|
sync_clock_store = SyncClockStore(db)
|
||||||
cspt_store = ColorStripProcessingTemplateStore(db)
|
cspt_store = ColorStripProcessingTemplateStore(db)
|
||||||
gradient_store = GradientStore(db)
|
gradient_store = GradientStore(db)
|
||||||
@@ -181,6 +187,10 @@ pattern_template_store = PatternTemplateStore(db)
|
|||||||
game_event_bus = GameEventBus()
|
game_event_bus = GameEventBus()
|
||||||
register_community_adapters()
|
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(
|
processor_manager = ProcessorManager(
|
||||||
ProcessorDependencies(
|
ProcessorDependencies(
|
||||||
picture_source_store=picture_source_store,
|
picture_source_store=picture_source_store,
|
||||||
@@ -278,6 +288,26 @@ async def lifespan(app: FastAPI):
|
|||||||
value_source_store=value_source_store,
|
value_source_store=value_source_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create playlist engine — auto-cycles scene presets, one playlist at a
|
||||||
|
# time. Idle (no background task) until a playlist is started via the API.
|
||||||
|
playlist_engine = PlaylistEngine(
|
||||||
|
playlist_store=scene_playlist_store,
|
||||||
|
scene_preset_store=scene_preset_store,
|
||||||
|
target_store=output_target_store,
|
||||||
|
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
|
# Create auto-backup engine — derive paths from database location so that
|
||||||
# demo mode auto-backups go to data/demo/ instead of data/.
|
# demo mode auto-backups go to data/demo/ instead of data/.
|
||||||
_data_dir = Path(config.storage.database_file).parent
|
_data_dir = Path(config.storage.database_file).parent
|
||||||
@@ -314,7 +344,9 @@ async def lifespan(app: FastAPI):
|
|||||||
value_source_store=value_source_store,
|
value_source_store=value_source_store,
|
||||||
automation_store=automation_store,
|
automation_store=automation_store,
|
||||||
scene_preset_store=scene_preset_store,
|
scene_preset_store=scene_preset_store,
|
||||||
|
scene_playlist_store=scene_playlist_store,
|
||||||
automation_engine=automation_engine,
|
automation_engine=automation_engine,
|
||||||
|
playlist_engine=playlist_engine,
|
||||||
auto_backup_engine=auto_backup_engine,
|
auto_backup_engine=auto_backup_engine,
|
||||||
sync_clock_store=sync_clock_store,
|
sync_clock_store=sync_clock_store,
|
||||||
sync_clock_manager=sync_clock_manager,
|
sync_clock_manager=sync_clock_manager,
|
||||||
@@ -333,7 +365,13 @@ async def lifespan(app: FastAPI):
|
|||||||
http_endpoint_store=http_endpoint_store,
|
http_endpoint_store=http_endpoint_store,
|
||||||
audio_processing_template_store=audio_processing_template_store,
|
audio_processing_template_store=audio_processing_template_store,
|
||||||
pattern_template_store=pattern_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
|
# Register devices in processor manager for health monitoring
|
||||||
devices = device_store.get_all_devices()
|
devices = device_store.get_all_devices()
|
||||||
@@ -376,6 +414,9 @@ async def lifespan(app: FastAPI):
|
|||||||
# Start auto-backup engine (periodic configuration backups)
|
# Start auto-backup engine (periodic configuration backups)
|
||||||
await auto_backup_engine.start()
|
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)
|
# Start update checker (periodic release polling)
|
||||||
await update_service.start()
|
await update_service.start()
|
||||||
|
|
||||||
@@ -424,6 +465,19 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Shutdown step '%s' raised: %s", label, 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.
|
# Legacy hook — SQLite stores are write-through so this only logs.
|
||||||
# Durability comes from PRAGMA synchronous=FULL + the explicit
|
# Durability comes from PRAGMA synchronous=FULL + the explicit
|
||||||
# wal_checkpoint(TRUNCATE) in Database.close() at the end of this block.
|
# wal_checkpoint(TRUNCATE) in Database.close() at the end of this block.
|
||||||
@@ -436,6 +490,16 @@ async def lifespan(app: FastAPI):
|
|||||||
# would talk to processors mid-shutdown.
|
# would talk to processors mid-shutdown.
|
||||||
await _bounded("automation_engine.stop", automation_engine.stop(), timeout=1.5)
|
await _bounded("automation_engine.stop", automation_engine.stop(), timeout=1.5)
|
||||||
|
|
||||||
|
# Stop the playlist engine so its cycling task can't apply scenes mid-shutdown.
|
||||||
|
await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0)
|
||||||
|
|
||||||
|
# Tear down any active calibration session BEFORE stop_all so the device
|
||||||
|
# isn't left stuck in the white-chase and its prior target is restored.
|
||||||
|
# stop() is a no-op when no session is active.
|
||||||
|
from ledgrab.core.capture.calibration_session import get_calibration_session
|
||||||
|
|
||||||
|
await _bounded("calibration_session.stop", get_calibration_session().stop(), timeout=1.0)
|
||||||
|
|
||||||
# Stop discovery watcher and OS notification listener so they stop
|
# Stop discovery watcher and OS notification listener so they stop
|
||||||
# firing events into a shutting-down processor manager.
|
# firing events into a shutting-down processor manager.
|
||||||
if discovery_watcher is not None:
|
if discovery_watcher is not None:
|
||||||
@@ -486,6 +550,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
await _bounded("update_service.stop", update_service.stop(), timeout=0.5)
|
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("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
|
# 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
|
# 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;
|
||||||
|
}
|
||||||
@@ -19,5 +19,6 @@
|
|||||||
@import './graph-editor.css';
|
@import './graph-editor.css';
|
||||||
@import './appearance.css';
|
@import './appearance.css';
|
||||||
@import './game-integration.css';
|
@import './game-integration.css';
|
||||||
|
@import './activity-log.css';
|
||||||
@import './mobile.css';
|
@import './mobile.css';
|
||||||
@import './tv.css';
|
@import './tv.css';
|
||||||
|
|||||||
@@ -79,6 +79,12 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-screen-total:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-screen-total.mismatch {
|
.preview-screen-total.mismatch {
|
||||||
color: #FFC107;
|
color: #FFC107;
|
||||||
}
|
}
|
||||||
@@ -123,6 +129,12 @@
|
|||||||
background: rgba(128, 128, 128, 0.25);
|
background: rgba(128, 128, 128, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edge-toggle:focus-visible {
|
||||||
|
background: rgba(128, 128, 128, 0.25);
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-edge.edge-disabled {
|
.preview-edge.edge-disabled {
|
||||||
opacity: 0.25;
|
opacity: 0.25;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -374,6 +386,13 @@
|
|||||||
color: rgba(76, 175, 80, 0.6);
|
color: rgba(76, 175, 80, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-corner:focus-visible {
|
||||||
|
color: rgba(76, 175, 80, 0.6);
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-corner.active:hover {
|
.preview-corner.active:hover {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
@@ -412,6 +431,12 @@
|
|||||||
background: rgba(255, 255, 255, 0.25);
|
background: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.direction-toggle:focus-visible {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.direction-toggle #direction-icon {
|
.direction-toggle #direction-icon {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2512,7 +2512,11 @@
|
|||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
/* `.modal-error` is the convention recommended in contexts/frontend.md and is
|
||||||
|
used by several modals; it aliases `.error-message` so both render the same
|
||||||
|
inline error banner. */
|
||||||
|
.error-message,
|
||||||
|
.modal-error {
|
||||||
background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
|
background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
|
||||||
border: 1px solid var(--danger-color);
|
border: 1px solid var(--danger-color);
|
||||||
color: var(--danger-color);
|
color: var(--danger-color);
|
||||||
|
|||||||
@@ -36,7 +36,16 @@ import {
|
|||||||
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
|
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
|
||||||
startIntegrationsTutorial,
|
startIntegrationsTutorial,
|
||||||
closeTutorial, tutorialNext, tutorialPrev,
|
closeTutorial, tutorialNext, tutorialPrev,
|
||||||
|
TOUR_KEY,
|
||||||
} from './features/tutorials.ts';
|
} from './features/tutorials.ts';
|
||||||
|
import {
|
||||||
|
openSetupWizard, closeSetupWizard,
|
||||||
|
checkAndOpenWizardIfNeeded,
|
||||||
|
wizardNext, wizardBack, wizardSkip, wizardFinish,
|
||||||
|
wizardShowManual, wizardHideManual, wizardRescan,
|
||||||
|
wizardSelectDiscovered, wizardAddManualDevice, wizardUseExistingDevice,
|
||||||
|
wizardSelectDisplay,
|
||||||
|
} from './features/setup-wizard.ts';
|
||||||
|
|
||||||
// Layer 4: devices, dashboard, streams, pattern-templates, automations
|
// Layer 4: devices, dashboard, streams, pattern-templates, automations
|
||||||
import {
|
import {
|
||||||
@@ -116,6 +125,11 @@ import {
|
|||||||
activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
|
activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
|
||||||
addSceneTarget,
|
addSceneTarget,
|
||||||
} from './features/scene-presets.ts';
|
} from './features/scene-presets.ts';
|
||||||
|
import {
|
||||||
|
openPlaylistEditor, editPlaylist, savePlaylist, closePlaylistEditor,
|
||||||
|
clonePlaylist, deletePlaylist, addPlaylistItem,
|
||||||
|
startScenePlaylist, stopScenePlaylist,
|
||||||
|
} from './features/scene-playlists.ts';
|
||||||
|
|
||||||
// Layer 5: device-discovery, targets
|
// Layer 5: device-discovery, targets
|
||||||
import {
|
import {
|
||||||
@@ -198,12 +212,39 @@ import {
|
|||||||
updateOffsetSkipLock, updateCalibrationPreview,
|
updateOffsetSkipLock, updateCalibrationPreview,
|
||||||
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
|
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
|
||||||
showCSSCalibration, toggleCalibrationOverlay,
|
showCSSCalibration, toggleCalibrationOverlay,
|
||||||
|
openAutoCalFromCalibration,
|
||||||
} from './features/calibration.ts';
|
} from './features/calibration.ts';
|
||||||
import {
|
import {
|
||||||
showAdvancedCalibration, closeAdvancedCalibration, saveAdvancedCalibration,
|
showAdvancedCalibration, closeAdvancedCalibration, saveAdvancedCalibration,
|
||||||
addCalibrationLine, removeCalibrationLine, selectCalibrationLine, moveCalibrationLine,
|
addCalibrationLine, removeCalibrationLine, selectCalibrationLine, moveCalibrationLine,
|
||||||
updateCalibrationLine, resetCalibrationView,
|
updateCalibrationLine, resetCalibrationView,
|
||||||
} from './features/advanced-calibration.ts';
|
} from './features/advanced-calibration.ts';
|
||||||
|
import {
|
||||||
|
showAutoCalibration, closeAutoCalModal,
|
||||||
|
autoCalSelectDevice, autoCalSetCorner, autoCalSetDirection,
|
||||||
|
autoCalBackToCorner, autoCalBackToDirection,
|
||||||
|
autoCalSweepForward, autoCalSweepBack, autoCalMarkCorner,
|
||||||
|
autoCalSolve, autoCalSave, autoCalCancel,
|
||||||
|
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
|
// Layer 5.5: graph editor
|
||||||
import {
|
import {
|
||||||
@@ -234,6 +275,7 @@ import {
|
|||||||
loadDaylightTimezone, saveDaylightTimezone,
|
loadDaylightTimezone, saveDaylightTimezone,
|
||||||
requestNotifPermissionFromSettings, testNotifFromSettings,
|
requestNotifPermissionFromSettings, testNotifFromSettings,
|
||||||
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
|
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
|
||||||
|
loadActivityLogSettings, saveActivityLogSettings, activityLogSettingsExport, clearActivityLog,
|
||||||
} from './features/settings.ts';
|
} from './features/settings.ts';
|
||||||
import {
|
import {
|
||||||
loadUpdateStatus, initUpdateListener, checkForUpdates,
|
loadUpdateStatus, initUpdateListener, checkForUpdates,
|
||||||
@@ -315,6 +357,21 @@ Object.assign(window, {
|
|||||||
selectDisplay,
|
selectDisplay,
|
||||||
formatDisplayLabel,
|
formatDisplayLabel,
|
||||||
|
|
||||||
|
// setup wizard
|
||||||
|
openSetupWizard,
|
||||||
|
closeSetupWizard,
|
||||||
|
wizardNext,
|
||||||
|
wizardBack,
|
||||||
|
wizardSkip,
|
||||||
|
wizardFinish,
|
||||||
|
wizardShowManual,
|
||||||
|
wizardHideManual,
|
||||||
|
wizardRescan,
|
||||||
|
wizardSelectDiscovered,
|
||||||
|
wizardAddManualDevice,
|
||||||
|
wizardUseExistingDevice,
|
||||||
|
wizardSelectDisplay,
|
||||||
|
|
||||||
// tutorials
|
// tutorials
|
||||||
startCalibrationTutorial,
|
startCalibrationTutorial,
|
||||||
startDeviceTutorial,
|
startDeviceTutorial,
|
||||||
@@ -463,6 +520,17 @@ Object.assign(window, {
|
|||||||
recaptureScenePreset,
|
recaptureScenePreset,
|
||||||
addSceneTarget,
|
addSceneTarget,
|
||||||
|
|
||||||
|
// scene playlists — modal buttons + mod-card inline handlers
|
||||||
|
openPlaylistEditor,
|
||||||
|
editPlaylist,
|
||||||
|
savePlaylist,
|
||||||
|
closePlaylistEditor,
|
||||||
|
clonePlaylist,
|
||||||
|
deletePlaylist,
|
||||||
|
addPlaylistItem,
|
||||||
|
startScenePlaylist,
|
||||||
|
stopScenePlaylist,
|
||||||
|
|
||||||
// integrations
|
// integrations
|
||||||
loadIntegrations,
|
loadIntegrations,
|
||||||
switchIntegrationTab,
|
switchIntegrationTab,
|
||||||
@@ -604,6 +672,24 @@ Object.assign(window, {
|
|||||||
toggleTestEdge,
|
toggleTestEdge,
|
||||||
showCSSCalibration,
|
showCSSCalibration,
|
||||||
toggleCalibrationOverlay,
|
toggleCalibrationOverlay,
|
||||||
|
openAutoCalFromCalibration,
|
||||||
|
|
||||||
|
// auto-calibration wizard
|
||||||
|
showAutoCalibration,
|
||||||
|
closeAutoCalModal,
|
||||||
|
autoCalSelectDevice,
|
||||||
|
autoCalSetCorner,
|
||||||
|
autoCalSetDirection,
|
||||||
|
autoCalBackToCorner,
|
||||||
|
autoCalBackToDirection,
|
||||||
|
autoCalSweepForward,
|
||||||
|
autoCalSweepBack,
|
||||||
|
autoCalMarkCorner,
|
||||||
|
autoCalSolve,
|
||||||
|
autoCalSave,
|
||||||
|
autoCalCancel,
|
||||||
|
mountAutoCalibration,
|
||||||
|
unmountAutoCalibration,
|
||||||
|
|
||||||
// advanced calibration
|
// advanced calibration
|
||||||
showAdvancedCalibration,
|
showAdvancedCalibration,
|
||||||
@@ -674,6 +760,10 @@ Object.assign(window, {
|
|||||||
saveExternalUrl,
|
saveExternalUrl,
|
||||||
revertExternalUrl,
|
revertExternalUrl,
|
||||||
getBaseOrigin,
|
getBaseOrigin,
|
||||||
|
loadActivityLogSettings,
|
||||||
|
saveActivityLogSettings,
|
||||||
|
activityLogSettingsExport,
|
||||||
|
clearActivityLog,
|
||||||
|
|
||||||
// update
|
// update
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
@@ -695,6 +785,22 @@ Object.assign(window, {
|
|||||||
applyStylePreset,
|
applyStylePreset,
|
||||||
applyBgEffect,
|
applyBgEffect,
|
||||||
renderAppearanceTab,
|
renderAppearanceTab,
|
||||||
|
|
||||||
|
// activity log
|
||||||
|
loadActivityLog,
|
||||||
|
activityLogToggleDetail,
|
||||||
|
activityLogToggleCat,
|
||||||
|
activityLogToggleSev,
|
||||||
|
activityLogOnSearch,
|
||||||
|
activityLogOnActor,
|
||||||
|
activityLogOnEntityType,
|
||||||
|
activityLogOnSince,
|
||||||
|
activityLogOnUntil,
|
||||||
|
activityLogClearFilters,
|
||||||
|
activityLogPreset,
|
||||||
|
activityLogLoadMore,
|
||||||
|
activityLogExport,
|
||||||
|
activityLogNavigateToEntity,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Global keyboard shortcuts ───
|
// ─── Global keyboard shortcuts ───
|
||||||
@@ -712,7 +818,7 @@ document.addEventListener('keydown', (e) => {
|
|||||||
|
|
||||||
// Tab shortcuts: Ctrl+1..4 (skip when typing in inputs)
|
// Tab shortcuts: Ctrl+1..4 (skip when typing in inputs)
|
||||||
if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
|
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];
|
const tab = tabMap[e.key];
|
||||||
if (tab) {
|
if (tab) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -908,8 +1014,17 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
setProjectUrls(serverRepoUrl, serverDonateUrl);
|
setProjectUrls(serverRepoUrl, serverDonateUrl);
|
||||||
initDonationBanner();
|
initDonationBanner();
|
||||||
|
|
||||||
// Show getting-started tutorial on first visit
|
// First-run: wizard wins over the tooltip tour.
|
||||||
if (!localStorage.getItem('tour_completed')) {
|
//
|
||||||
|
// Precedence (explicit):
|
||||||
|
// 1. If backend says onboarded=false AND no output targets exist
|
||||||
|
// → open the setup wizard (suppresses tooltip tour — wizard owns
|
||||||
|
// the first-run experience; it sets localStorage TOUR_KEY on
|
||||||
|
// completion/skip so the tour never double-fires on reload).
|
||||||
|
// 2. Otherwise (already onboarded, or has targets but no wizard flag)
|
||||||
|
// → fall back to the existing tooltip tour logic unchanged.
|
||||||
|
const wizardOpened = await checkAndOpenWizardIfNeeded();
|
||||||
|
if (!wizardOpened && !localStorage.getItem(TOUR_KEY)) {
|
||||||
setTimeout(() => startGettingStartedTutorial(), 600);
|
setTimeout(() => startGettingStartedTutorial(), 600);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import {
|
import {
|
||||||
devicesCache, outputTargetsCache, colorStripSourcesCache,
|
devicesCache, outputTargetsCache, colorStripSourcesCache,
|
||||||
streamsCache, audioSourcesCache, valueSourcesCache,
|
streamsCache, audioSourcesCache, valueSourcesCache,
|
||||||
syncClocksCache, automationsCacheObj, scenePresetsCache,
|
syncClocksCache, automationsCacheObj, scenePresetsCache, scenePlaylistsCache,
|
||||||
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
|
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
|
||||||
patternTemplatesCache,
|
patternTemplatesCache,
|
||||||
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
|
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
|
||||||
@@ -26,6 +26,7 @@ const ENTITY_CACHE_MAP = {
|
|||||||
sync_clock: syncClocksCache,
|
sync_clock: syncClocksCache,
|
||||||
automation: automationsCacheObj,
|
automation: automationsCacheObj,
|
||||||
scene_preset: scenePresetsCache,
|
scene_preset: scenePresetsCache,
|
||||||
|
scene_playlist: scenePlaylistsCache,
|
||||||
capture_template: captureTemplatesCache,
|
capture_template: captureTemplatesCache,
|
||||||
audio_template: audioTemplatesCache,
|
audio_template: audioTemplatesCache,
|
||||||
pp_template: ppTemplatesCache,
|
pp_template: ppTemplatesCache,
|
||||||
@@ -51,6 +52,7 @@ const ENTITY_LOADER_MAP = {
|
|||||||
pp_template: 'loadPictureSources',
|
pp_template: 'loadPictureSources',
|
||||||
automation: 'loadAutomations',
|
automation: 'loadAutomations',
|
||||||
scene_preset: 'loadAutomations',
|
scene_preset: 'loadAutomations',
|
||||||
|
scene_playlist: 'loadAutomations',
|
||||||
weather_source: 'loadIntegrations',
|
weather_source: 'loadIntegrations',
|
||||||
home_assistant_source: 'loadIntegrations',
|
home_assistant_source: 'loadIntegrations',
|
||||||
mqtt_source: 'loadIntegrations',
|
mqtt_source: 'loadIntegrations',
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { openAuthedWs } from './ws-auth.ts';
|
|||||||
* update_download_progress — update_service.py (consumed by features/update.ts)
|
* update_download_progress — update_service.py (consumed by features/update.ts)
|
||||||
* device_discovered — discovery_watcher.py (consumed by features/notifications-watcher.ts)
|
* device_discovered — discovery_watcher.py (consumed by features/notifications-watcher.ts)
|
||||||
* device_lost — 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
|
* Missing any of these silently breaks the corresponding UI flow — keep
|
||||||
* this list in sync when adding new event types on the server side.
|
* this list in sync when adding new event types on the server side.
|
||||||
@@ -40,12 +41,14 @@ const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|||||||
'server_restarting',
|
'server_restarting',
|
||||||
'state_change',
|
'state_change',
|
||||||
'automation_state_changed',
|
'automation_state_changed',
|
||||||
|
'playlist_state_changed',
|
||||||
'entity_changed',
|
'entity_changed',
|
||||||
'device_health_changed',
|
'device_health_changed',
|
||||||
'update_available',
|
'update_available',
|
||||||
'update_download_progress',
|
'update_download_progress',
|
||||||
'device_discovered',
|
'device_discovered',
|
||||||
'device_lost',
|
'device_lost',
|
||||||
|
'activity_logged', // source: core/activity_log/recorder.py
|
||||||
]);
|
]);
|
||||||
|
|
||||||
interface ServerEventEnvelope {
|
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
|
// 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"/>';
|
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.
|
// Easing curve glyphs — custom mini-charts that draw the actual curve.
|
||||||
// Curve travels from (4, 20) to (20, 4); each path renders the easing
|
// Curve travels from (4, 20) to (20, 4); each path renders the easing
|
||||||
// function directly so the picker shows the shape, not a metaphor.
|
// 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_GIT_MERGE = _svg(P.gitMerge);
|
||||||
export const ICON_COPY = _svg(P.copy);
|
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 ─────────────────────────────────
|
// ── Game integration icons ─────────────────────────────────
|
||||||
|
|
||||||
export const ICON_GAMEPAD = _svg(P.gamepad2);
|
export const ICON_GAMEPAD = _svg(P.gamepad2);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
import { DataCache } from './cache.ts';
|
import { DataCache } from './cache.ts';
|
||||||
import type {
|
import type {
|
||||||
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
||||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
ValueSource, AudioSource, PictureSource, ScenePreset, ScenePlaylist,
|
||||||
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo,
|
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||||
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||||
@@ -436,6 +436,11 @@ export const scenePresetsCache = new DataCache<ScenePreset[]>({
|
|||||||
extractData: json => json.presets || [],
|
extractData: json => json.presets || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const scenePlaylistsCache = new DataCache<ScenePlaylist[]>({
|
||||||
|
endpoint: '/scene-playlists',
|
||||||
|
extractData: json => json.playlists || [],
|
||||||
|
});
|
||||||
|
|
||||||
export interface GradientEntity {
|
export interface GradientEntity {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const TAB_REGISTRY: Readonly<Record<string, TabConfig>> = {
|
|||||||
automations: { loadFnName: 'loadAutomations',
|
automations: { loadFnName: 'loadAutomations',
|
||||||
subTab: { storageKey: 'activeAutomationTab', defaultSubTab: 'automations', switchFnName: 'switchAutomationTab' } },
|
subTab: { storageKey: 'activeAutomationTab', defaultSubTab: 'automations', switchFnName: 'switchAutomationTab' } },
|
||||||
graph: { loadFnName: 'loadGraphEditor' },
|
graph: { loadFnName: 'loadGraphEditor' },
|
||||||
|
activity_log: { loadFnName: 'loadActivityLog', autoRefresh: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get the full config for a tab, or undefined if not registered. */
|
/** Get the full config for a tab, or undefined if not registered. */
|
||||||
|
|||||||
@@ -555,6 +555,90 @@ export function formatCompact(n: number | null | undefined) {
|
|||||||
return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B';
|
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 {
|
export function formatUptime(seconds: number | null | undefined): string {
|
||||||
if (!seconds || seconds <= 0) return '-';
|
if (!seconds || seconds <= 0) return '-';
|
||||||
const total = Math.floor(seconds);
|
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, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,840 @@
|
|||||||
|
/**
|
||||||
|
* Auto-Calibration flow — guided LED-chase corner-tap wizard.
|
||||||
|
*
|
||||||
|
* Exports `mountAutoCalibration` / `unmountAutoCalibration` so Phase 4's
|
||||||
|
* wizard can embed this as a step without modification.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Device selection (EntitySelect; skipped when deviceId supplied)
|
||||||
|
* 2. Start corner — light index 0; user taps which corner is lit → start_position
|
||||||
|
* 3. Direction — advance a few indices; user identifies direction → layout
|
||||||
|
* 4. Tap-to-mark-corners — dot sweeps; user taps NEXT at each physical corner
|
||||||
|
* (first tap = corner at index 0, per Phase 1 solver contract)
|
||||||
|
* 5. Preview & Save — POST /calibration/solve → summary → PUT CSS hot-reload
|
||||||
|
*
|
||||||
|
* Session contract (Phase 1 handoff):
|
||||||
|
* POST /api/v1/calibration/session → start (stops running target)
|
||||||
|
* POST /api/v1/calibration/session/position → advance chase pixel
|
||||||
|
* POST /api/v1/calibration/session/stop → ALWAYS call on exit / error
|
||||||
|
* POST /api/v1/calibration/solve → pure solver (no persist)
|
||||||
|
* PUT /api/v1/color-strip-sources/{id} → persist + hot-reload
|
||||||
|
*
|
||||||
|
* CRITICAL: the first corner tap corresponds to LED index 0 so the solver's
|
||||||
|
* `corner_indices[0] == 0` matches `solve_calibration`'s assumption that the
|
||||||
|
* start corner is at strip index 0 (Phase 1 review finding).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiPost, apiPut } from '../core/api-client.ts';
|
||||||
|
import { colorStripSourcesCache, devicesCache } from '../core/state.ts';
|
||||||
|
import { t } from '../core/i18n.ts';
|
||||||
|
import { showToast } from '../core/ui.ts';
|
||||||
|
import { Modal } from '../core/modal.ts';
|
||||||
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
|
import { renderDeviceIcon } from '../core/device-icons.ts';
|
||||||
|
import {
|
||||||
|
ICON_DEVICE, ICON_ROTATE_CW, ICON_ROTATE_CCW,
|
||||||
|
ICON_CALIBRATION, ICON_OK,
|
||||||
|
} from '../core/icons.ts';
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type StartPosition = 'top_left' | 'top_right' | 'bottom_left' | 'bottom_right';
|
||||||
|
type Layout = 'clockwise' | 'counterclockwise';
|
||||||
|
type AutoCalStep = 'device' | 'corner' | 'direction' | 'corners' | 'preview';
|
||||||
|
|
||||||
|
interface CalibrationSessionState {
|
||||||
|
active: boolean;
|
||||||
|
device_id: string | null;
|
||||||
|
led_count: number;
|
||||||
|
prior_target_id: string | null;
|
||||||
|
last_activity: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SolvedCalibration {
|
||||||
|
mode: 'simple';
|
||||||
|
layout: string;
|
||||||
|
start_position: string;
|
||||||
|
leds_top: number;
|
||||||
|
leds_right: number;
|
||||||
|
leds_bottom: number;
|
||||||
|
leds_left: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoCalState {
|
||||||
|
step: AutoCalStep;
|
||||||
|
cssId: string;
|
||||||
|
cssSourceType: string;
|
||||||
|
deviceId: string;
|
||||||
|
ledCount: number;
|
||||||
|
startPosition: StartPosition | null;
|
||||||
|
layout: Layout | null;
|
||||||
|
/** Strip indices of the 4 physical corners, in strip-walk order.
|
||||||
|
* cornerIndices[0] is ALWAYS 0 (start corner = LED index 0). */
|
||||||
|
cornerIndices: number[];
|
||||||
|
currentIndex: number;
|
||||||
|
sessionActive: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
solved: SolvedCalibration | null;
|
||||||
|
errorMsg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for `mountAutoCalibration()`. */
|
||||||
|
export interface AutoCalOptions {
|
||||||
|
/** DOM container element to render wizard steps into. */
|
||||||
|
container: HTMLElement;
|
||||||
|
/** Color-strip source ID being calibrated. */
|
||||||
|
cssId: string;
|
||||||
|
/** Pre-selected device ID; if supplied the device-picker step is skipped. */
|
||||||
|
deviceId?: string;
|
||||||
|
/** Called after successful save. */
|
||||||
|
onComplete?: () => void;
|
||||||
|
/** Called after user cancels (session already stopped before this fires). */
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Module-level singleton ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _state: AutoCalState | null = null;
|
||||||
|
let _opts: AutoCalOptions | null = null;
|
||||||
|
let _deviceEntitySelect: EntitySelect | null = null;
|
||||||
|
/** First render after mount: let the containing Modal's autofocus win; only
|
||||||
|
* steal focus on subsequent step transitions (for D-pad / TV navigation). */
|
||||||
|
let _firstRender = true;
|
||||||
|
|
||||||
|
// ── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount the auto-calibration flow into the given container.
|
||||||
|
*
|
||||||
|
* Phase 4 usage:
|
||||||
|
* ```ts
|
||||||
|
* await mountAutoCalibration({
|
||||||
|
* container: document.getElementById('wizard-body')!,
|
||||||
|
* cssId: sourceId,
|
||||||
|
* deviceId: inferredDeviceId, // optional
|
||||||
|
* onComplete: () => wizard.next(),
|
||||||
|
* onCancel: () => wizard.close(),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
* Call `unmountAutoCalibration()` when the containing modal closes to guarantee
|
||||||
|
* the calibration session is stopped.
|
||||||
|
*/
|
||||||
|
export async function mountAutoCalibration(opts: AutoCalOptions): Promise<void> {
|
||||||
|
await unmountAutoCalibration();
|
||||||
|
_firstRender = true;
|
||||||
|
_opts = opts;
|
||||||
|
|
||||||
|
let cssSourceType = 'picture';
|
||||||
|
try {
|
||||||
|
const sources = await colorStripSourcesCache.fetch() as { id: string; source_type?: string }[];
|
||||||
|
const src = sources.find(s => s.id === opts.cssId);
|
||||||
|
if (src) cssSourceType = src.source_type || 'picture';
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
|
||||||
|
_state = {
|
||||||
|
step: opts.deviceId ? 'corner' : 'device',
|
||||||
|
cssId: opts.cssId,
|
||||||
|
cssSourceType,
|
||||||
|
deviceId: opts.deviceId || '',
|
||||||
|
ledCount: 0,
|
||||||
|
startPosition: null,
|
||||||
|
layout: null,
|
||||||
|
cornerIndices: [],
|
||||||
|
currentIndex: 0,
|
||||||
|
sessionActive: false,
|
||||||
|
busy: false,
|
||||||
|
solved: null,
|
||||||
|
errorMsg: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
_render();
|
||||||
|
|
||||||
|
if (opts.deviceId) {
|
||||||
|
_state.deviceId = opts.deviceId;
|
||||||
|
await _startSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmount: stop any active session, destroy widgets, clear container.
|
||||||
|
* Safe to call when nothing is mounted.
|
||||||
|
*/
|
||||||
|
export async function unmountAutoCalibration(): Promise<void> {
|
||||||
|
if (_deviceEntitySelect) { _deviceEntitySelect.destroy(); _deviceEntitySelect = null; }
|
||||||
|
if (_state?.sessionActive) {
|
||||||
|
await _stopSession().catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
if (_opts?.container) _opts.container.innerHTML = '';
|
||||||
|
_state = null;
|
||||||
|
_opts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal render ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _render(): void {
|
||||||
|
if (!_opts || !_state) return;
|
||||||
|
switch (_state.step) {
|
||||||
|
case 'device': _renderDevice(); break;
|
||||||
|
case 'corner': _renderCorner(); break;
|
||||||
|
case 'direction': _renderDirection(); break;
|
||||||
|
case 'corners': _renderCorners(); break;
|
||||||
|
case 'preview': _renderPreview(); break;
|
||||||
|
}
|
||||||
|
_focusFirstControl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move focus to the step's first focusable control after a re-render so D-pad /
|
||||||
|
* TV WebView users don't land on a node that was just removed. Skipped on the
|
||||||
|
* very first render so it doesn't fight the Modal's own initial autofocus.
|
||||||
|
*/
|
||||||
|
function _focusFirstControl(): void {
|
||||||
|
if (_firstRender) {
|
||||||
|
_firstRender = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const container = _opts?.container;
|
||||||
|
if (!container) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = container.querySelector(
|
||||||
|
'button:not([disabled]),[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled])'
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (el && el.offsetParent !== null) el.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 1: Device picker ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderDevice(): void {
|
||||||
|
if (!_opts) return;
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="device">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon">${ICON_DEVICE}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(t('autocal.device.title'))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(t('autocal.device.desc'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:16px;">
|
||||||
|
<label for="autocal-device-select">${_esc(t('autocal.device.label'))}</label>
|
||||||
|
<select id="autocal-device-select"></select>
|
||||||
|
</div>
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
<button class="btn btn-primary" onclick="autoCalSelectDevice()">${_esc(t('autocal.btn.next'))}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_populateDeviceSelect();
|
||||||
|
_showError(_state?.errorMsg || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _populateDeviceSelect(): Promise<void> {
|
||||||
|
const sel = document.getElementById('autocal-device-select') as HTMLSelectElement | null;
|
||||||
|
if (!sel) return;
|
||||||
|
let devices: { id: string; name: string; led_count: number; icon?: string }[] = [];
|
||||||
|
try { devices = await devicesCache.fetch() as typeof devices; } catch { /* empty */ }
|
||||||
|
|
||||||
|
sel.innerHTML = '';
|
||||||
|
devices.forEach(d => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = d.id;
|
||||||
|
opt.textContent = d.name;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_deviceEntitySelect) { _deviceEntitySelect.destroy(); _deviceEntitySelect = null; }
|
||||||
|
if (devices.length > 0) {
|
||||||
|
_deviceEntitySelect = new EntitySelect({
|
||||||
|
target: sel,
|
||||||
|
getItems: () => devices.map(d => ({
|
||||||
|
value: d.id,
|
||||||
|
label: d.name,
|
||||||
|
icon: renderDeviceIcon(d.icon) || ICON_DEVICE,
|
||||||
|
desc: d.led_count ? `${d.led_count} LEDs` : '',
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
} as ConstructorParameters<typeof EntitySelect>[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select LED-count-matched device
|
||||||
|
if (devices.length > 0 && _state) {
|
||||||
|
try {
|
||||||
|
const sources = await colorStripSourcesCache.fetch() as { id: string; led_count?: number }[];
|
||||||
|
const src = sources.find(s => s.id === _state!.cssId);
|
||||||
|
if (src?.led_count) {
|
||||||
|
const match = devices.find(d => d.led_count === src.led_count);
|
||||||
|
if (match) {
|
||||||
|
sel.value = match.id;
|
||||||
|
if (_deviceEntitySelect) _deviceEntitySelect.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSelectDevice(): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
const sel = document.getElementById('autocal-device-select') as HTMLSelectElement | null;
|
||||||
|
if (!sel?.value) { _setError(t('autocal.error.no_device')); return; }
|
||||||
|
_state.deviceId = sel.value;
|
||||||
|
_state.step = 'corner';
|
||||||
|
_render();
|
||||||
|
await _startSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: Start corner ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderCorner(): void {
|
||||||
|
if (!_opts) return;
|
||||||
|
const busy = _state?.busy ?? false;
|
||||||
|
const s = _state!;
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="corner">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon">${ICON_CALIBRATION}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(t('autocal.corner.title'))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(t('autocal.corner.desc'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-led-indicator">
|
||||||
|
<span class="autocal-led-dot ${busy ? '' : 'autocal-led-dot--active'}" aria-hidden="true"></span>
|
||||||
|
<span class="autocal-led-index">${_esc(t('autocal.corner.led_index', { index: '0' }))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-corner-grid" ${busy ? 'aria-busy="true"' : ''}>
|
||||||
|
${(['top_left', 'top_right', 'bottom_left', 'bottom_right'] as StartPosition[]).map(pos =>
|
||||||
|
`<button class="autocal-corner-btn autocal-corner-btn--${pos.replace('_', '-')}"
|
||||||
|
onclick="autoCalSetCorner('${pos}')"
|
||||||
|
${busy ? 'disabled' : ''}
|
||||||
|
aria-label="${_esc(t(`autocal.position.${pos}`))}">
|
||||||
|
<span class="autocal-corner-glyph" aria-hidden="true"></span>
|
||||||
|
<span>${_esc(t(`autocal.position.${pos}`))}</span>
|
||||||
|
</button>`
|
||||||
|
).join('')}
|
||||||
|
</div>
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_showError(s.errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSetCorner(position: StartPosition): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.startPosition = position;
|
||||||
|
_state.step = 'direction';
|
||||||
|
_state.busy = true;
|
||||||
|
_render();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// LED is at index 0; advance to ~5% to show movement direction
|
||||||
|
await _setPosition(0);
|
||||||
|
await _delay(350);
|
||||||
|
// User may have cancelled during the delay (unmount sets _state = null).
|
||||||
|
if (!_state) return;
|
||||||
|
const advance = Math.max(4, Math.round(_state.ledCount * 0.04));
|
||||||
|
await _setPosition(advance);
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = false;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// _state may have been torn down mid-await by a cancel.
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = false;
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
_state.step = 'corner'; // revert on error
|
||||||
|
}
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: Direction ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderDirection(): void {
|
||||||
|
if (!_opts || !_state) return;
|
||||||
|
const busy = _state.busy;
|
||||||
|
const advance = Math.max(4, Math.round(_state.ledCount * 0.04));
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="direction">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon">${ICON_ROTATE_CW}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(t('autocal.direction.title', { step: String(advance) }))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(t('autocal.direction.desc'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-direction-grid">
|
||||||
|
<button class="autocal-direction-btn" onclick="autoCalSetDirection('clockwise')" ${busy ? 'disabled' : ''}>
|
||||||
|
${ICON_ROTATE_CW}
|
||||||
|
<span>${_esc(t('calibration.direction.clockwise'))}</span>
|
||||||
|
</button>
|
||||||
|
<button class="autocal-direction-btn" onclick="autoCalSetDirection('counterclockwise')" ${busy ? 'disabled' : ''}>
|
||||||
|
${ICON_ROTATE_CCW}
|
||||||
|
<span>${_esc(t('calibration.direction.counterclockwise'))}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
<button class="btn btn-ghost" onclick="autoCalBackToCorner()">${_esc(t('autocal.btn.back'))}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_showError(_state.errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSetDirection(layout: Layout): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.layout = layout;
|
||||||
|
// corner_indices[0] MUST be 0 (Phase 1 solver contract: start corner = index 0)
|
||||||
|
_state.cornerIndices = [0];
|
||||||
|
_state.currentIndex = 0;
|
||||||
|
_state.step = 'corners';
|
||||||
|
_render();
|
||||||
|
await _setPosition(0).catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalBackToCorner(): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.step = 'corner';
|
||||||
|
_state.startPosition = null;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_render();
|
||||||
|
await _setPosition(0).catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4: Tap-to-mark corners ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderCorners(): void {
|
||||||
|
if (!_opts || !_state) return;
|
||||||
|
const { cornerIndices, currentIndex, ledCount, busy } = _state;
|
||||||
|
const collected = cornerIndices.length; // starts at 1 (index 0 already in)
|
||||||
|
const isComplete = collected >= 4;
|
||||||
|
const cornerLabels = _cornerLabels(_state.startPosition!, _state.layout!);
|
||||||
|
|
||||||
|
const pips = [0, 1, 2, 3].map(i => {
|
||||||
|
const done = i < collected;
|
||||||
|
const active = i === collected - 1;
|
||||||
|
return `<span class="autocal-pip ${done ? 'autocal-pip--done' : ''} ${active ? 'autocal-pip--active' : ''}"
|
||||||
|
aria-label="${cornerLabels[i]}">${i + 1}</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const activeCornerLabel = isComplete ? '' : cornerLabels[collected - 1];
|
||||||
|
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="corners">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon">${ICON_CALIBRATION}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(isComplete ? t('autocal.corners.title', { remaining: '0' }) : t('autocal.corners.title', { remaining: String(4 - collected) }))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(
|
||||||
|
isComplete
|
||||||
|
? t('autocal.corners.desc_complete')
|
||||||
|
: t('autocal.corners.desc', { corner: activeCornerLabel })
|
||||||
|
)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="autocal-corners-progress">
|
||||||
|
<div class="autocal-pips">${pips}</div>
|
||||||
|
<div class="autocal-index-badge">
|
||||||
|
<span class="autocal-index-label">${_esc(t('autocal.corners.index_label'))}</span>
|
||||||
|
<span class="autocal-index-value">${currentIndex}</span>
|
||||||
|
<span class="autocal-index-total">/ ${ledCount - 1}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="autocal-sweep-row">
|
||||||
|
<button class="btn btn-ghost btn-sm autocal-sweep-btn" onclick="autoCalSweepBack()" ${busy || isComplete || currentIndex <= 0 ? 'disabled' : ''}
|
||||||
|
aria-label="${_esc(t('autocal.btn.step_back'))}">←</button>
|
||||||
|
<div class="autocal-led-track">
|
||||||
|
<div class="autocal-led-track-fill" style="width:${ledCount > 1 ? (currentIndex / (ledCount - 1)) * 100 : 0}%"></div>
|
||||||
|
<div class="autocal-led-cursor" style="left:${ledCount > 1 ? (currentIndex / (ledCount - 1)) * 100 : 0}%"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-sm autocal-sweep-btn" onclick="autoCalSweepForward()" ${busy || isComplete || currentIndex >= ledCount - 1 ? 'disabled' : ''}
|
||||||
|
aria-label="${_esc(t('autocal.btn.step_fwd'))}">→</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${isComplete ? '' : `
|
||||||
|
<button class="btn btn-primary autocal-mark-btn" onclick="autoCalMarkCorner()" ${busy ? 'disabled' : ''}>
|
||||||
|
${_esc(t('autocal.btn.mark_corner', { n: String(collected), label: activeCornerLabel }))}
|
||||||
|
</button>`}
|
||||||
|
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
<button class="btn btn-ghost" onclick="autoCalBackToDirection()">${_esc(t('autocal.btn.back'))}</button>
|
||||||
|
${isComplete ? `<button class="btn btn-primary" onclick="autoCalSolve()">${_esc(t('autocal.btn.solve'))}</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_showError(_state.errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _cornerLabels(startPos: StartPosition, layout: Layout): string[] {
|
||||||
|
const all: StartPosition[] = ['top_left', 'top_right', 'bottom_right', 'bottom_left'];
|
||||||
|
const si = all.indexOf(startPos);
|
||||||
|
let ordered: StartPosition[];
|
||||||
|
if (layout === 'clockwise') {
|
||||||
|
ordered = [all[si % 4], all[(si + 1) % 4], all[(si + 2) % 4], all[(si + 3) % 4]];
|
||||||
|
} else {
|
||||||
|
ordered = [all[si % 4], all[(si + 3) % 4], all[(si + 2) % 4], all[(si + 1) % 4]];
|
||||||
|
}
|
||||||
|
return ordered.map(c => t(`autocal.position.${c}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSweepForward(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
|
||||||
|
const next = _state.currentIndex + 1;
|
||||||
|
if (next >= _state.ledCount) return;
|
||||||
|
_state.busy = true;
|
||||||
|
try {
|
||||||
|
await _setPosition(next);
|
||||||
|
_state.currentIndex = next;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
} finally {
|
||||||
|
_state.busy = false;
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSweepBack(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
|
||||||
|
const prev = _state.currentIndex - 1;
|
||||||
|
// Clamp to one past the last marked corner index to preserve monotonic ordering.
|
||||||
|
const lastMarked = _state.cornerIndices.length > 0
|
||||||
|
? _state.cornerIndices[_state.cornerIndices.length - 1]
|
||||||
|
: -1;
|
||||||
|
if (prev < 0 || prev <= lastMarked) return;
|
||||||
|
_state.busy = true;
|
||||||
|
try {
|
||||||
|
await _setPosition(prev);
|
||||||
|
_state.currentIndex = prev;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
} finally {
|
||||||
|
_state.busy = false;
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalMarkCorner(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
|
||||||
|
_state.cornerIndices.push(_state.currentIndex);
|
||||||
|
if (_state.cornerIndices.length < 4) {
|
||||||
|
// Nudge forward so user can see the dot isn't stuck
|
||||||
|
const next = Math.min(_state.currentIndex + 1, _state.ledCount - 1);
|
||||||
|
_state.busy = true;
|
||||||
|
try {
|
||||||
|
await _setPosition(next);
|
||||||
|
_state.currentIndex = next;
|
||||||
|
} catch { /* best effort */ } finally {
|
||||||
|
_state.busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalBackToDirection(): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.step = 'direction';
|
||||||
|
_state.layout = null;
|
||||||
|
_state.cornerIndices = [];
|
||||||
|
_state.currentIndex = 0;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_render();
|
||||||
|
await _setPosition(0).catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSolve(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || _state.cornerIndices.length !== 4) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_render();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const solved = await apiPost<SolvedCalibration>('/calibration/solve', {
|
||||||
|
device_id: _state.deviceId,
|
||||||
|
start_position: _state.startPosition,
|
||||||
|
layout: _state.layout,
|
||||||
|
corner_indices: _state.cornerIndices,
|
||||||
|
offset: 0,
|
||||||
|
}, { errorMessage: t('autocal.error.solve_failed') });
|
||||||
|
|
||||||
|
_state.solved = solved;
|
||||||
|
// Stop the chase session — device restored to prior target
|
||||||
|
await _stopSession();
|
||||||
|
_state.step = 'preview';
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
_state.busy = false;
|
||||||
|
_render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.busy = false;
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 5: Preview & Save ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderPreview(): void {
|
||||||
|
if (!_opts || !_state?.solved) return;
|
||||||
|
const s = _state.solved;
|
||||||
|
const busy = _state.busy;
|
||||||
|
|
||||||
|
const dirLabel = s.layout === 'clockwise'
|
||||||
|
? t('calibration.direction.clockwise')
|
||||||
|
: t('calibration.direction.counterclockwise');
|
||||||
|
|
||||||
|
const dirIcon = s.layout === 'clockwise' ? ICON_ROTATE_CW : ICON_ROTATE_CCW;
|
||||||
|
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="preview">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon autocal-step-icon--ok">${ICON_OK}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(t('autocal.preview.title'))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(t('autocal.preview.desc'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="autocal-solved-grid">
|
||||||
|
<div class="autocal-solved-item autocal-solved-item--wide">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.start'))}</span>
|
||||||
|
<span class="autocal-solved-val">${_esc(t(`autocal.position.${s.start_position}`))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item autocal-solved-item--wide">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('calibration.direction'))}</span>
|
||||||
|
<span class="autocal-solved-val">${dirIcon} ${_esc(dirLabel)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.top'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_top}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.right'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_right}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.bottom'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_bottom}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.left'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_left}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item autocal-solved-item--wide">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.total'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_top + s.leds_right + s.leds_bottom + s.leds_left}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
<button class="btn btn-primary" id="autocal-save-btn" onclick="autoCalSave()" ${busy ? 'disabled' : ''}>
|
||||||
|
${_esc(t('autocal.btn.save'))}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_showError(_state.errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSave(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || !_state.solved) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
const btn = document.getElementById('autocal-save-btn');
|
||||||
|
if (btn) btn.setAttribute('disabled', 'true');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const s = _state.solved;
|
||||||
|
await apiPut(`/color-strip-sources/${_state.cssId}`, {
|
||||||
|
source_type: _state.cssSourceType,
|
||||||
|
calibration: {
|
||||||
|
mode: 'simple',
|
||||||
|
layout: s.layout,
|
||||||
|
start_position: s.start_position,
|
||||||
|
leds_top: s.leds_top,
|
||||||
|
leds_right: s.leds_right,
|
||||||
|
leds_bottom: s.leds_bottom,
|
||||||
|
leds_left: s.leds_left,
|
||||||
|
offset: s.offset,
|
||||||
|
span_top_start: 0, span_top_end: 1,
|
||||||
|
span_right_start: 0, span_right_end: 1,
|
||||||
|
span_bottom_start: 0, span_bottom_end: 1,
|
||||||
|
span_left_start: 0, span_left_end: 1,
|
||||||
|
skip_leds_start: 0,
|
||||||
|
skip_leds_end: 0,
|
||||||
|
border_width: 10,
|
||||||
|
roi_x: 0, roi_y: 0, roi_width: 1, roi_height: 1,
|
||||||
|
},
|
||||||
|
}, { errorMessage: t('autocal.error.save_failed') });
|
||||||
|
|
||||||
|
colorStripSourcesCache.invalidate();
|
||||||
|
showToast(t('autocal.saved'), 'success');
|
||||||
|
|
||||||
|
const onComplete = _opts?.onComplete;
|
||||||
|
await unmountAutoCalibration();
|
||||||
|
if (onComplete) onComplete();
|
||||||
|
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
if (btn) btn.removeAttribute('disabled');
|
||||||
|
_showError(_state.errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cancel ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function autoCalCancel(): Promise<void> {
|
||||||
|
const onCancel = _opts?.onCancel;
|
||||||
|
await unmountAutoCalibration();
|
||||||
|
if (onCancel) onCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session lifecycle ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _startSession(): Promise<void> {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_render();
|
||||||
|
try {
|
||||||
|
const state = await apiPost<CalibrationSessionState>('/calibration/session', {
|
||||||
|
device_id: _state.deviceId,
|
||||||
|
}, { errorMessage: t('autocal.error.session_start_failed') });
|
||||||
|
_state.sessionActive = true;
|
||||||
|
_state.ledCount = state.led_count;
|
||||||
|
_state.busy = false;
|
||||||
|
await _setPosition(0);
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_render();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Session may already be live (POST /calibration/session succeeded before _setPosition threw),
|
||||||
|
// so call _stopSession() to let the backend tear down cleanly instead of flipping the flag directly.
|
||||||
|
await _stopSession().catch(() => { /* best effort */ });
|
||||||
|
_state.busy = false;
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _stopSession(): Promise<void> {
|
||||||
|
if (!_state?.sessionActive) return;
|
||||||
|
try {
|
||||||
|
await apiPost<CalibrationSessionState>('/calibration/session/stop', undefined, {
|
||||||
|
errorMessage: t('autocal.error.session_stop_failed'),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (_state) _state.sessionActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _setPosition(index: number): Promise<void> {
|
||||||
|
if (!_state?.sessionActive) return;
|
||||||
|
await apiPost<CalibrationSessionState>('/calibration/session/position', {
|
||||||
|
index,
|
||||||
|
window: 1,
|
||||||
|
}, { errorMessage: t('autocal.error.position_failed') });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _errMsg(err: unknown): string {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _esc(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showError(msg: string): void {
|
||||||
|
const el = document.getElementById('autocal-error');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.display = msg ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setError(msg: string): void {
|
||||||
|
if (_state) _state.errorMsg = msg;
|
||||||
|
_showError(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Standalone modal management ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The standalone modal is the Phase 3 surface: opened from the calibration
|
||||||
|
// modal's "Auto-calibrate" button. Phase 4 wizard uses mountAutoCalibration()
|
||||||
|
// directly (no modal wrapper needed — the wizard is itself a modal).
|
||||||
|
|
||||||
|
class AutoCalModal extends Modal {
|
||||||
|
constructor() { super('auto-calibration-modal'); }
|
||||||
|
|
||||||
|
snapshotValues(): Record<string, string> {
|
||||||
|
// No dirty-check needed for a wizard flow; always allow close.
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
onForceClose(): void {
|
||||||
|
// Unmount the flow asynchronously (session stop is async)
|
||||||
|
unmountAutoCalibration().catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _autoCalModal = new AutoCalModal();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the auto-calibration wizard for a color-strip source.
|
||||||
|
*
|
||||||
|
* Called from calibration.ts "Auto-calibrate" button.
|
||||||
|
*
|
||||||
|
* @param cssId The color-strip source ID to calibrate.
|
||||||
|
* @param deviceId Optional pre-selected device; if omitted, the device picker
|
||||||
|
* step is shown.
|
||||||
|
*/
|
||||||
|
export async function showAutoCalibration(cssId: string, deviceId?: string): Promise<void> {
|
||||||
|
const container = document.getElementById('autocal-step-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Store context on the hidden inputs for reference
|
||||||
|
const cssIdInput = document.getElementById('autocal-modal-css-id') as HTMLInputElement | null;
|
||||||
|
const deviceIdInput = document.getElementById('autocal-modal-device-id') as HTMLInputElement | null;
|
||||||
|
if (cssIdInput) cssIdInput.value = cssId;
|
||||||
|
if (deviceIdInput) deviceIdInput.value = deviceId || '';
|
||||||
|
|
||||||
|
_autoCalModal.open();
|
||||||
|
_autoCalModal.snapshot();
|
||||||
|
|
||||||
|
await mountAutoCalibration({
|
||||||
|
container,
|
||||||
|
cssId,
|
||||||
|
deviceId,
|
||||||
|
onComplete: () => {
|
||||||
|
_autoCalModal.forceClose();
|
||||||
|
// Reload calibration view if open
|
||||||
|
if (window.loadTargetsTab) window.loadTargetsTab();
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
_autoCalModal.forceClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the auto-calibration modal (stops session). */
|
||||||
|
export async function closeAutoCalModal(): Promise<void> {
|
||||||
|
await _autoCalModal.close();
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
|
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
|
||||||
scenePresetsCache, _cachedHASources, haSourcesCache,
|
scenePresetsCache, scenePlaylistsCache, _cachedHASources, haSourcesCache,
|
||||||
_cachedValueSources, valueSourcesCache,
|
_cachedValueSources, valueSourcesCache,
|
||||||
getHAEntityFriendlyName, setHAEntityNames,
|
getHAEntityFriendlyName, setHAEntityNames,
|
||||||
} from '../core/state.ts';
|
} from '../core/state.ts';
|
||||||
@@ -18,7 +18,7 @@ import { Modal } from '../core/modal.ts';
|
|||||||
import { CardSection } from '../core/card-sections.ts';
|
import { CardSection } from '../core/card-sections.ts';
|
||||||
import { updateTabBadge, updateSubTabHash } from './tabs.ts';
|
import { updateTabBadge, updateSubTabHash } from './tabs.ts';
|
||||||
import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
||||||
import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE } from '../core/icons.ts';
|
import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE, ICON_LIST_CHECKS } from '../core/icons.ts';
|
||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../core/icon-paths.ts';
|
||||||
import { wrapCard } from '../core/card-colors.ts';
|
import { wrapCard } from '../core/card-colors.ts';
|
||||||
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
|
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
|
||||||
@@ -32,6 +32,7 @@ import { enhanceMiniSelects } from '../core/mini-select.ts';
|
|||||||
import { attachProcessPicker, attachAppPicker } from '../core/process-picker.ts';
|
import { attachProcessPicker, attachAppPicker } from '../core/process-picker.ts';
|
||||||
import { TreeNav } from '../core/tree-nav.ts';
|
import { TreeNav } from '../core/tree-nav.ts';
|
||||||
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
|
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
|
||||||
|
import { csPlaylists, createPlaylistCard, initPlaylistDelegation } from './scene-playlists.ts';
|
||||||
import type { Automation, RuleType } from '../types.ts';
|
import type { Automation, RuleType } from '../types.ts';
|
||||||
|
|
||||||
registerIconEntityType('automation', makeSimpleIconAdapter<Automation>({
|
registerIconEntityType('automation', makeSimpleIconAdapter<Automation>({
|
||||||
@@ -252,6 +253,7 @@ export async function loadAutomations() {
|
|||||||
const [automations, scenes] = await Promise.all([
|
const [automations, scenes] = await Promise.all([
|
||||||
automationsCacheObj.fetch(),
|
automationsCacheObj.fetch(),
|
||||||
scenePresetsCache.fetch(),
|
scenePresetsCache.fetch(),
|
||||||
|
scenePlaylistsCache.fetch(),
|
||||||
haSourcesCache.fetch(),
|
haSourcesCache.fetch(),
|
||||||
valueSourcesCache.fetch(),
|
valueSourcesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
@@ -291,38 +293,45 @@ function renderAutomations(automations: any, sceneMap: any) {
|
|||||||
|
|
||||||
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
|
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
|
||||||
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
|
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
|
||||||
|
const playlistItems = csPlaylists.applySortOrder(scenePlaylistsCache.data.map(p => ({ key: p.id, html: createPlaylistCard(p) })));
|
||||||
|
|
||||||
const activeTab = getActiveSubTab('automations')!;
|
const activeTab = getActiveSubTab('automations')!;
|
||||||
|
|
||||||
const treeItems = [
|
const treeItems = [
|
||||||
{ key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length },
|
{ key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length },
|
||||||
{ key: 'scenes', icon: ICON_SCENE, titleKey: 'scenes.title', count: scenePresetsCache.data.length },
|
{ key: 'scenes', icon: ICON_SCENE, titleKey: 'scenes.title', count: scenePresetsCache.data.length },
|
||||||
|
{ key: 'playlists', icon: ICON_LIST_CHECKS, titleKey: 'playlists.title', count: scenePlaylistsCache.data.length },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (csAutomations.isMounted()) {
|
if (csAutomations.isMounted()) {
|
||||||
_automationsTree.updateCounts({
|
_automationsTree.updateCounts({
|
||||||
automations: automations.length,
|
automations: automations.length,
|
||||||
scenes: scenePresetsCache.data.length,
|
scenes: scenePresetsCache.data.length,
|
||||||
|
playlists: scenePlaylistsCache.data.length,
|
||||||
});
|
});
|
||||||
csAutomations.reconcile(autoItems);
|
csAutomations.reconcile(autoItems);
|
||||||
csScenes.reconcile(sceneItems);
|
csScenes.reconcile(sceneItems);
|
||||||
|
csPlaylists.reconcile(playlistItems);
|
||||||
} else {
|
} else {
|
||||||
const panels = [
|
const panels = [
|
||||||
{ key: 'automations', html: csAutomations.render(autoItems) },
|
{ key: 'automations', html: csAutomations.render(autoItems) },
|
||||||
{ key: 'scenes', html: csScenes.render(sceneItems) },
|
{ key: 'scenes', html: csScenes.render(sceneItems) },
|
||||||
|
{ key: 'playlists', html: csPlaylists.render(playlistItems) },
|
||||||
].map(p => `<div class="automation-sub-tab-panel stream-tab-panel${p.key === activeTab ? ' active' : ''}" id="automation-tab-${p.key}">${p.html}</div>`).join('');
|
].map(p => `<div class="automation-sub-tab-panel stream-tab-panel${p.key === activeTab ? ' active' : ''}" id="automation-tab-${p.key}">${p.html}</div>`).join('');
|
||||||
|
|
||||||
container!.innerHTML = panels;
|
container!.innerHTML = panels;
|
||||||
CardSection.bindAll([csAutomations, csScenes]);
|
CardSection.bindAll([csAutomations, csScenes, csPlaylists]);
|
||||||
|
|
||||||
// Event delegation for scene preset card actions
|
// Event delegation for scene preset + playlist card actions
|
||||||
initScenePresetDelegation(container!);
|
initScenePresetDelegation(container!);
|
||||||
|
initPlaylistDelegation(container!);
|
||||||
|
|
||||||
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
||||||
_automationsTree.update(treeItems, activeTab);
|
_automationsTree.update(treeItems, activeTab);
|
||||||
_automationsTree.observeSections('automations-content', {
|
_automationsTree.observeSections('automations-content', {
|
||||||
'automations': 'automations',
|
'automations': 'automations',
|
||||||
'scenes': 'scenes',
|
'scenes': 'scenes',
|
||||||
|
'playlists': 'playlists',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW, ICON_DEVICE } from '../c
|
|||||||
import { renderDeviceIcon } from '../core/device-icons.ts';
|
import { renderDeviceIcon } from '../core/device-icons.ts';
|
||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
import type { Calibration } from '../types.ts';
|
import type { Calibration } from '../types.ts';
|
||||||
|
import { showAutoCalibration } from './auto-calibration.ts';
|
||||||
|
|
||||||
let _calTestDeviceEntitySelect: EntitySelect | null = null;
|
let _calTestDeviceEntitySelect: EntitySelect | null = null;
|
||||||
let _calTestDeviceList: any[] = [];
|
let _calTestDeviceList: any[] = [];
|
||||||
@@ -41,6 +42,10 @@ class CalibrationModal extends Modal {
|
|||||||
skip_start: (this.$('cal-skip-start') as HTMLInputElement).value,
|
skip_start: (this.$('cal-skip-start') as HTMLInputElement).value,
|
||||||
skip_end: (this.$('cal-skip-end') as HTMLInputElement).value,
|
skip_end: (this.$('cal-skip-end') as HTMLInputElement).value,
|
||||||
border_width: (this.$('cal-border-width') as HTMLInputElement).value,
|
border_width: (this.$('cal-border-width') as HTMLInputElement).value,
|
||||||
|
roi_x: (this.$('cal-roi-x') as HTMLInputElement)?.value,
|
||||||
|
roi_y: (this.$('cal-roi-y') as HTMLInputElement)?.value,
|
||||||
|
roi_width: (this.$('cal-roi-width') as HTMLInputElement)?.value,
|
||||||
|
roi_height: (this.$('cal-roi-height') as HTMLInputElement)?.value,
|
||||||
led_count: (this.$('cal-css-led-count') as HTMLInputElement).value,
|
led_count: (this.$('cal-css-led-count') as HTMLInputElement).value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -59,6 +64,14 @@ class CalibrationModal extends Modal {
|
|||||||
if (testGroup) testGroup.style.display = 'none';
|
if (testGroup) testGroup.style.display = 'none';
|
||||||
const testSection = document.getElementById('calibration-test-setup-section');
|
const testSection = document.getElementById('calibration-test-setup-section');
|
||||||
if (testSection) testSection.style.display = 'none';
|
if (testSection) testSection.style.display = 'none';
|
||||||
|
// Destroy the test-device EntitySelect; otherwise the instance leaks and
|
||||||
|
// leaves a stale enhanced trigger button in the DOM until the next open.
|
||||||
|
if (_calTestDeviceEntitySelect) {
|
||||||
|
_calTestDeviceEntitySelect.destroy();
|
||||||
|
_calTestDeviceEntitySelect = null;
|
||||||
|
}
|
||||||
|
const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement | null;
|
||||||
|
if (testDeviceSelect) testDeviceSelect.onchange = null;
|
||||||
} else {
|
} else {
|
||||||
const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value;
|
const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value;
|
||||||
if (deviceId) clearTestMode(deviceId);
|
if (deviceId) clearTestMode(deviceId);
|
||||||
@@ -173,6 +186,7 @@ export async function showCalibration(deviceId: any) {
|
|||||||
updateOffsetSkipLock();
|
updateOffsetSkipLock();
|
||||||
|
|
||||||
(document.getElementById('cal-border-width') as HTMLInputElement).value = calibration.border_width || 10;
|
(document.getElementById('cal-border-width') as HTMLInputElement).value = calibration.border_width || 10;
|
||||||
|
_populateRoiInputs(calibration);
|
||||||
|
|
||||||
window.edgeSpans = {
|
window.edgeSpans = {
|
||||||
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
||||||
@@ -228,6 +242,33 @@ export async function closeCalibrationModal() {
|
|||||||
calibModal.close();
|
calibModal.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the auto-calibration wizard for the currently-open calibration modal.
|
||||||
|
*
|
||||||
|
* Reads the CSS ID or device ID from the active calibration modal context,
|
||||||
|
* then launches the auto-cal modal. In CSS mode the test device (if selected)
|
||||||
|
* is offered as the default device; in device mode the device is known.
|
||||||
|
*/
|
||||||
|
export async function openAutoCalFromCalibration(): Promise<void> {
|
||||||
|
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value || '';
|
||||||
|
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement)?.value || '';
|
||||||
|
|
||||||
|
if (cssId) {
|
||||||
|
// CSS calibration mode: try the already-selected test device as default
|
||||||
|
const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement | null;
|
||||||
|
const testDevice = testDeviceSelect?.value || undefined;
|
||||||
|
// Close the calibration modal so the auto-cal modal has focus
|
||||||
|
calibModal.forceClose();
|
||||||
|
await showAutoCalibration(cssId, testDevice);
|
||||||
|
} else if (deviceId) {
|
||||||
|
// Device calibration mode: not directly supported by auto-cal (which
|
||||||
|
// writes to a CSS), so show a toast explaining the constraint.
|
||||||
|
showToast(t('autocal.error.css_required'), 'error');
|
||||||
|
} else {
|
||||||
|
showToast(t('calibration.error.load_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── CSS Calibration support ──────────────────────────────────── */
|
/* ── CSS Calibration support ──────────────────────────────────── */
|
||||||
|
|
||||||
export async function showCSSCalibration(cssId: any) {
|
export async function showCSSCalibration(cssId: any) {
|
||||||
@@ -319,6 +360,7 @@ export async function showCSSCalibration(cssId: any) {
|
|||||||
updateOffsetSkipLock();
|
updateOffsetSkipLock();
|
||||||
|
|
||||||
(document.getElementById('cal-border-width') as HTMLInputElement).value = String(calibration.border_width || 10);
|
(document.getElementById('cal-border-width') as HTMLInputElement).value = String(calibration.border_width || 10);
|
||||||
|
_populateRoiInputs(calibration);
|
||||||
|
|
||||||
window.edgeSpans = {
|
window.edgeSpans = {
|
||||||
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
||||||
@@ -882,6 +924,20 @@ async function clearTestMode(deviceId: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Populate the ROI percentage inputs from a calibration object (fractions 0..1). */
|
||||||
|
function _populateRoiInputs(calibration: any): void {
|
||||||
|
const pct = (v: number | undefined, fallback: number) =>
|
||||||
|
String(Math.round((v ?? fallback) * 100));
|
||||||
|
const set = (id: string, v: string) => {
|
||||||
|
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||||
|
if (el) el.value = v;
|
||||||
|
};
|
||||||
|
set('cal-roi-x', pct(calibration.roi_x, 0));
|
||||||
|
set('cal-roi-y', pct(calibration.roi_y, 0));
|
||||||
|
set('cal-roi-width', pct(calibration.roi_width, 1));
|
||||||
|
set('cal-roi-height', pct(calibration.roi_height, 1));
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveCalibration() {
|
export async function saveCalibration() {
|
||||||
const cssMode = _isCSS();
|
const cssMode = _isCSS();
|
||||||
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
|
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
|
||||||
@@ -936,6 +992,10 @@ export async function saveCalibration() {
|
|||||||
skip_leds_start: parseInt((document.getElementById('cal-skip-start') as HTMLInputElement).value || '0'),
|
skip_leds_start: parseInt((document.getElementById('cal-skip-start') as HTMLInputElement).value || '0'),
|
||||||
skip_leds_end: parseInt((document.getElementById('cal-skip-end') as HTMLInputElement).value || '0'),
|
skip_leds_end: parseInt((document.getElementById('cal-skip-end') as HTMLInputElement).value || '0'),
|
||||||
border_width: parseInt((document.getElementById('cal-border-width') as HTMLInputElement).value) || 10,
|
border_width: parseInt((document.getElementById('cal-border-width') as HTMLInputElement).value) || 10,
|
||||||
|
roi_x: (parseFloat((document.getElementById('cal-roi-x') as HTMLInputElement).value) || 0) / 100,
|
||||||
|
roi_y: (parseFloat((document.getElementById('cal-roi-y') as HTMLInputElement).value) || 0) / 100,
|
||||||
|
roi_width: (parseFloat((document.getElementById('cal-roi-width') as HTMLInputElement).value) || 100) / 100,
|
||||||
|
roi_height: (parseFloat((document.getElementById('cal-roi-height') as HTMLInputElement).value) || 100) / 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -151,6 +151,15 @@ let _cssTestWs: WebSocket | null = null;
|
|||||||
let _cssTestRaf: number | null = null;
|
let _cssTestRaf: number | null = null;
|
||||||
let _cssTestLatestRgb: Uint8Array | null = null;
|
let _cssTestLatestRgb: Uint8Array | null = null;
|
||||||
let _cssTestMeta: any = null;
|
let _cssTestMeta: any = null;
|
||||||
|
// Set true when a WS frame updates the latest RGB / layer data; the render loop
|
||||||
|
// only repaints when a new frame has arrived, then clears it. Avoids re-rendering
|
||||||
|
// the same frame every RAF tick on low-powered devices (e.g. Android TV WebView).
|
||||||
|
let _cssTestFrameDirty: boolean = false;
|
||||||
|
// Per-canvas render caches: reuse the 2d context and a single ImageData buffer
|
||||||
|
// instead of calling getContext()/createImageData() and resetting width/height
|
||||||
|
// every frame. ImageData is rebuilt only when the canvas LED count changes.
|
||||||
|
const _cssTestCtxCache = new WeakMap<HTMLCanvasElement, CanvasRenderingContext2D>();
|
||||||
|
const _cssTestImageDataCache = new WeakMap<HTMLCanvasElement, { w: number; h: number; imageData: ImageData }>();
|
||||||
let _cssTestSourceId: string | null = null;
|
let _cssTestSourceId: string | null = null;
|
||||||
let _cssTestIsComposite: boolean = false;
|
let _cssTestIsComposite: boolean = false;
|
||||||
let _cssTestLayerData: any = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array }
|
let _cssTestLayerData: any = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array }
|
||||||
@@ -234,6 +243,7 @@ function _testKeyColorsSource(sourceId: string) {
|
|||||||
_cssTestLatestRgb = null;
|
_cssTestLatestRgb = null;
|
||||||
_cssTestMeta = null;
|
_cssTestMeta = null;
|
||||||
_cssTestLayerData = null;
|
_cssTestLayerData = null;
|
||||||
|
_cssTestFrameDirty = false;
|
||||||
|
|
||||||
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
|
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
@@ -414,6 +424,7 @@ function _openTestModal(sourceId: string) {
|
|||||||
_cssTestIsComposite = false;
|
_cssTestIsComposite = false;
|
||||||
_cssTestIsKeyColors = false;
|
_cssTestIsKeyColors = false;
|
||||||
_cssTestLayerData = null;
|
_cssTestLayerData = null;
|
||||||
|
_cssTestFrameDirty = false;
|
||||||
|
|
||||||
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
|
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
@@ -636,6 +647,8 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) {
|
|||||||
// Standard format: raw RGB
|
// Standard format: raw RGB
|
||||||
_cssTestLatestRgb = raw;
|
_cssTestLatestRgb = raw;
|
||||||
}
|
}
|
||||||
|
// Mark a new frame so the render loop repaints on the next RAF tick.
|
||||||
|
_cssTestFrameDirty = true;
|
||||||
|
|
||||||
// Track FPS for api_input sources
|
// Track FPS for api_input sources
|
||||||
if (_cssTestIsApiInput) {
|
if (_cssTestIsApiInput) {
|
||||||
@@ -756,6 +769,7 @@ export function applyCssTestSettings() {
|
|||||||
_cssTestLatestRgb = null;
|
_cssTestLatestRgb = null;
|
||||||
_cssTestMeta = null;
|
_cssTestMeta = null;
|
||||||
_cssTestLayerData = null;
|
_cssTestLayerData = null;
|
||||||
|
_cssTestFrameDirty = false;
|
||||||
|
|
||||||
// Read selected input source from selector (both CSS and CSPT modes)
|
// Read selected input source from selector (both CSS and CSPT modes)
|
||||||
const inputSel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement | null;
|
const inputSel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement | null;
|
||||||
@@ -774,6 +788,9 @@ export function applyCssTestSettings() {
|
|||||||
function _cssTestRenderLoop() {
|
function _cssTestRenderLoop() {
|
||||||
_cssTestRaf = requestAnimationFrame(_cssTestRenderLoop);
|
_cssTestRaf = requestAnimationFrame(_cssTestRenderLoop);
|
||||||
if (!_cssTestMeta) return;
|
if (!_cssTestMeta) return;
|
||||||
|
// Skip repaint when no new WS frame has arrived since the last render.
|
||||||
|
if (!_cssTestFrameDirty) return;
|
||||||
|
_cssTestFrameDirty = false;
|
||||||
|
|
||||||
const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0;
|
const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0;
|
||||||
|
|
||||||
@@ -786,14 +803,40 @@ function _cssTestRenderLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns a cached 2d context and a reusable ImageData for the canvas, only
|
||||||
|
// resizing the canvas / rebuilding the ImageData when the requested dimensions
|
||||||
|
// differ from the cached ones. Returns null if a 2d context is unavailable.
|
||||||
|
function _cssTestAcquireCanvas(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
): { ctx: CanvasRenderingContext2D; imageData: ImageData } | null {
|
||||||
|
let ctx = _cssTestCtxCache.get(canvas);
|
||||||
|
if (!ctx) {
|
||||||
|
const got = canvas.getContext('2d');
|
||||||
|
if (!got) return null;
|
||||||
|
ctx = got;
|
||||||
|
_cssTestCtxCache.set(canvas, ctx);
|
||||||
|
}
|
||||||
|
// Only touch width/height when they actually change (resetting them clears
|
||||||
|
// the backing store and is expensive).
|
||||||
|
if (canvas.width !== w) canvas.width = w;
|
||||||
|
if (canvas.height !== h) canvas.height = h;
|
||||||
|
let cached = _cssTestImageDataCache.get(canvas);
|
||||||
|
if (!cached || cached.w !== w || cached.h !== h) {
|
||||||
|
cached = { w, h, imageData: ctx.createImageData(w, h) };
|
||||||
|
_cssTestImageDataCache.set(canvas, cached);
|
||||||
|
}
|
||||||
|
return { ctx, imageData: cached.imageData };
|
||||||
|
}
|
||||||
|
|
||||||
function _cssTestRenderStrip(rgbBytes: Uint8Array) {
|
function _cssTestRenderStrip(rgbBytes: Uint8Array) {
|
||||||
const canvas = document.getElementById('css-test-strip-canvas') as HTMLCanvasElement | null;
|
const canvas = document.getElementById('css-test-strip-canvas') as HTMLCanvasElement | null;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ledCount = rgbBytes.length / 3;
|
const ledCount = rgbBytes.length / 3;
|
||||||
canvas.width = ledCount;
|
const acquired = _cssTestAcquireCanvas(canvas, ledCount, 1);
|
||||||
canvas.height = 1;
|
if (!acquired) return;
|
||||||
const ctx = canvas.getContext('2d')!;
|
const { ctx, imageData } = acquired;
|
||||||
const imageData = ctx.createImageData(ledCount, 1);
|
|
||||||
const data = imageData.data;
|
const data = imageData.data;
|
||||||
for (let i = 0; i < ledCount; i++) {
|
for (let i = 0; i < ledCount; i++) {
|
||||||
const si = i * 3;
|
const si = i * 3;
|
||||||
@@ -824,10 +867,9 @@ function _cssTestRenderLayers(data: any) {
|
|||||||
function _cssTestRenderStripCanvas(canvas: HTMLCanvasElement, rgbBytes: Uint8Array) {
|
function _cssTestRenderStripCanvas(canvas: HTMLCanvasElement, rgbBytes: Uint8Array) {
|
||||||
const ledCount = rgbBytes.length / 3;
|
const ledCount = rgbBytes.length / 3;
|
||||||
if (ledCount <= 0) return;
|
if (ledCount <= 0) return;
|
||||||
canvas.width = ledCount;
|
const acquired = _cssTestAcquireCanvas(canvas, ledCount, 1);
|
||||||
canvas.height = 1;
|
if (!acquired) return;
|
||||||
const ctx = canvas.getContext('2d')!;
|
const { ctx, imageData } = acquired;
|
||||||
const imageData = ctx.createImageData(ledCount, 1);
|
|
||||||
const data = imageData.data;
|
const data = imageData.data;
|
||||||
for (let i = 0; i < ledCount; i++) {
|
for (let i = 0; i < ledCount; i++) {
|
||||||
const si = i * 3;
|
const si = i * 3;
|
||||||
@@ -852,13 +894,17 @@ function _cssTestRenderRect(rgbBytes: Uint8Array, edges: any[]) {
|
|||||||
const canvas = document.getElementById(`css-test-edge-${edge}`) as HTMLCanvasElement | null;
|
const canvas = document.getElementById(`css-test-edge-${edge}`) as HTMLCanvasElement | null;
|
||||||
if (!canvas) continue;
|
if (!canvas) continue;
|
||||||
const count = indices.length;
|
const count = indices.length;
|
||||||
if (count === 0) { canvas.width = 0; continue; }
|
if (count === 0) {
|
||||||
|
if (canvas.width !== 0) canvas.width = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const isH = edge === 'top' || edge === 'bottom';
|
const isH = edge === 'top' || edge === 'bottom';
|
||||||
canvas.width = isH ? count : 1;
|
const w = isH ? count : 1;
|
||||||
canvas.height = isH ? 1 : count;
|
const h = isH ? 1 : count;
|
||||||
const ctx = canvas.getContext('2d')!;
|
const acquired = _cssTestAcquireCanvas(canvas, w, h);
|
||||||
const imageData = ctx.createImageData(canvas.width, canvas.height);
|
if (!acquired) continue;
|
||||||
|
const { ctx, imageData } = acquired;
|
||||||
const px = imageData.data;
|
const px = imageData.data;
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const si = indices[i] * 3;
|
const si = indices[i] * 3;
|
||||||
@@ -1185,6 +1231,7 @@ export function closeTestCssSourceModal() {
|
|||||||
_cssTestIsComposite = false;
|
_cssTestIsComposite = false;
|
||||||
_cssTestIsKeyColors = false;
|
_cssTestIsKeyColors = false;
|
||||||
_cssTestLayerData = null;
|
_cssTestLayerData = null;
|
||||||
|
_cssTestFrameDirty = false;
|
||||||
_cssTestNotificationIds = [];
|
_cssTestNotificationIds = [];
|
||||||
_cssTestIsApiInput = false;
|
_cssTestIsApiInput = false;
|
||||||
_cssTestStopFpsSampling();
|
_cssTestStopFpsSampling();
|
||||||
|
|||||||
@@ -60,8 +60,10 @@ const REORDERABLE_SECTIONS: readonly string[] = [
|
|||||||
'integrations',
|
'integrations',
|
||||||
'automations',
|
'automations',
|
||||||
'scenes',
|
'scenes',
|
||||||
|
'playlists',
|
||||||
'sync-clocks',
|
'sync-clocks',
|
||||||
'targets',
|
'targets',
|
||||||
|
'recent-activity',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const SECTION_LABEL_KEYS: Record<string, string> = {
|
const SECTION_LABEL_KEYS: Record<string, string> = {
|
||||||
@@ -69,8 +71,10 @@ const SECTION_LABEL_KEYS: Record<string, string> = {
|
|||||||
integrations: 'dashboard.section.integrations',
|
integrations: 'dashboard.section.integrations',
|
||||||
automations: 'dashboard.section.automations',
|
automations: 'dashboard.section.automations',
|
||||||
scenes: 'dashboard.section.scenes',
|
scenes: 'dashboard.section.scenes',
|
||||||
|
playlists: 'dashboard.section.playlists',
|
||||||
'sync-clocks': 'dashboard.section.sync_clocks',
|
'sync-clocks': 'dashboard.section.sync_clocks',
|
||||||
targets: 'dashboard.section.targets',
|
targets: 'dashboard.section.targets',
|
||||||
|
'recent-activity': 'dashboard.section.recent_activity',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PERF_CELL_LABEL_KEYS: Record<string, string> = {
|
const PERF_CELL_LABEL_KEYS: Record<string, string> = {
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ export type SectionKey =
|
|||||||
| 'integrations'
|
| 'integrations'
|
||||||
| 'automations'
|
| 'automations'
|
||||||
| 'scenes'
|
| 'scenes'
|
||||||
|
| 'playlists'
|
||||||
| 'sync-clocks'
|
| 'sync-clocks'
|
||||||
| 'targets'
|
| 'targets'
|
||||||
|
| 'recent-activity'
|
||||||
// Reserved registry keys for v1.1+ (so saved layouts forward-compat).
|
// Reserved registry keys for v1.1+ (so saved layouts forward-compat).
|
||||||
| 'audio-meters'
|
| 'audio-meters'
|
||||||
| 'alerts'
|
| 'alerts'
|
||||||
@@ -151,8 +153,10 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = {
|
|||||||
_defaultSection('integrations'),
|
_defaultSection('integrations'),
|
||||||
_defaultSection('automations'),
|
_defaultSection('automations'),
|
||||||
_defaultSection('scenes'),
|
_defaultSection('scenes'),
|
||||||
|
_defaultSection('playlists'),
|
||||||
_defaultSection('sync-clocks'),
|
_defaultSection('sync-clocks'),
|
||||||
_defaultSection('targets'),
|
_defaultSection('targets'),
|
||||||
|
_defaultSection('recent-activity'),
|
||||||
],
|
],
|
||||||
perfCells: [
|
perfCells: [
|
||||||
_defaultPerfCell('patches'),
|
_defaultPerfCell('patches'),
|
||||||
@@ -192,7 +196,7 @@ export const PRESETS: Record<string, () => DashboardLayoutV1> = {
|
|||||||
|
|
||||||
operator: () => {
|
operator: () => {
|
||||||
const l = _clone(DEFAULT_LAYOUT, 'operator');
|
const l = _clone(DEFAULT_LAYOUT, 'operator');
|
||||||
const hide = new Set(['integrations', 'scenes', 'sync-clocks']);
|
const hide = new Set(['integrations', 'scenes', 'playlists', 'sync-clocks']);
|
||||||
l.sections = l.sections.map(s => hide.has(s.key) ? { ...s, visible: false } : s);
|
l.sections = l.sections.map(s => hide.has(s.key) ? { ...s, visible: false } : s);
|
||||||
l.sections = l.sections.map(s => ({ ...s, density: 'compact' }));
|
l.sections = l.sections.map(s => ({ ...s, density: 'compact' }));
|
||||||
return l;
|
return l;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.ts';
|
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 { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
|
||||||
import { t } from '../core/i18n.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 { 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 { startAutoRefresh, updateTabBadge } from './tabs.ts';
|
||||||
import { isActiveTab } from '../core/tab-registry.ts';
|
import { isActiveTab } from '../core/tab-registry.ts';
|
||||||
@@ -15,11 +15,14 @@ import {
|
|||||||
ICON_PLUG, ICON_HOME, ICON_RADIO, ICON_SETTINGS,
|
ICON_PLUG, ICON_HOME, ICON_RADIO, ICON_SETTINGS,
|
||||||
} from '../core/icons.ts';
|
} from '../core/icons.ts';
|
||||||
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
|
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
|
||||||
|
import { loadPlaylists } from './scene-playlists.ts';
|
||||||
import { cardColorStyle } from '../core/card-colors.ts';
|
import { cardColorStyle } from '../core/card-colors.ts';
|
||||||
import { renderDeviceIconSvg } from '../core/device-icons.ts';
|
import { renderDeviceIconSvg } from '../core/device-icons.ts';
|
||||||
import { createFpsSparkline } from '../core/chart-utils.ts';
|
import { createFpsSparkline } from '../core/chart-utils.ts';
|
||||||
import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts';
|
import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts';
|
||||||
import { mountCardModeToggle } from './card-modes.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 {
|
function _applyGlobalLayoutAttrs(): void {
|
||||||
const c = document.getElementById('dashboard-content');
|
const c = document.getElementById('dashboard-content');
|
||||||
@@ -55,7 +58,7 @@ function _mountDashboardCardModeToggles(): void {
|
|||||||
_dashboardModeTeardowns.set(surface, teardown);
|
_dashboardModeTeardowns.set(surface, teardown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts';
|
import type { Device, OutputTarget, ColorStripSource, ScenePreset, ScenePlaylist, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts';
|
||||||
|
|
||||||
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
||||||
const MAX_FPS_SAMPLES = 120;
|
const MAX_FPS_SAMPLES = 120;
|
||||||
@@ -529,6 +532,127 @@ function renderDashboardSyncClock(clock: SyncClock): string {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compact dashboard card for a scene playlist. Mirrors the sync-clock card:
|
||||||
|
* running state drives the LED / patch indicator and the Start↔Stop toggle.
|
||||||
|
* Only one playlist cycles at a time, so the running one is sorted to the
|
||||||
|
* front by the caller. Uses the window-exposed start/stop handlers (same as
|
||||||
|
* the Automations-tab cards), so no extra delegation wiring is needed. */
|
||||||
|
function renderDashboardPlaylist(playlist: ScenePlaylist): string {
|
||||||
|
const running = playlist.is_running === true;
|
||||||
|
const itemCount = (playlist.items || []).length;
|
||||||
|
const metaParts = [
|
||||||
|
itemCount > 0 ? `${itemCount} ${t('playlists.scenes_count')}` : null,
|
||||||
|
playlist.description ? escapeHtml(playlist.description) : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
const short = (playlist.id || '').replace(/^playlist_/i, '').slice(-2).toUpperCase() || 'PL';
|
||||||
|
const ledCls = running ? 'led on blink' : 'led';
|
||||||
|
const patchLabel = running ? t('playlists.status.playing') : t('playlists.status.stopped');
|
||||||
|
const patchLive = running ? ' is-live' : '';
|
||||||
|
const btnCls = running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
|
||||||
|
const btnLabel = running ? (t('playlists.action.stop') || 'Stop') : (t('playlists.action.start') || 'Start');
|
||||||
|
const btnTitle = running ? t('playlists.stop') : t('playlists.start');
|
||||||
|
const toggleAction = running ? 'stopScenePlaylist()' : `startScenePlaylist('${playlist.id}')`;
|
||||||
|
|
||||||
|
const plStyle = cardColorStyle(playlist.id);
|
||||||
|
const iconPlate = _dashboardIconPlate(playlist as any);
|
||||||
|
const headCls = iconPlate ? 'mod-head mod-head--with-icon' : 'mod-head';
|
||||||
|
return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${running ? 'is-running' : ''}" data-playlist-id="${playlist.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations','playlists','playlists','data-playlist-id','${playlist.id}')}"${plStyle ? ` style="${plStyle}"` : ''}>
|
||||||
|
<div class="${headCls}">
|
||||||
|
${iconPlate}
|
||||||
|
<div class="mod-id">
|
||||||
|
<span class="mod-badge">PL · ${escapeHtml(short)}</span>
|
||||||
|
<div class="mod-name"><span>${escapeHtml(playlist.name)}</span></div>
|
||||||
|
${metaParts.length ? `<div class="mod-meta">${metaParts.join(' · ')}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="mod-leds" aria-hidden="true">
|
||||||
|
<span class="${ledCls}"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mod-foot">
|
||||||
|
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
|
||||||
|
<button class="${btnCls}" onclick="event.stopPropagation(); ${toggleAction}" title="${btnTitle}">${running ? ICON_PAUSE : ICON_START} <span>${btnLabel}</span></button>
|
||||||
|
</div>
|
||||||
|
</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;
|
let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||||
/** Called from the transport-bar poll cycler (and any legacy callers
|
/** Called from the transport-bar poll cycler (and any legacy callers
|
||||||
* that might still reference `window.changeDashboardPollInterval`). */
|
* that might still reference `window.changeDashboardPollInterval`). */
|
||||||
@@ -635,6 +759,127 @@ function _sectionContent(sectionKey: string, itemsHtml: string): string {
|
|||||||
return `<div class="dashboard-section-content"${isCollapsed ? ' style="display:none"' : ''}>${itemsHtml}</div>`;
|
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> {
|
export async function loadDashboard(forceFullRender: boolean = false): Promise<void> {
|
||||||
if (_dashboardLoading) return;
|
if (_dashboardLoading) return;
|
||||||
set_dashboardLoading(true);
|
set_dashboardLoading(true);
|
||||||
@@ -644,7 +889,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Fire all requests in a single batch to avoid sequential RTTs
|
// Fire all requests in a single batch to avoid sequential RTTs
|
||||||
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp, haStatusResp, mqttStatusResp, deviceStatesResp] = await Promise.all([
|
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, playlists, syncClocksResp, haStatusResp, mqttStatusResp, deviceStatesResp] = await Promise.all([
|
||||||
outputTargetsCache.fetch().catch((): any[] => []),
|
outputTargetsCache.fetch().catch((): any[] => []),
|
||||||
fetchWithAuth('/automations').catch(() => null),
|
fetchWithAuth('/automations').catch(() => null),
|
||||||
devicesCache.fetch().catch((): any[] => []),
|
devicesCache.fetch().catch((): any[] => []),
|
||||||
@@ -652,6 +897,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
fetchWithAuth('/output-targets/batch/states').catch(() => null),
|
fetchWithAuth('/output-targets/batch/states').catch(() => null),
|
||||||
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
|
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
|
||||||
loadScenePresets(),
|
loadScenePresets(),
|
||||||
|
loadPlaylists().catch((): ScenePlaylist[] => []),
|
||||||
fetchWithAuth('/sync-clocks').catch(() => null),
|
fetchWithAuth('/sync-clocks').catch(() => null),
|
||||||
fetchWithAuth('/home-assistant/status').catch(() => null),
|
fetchWithAuth('/home-assistant/status').catch(() => null),
|
||||||
fetchWithAuth('/mqtt/status').catch(() => null),
|
fetchWithAuth('/mqtt/status').catch(() => null),
|
||||||
@@ -717,8 +963,22 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
// Build dynamic HTML (targets, automations)
|
// Build dynamic HTML (targets, automations)
|
||||||
let dynamicHtml = '';
|
let dynamicHtml = '';
|
||||||
let runningIds: any[] = [];
|
let runningIds: any[] = [];
|
||||||
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0 && haStatus.total_sources === 0 && mqttStatus.total_sources === 0) {
|
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'))} →
|
||||||
|
</button>
|
||||||
|
</div>`)}
|
||||||
|
</div>` : '';
|
||||||
|
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>${_raSection}`;
|
||||||
} else {
|
} else {
|
||||||
const enriched = targets.map(target => ({
|
const enriched = targets.map(target => ({
|
||||||
...target,
|
...target,
|
||||||
@@ -906,6 +1166,19 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scene Playlists section — running playlist (if any) sorts first.
|
||||||
|
if (playlists.length > 0) {
|
||||||
|
const ordered = [...playlists].sort(
|
||||||
|
(a, b) => Number(b.is_running === true) - Number(a.is_running === true),
|
||||||
|
);
|
||||||
|
const playlistCards = ordered.map(p => renderDashboardPlaylist(p)).join('');
|
||||||
|
const playlistGrid = `<div class="dashboard-autostart-grid">${playlistCards}</div>`;
|
||||||
|
sectionFragments['playlists'] = `<div class="dashboard-section" data-section="playlists">
|
||||||
|
${_sectionHeader('playlists', t('dashboard.section.playlists'), playlists.length, '', 'dashboard-playlists')}
|
||||||
|
${_sectionContent('playlists', playlistGrid)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Sync Clocks section
|
// Sync Clocks section
|
||||||
if (syncClocks.length > 0) {
|
if (syncClocks.length > 0) {
|
||||||
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
|
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
|
||||||
@@ -945,6 +1218,23 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
</div>`;
|
</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'))} →
|
||||||
|
</button>
|
||||||
|
</div>`)}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
// Now assemble in layout-driven order, skipping invisible
|
// Now assemble in layout-driven order, skipping invisible
|
||||||
// sections and the perf section (which is always rendered
|
// sections and the perf section (which is always rendered
|
||||||
// separately at the top for chart-persistence reasons).
|
// separately at the top for chart-persistence reasons).
|
||||||
@@ -985,8 +1275,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
existingPerf.style.display = perfVisible ? '' : 'none';
|
existingPerf.style.display = perfVisible ? '' : 'none';
|
||||||
}
|
}
|
||||||
const dynamic = container.querySelector('.dashboard-dynamic');
|
const dynamic = container.querySelector('.dashboard-dynamic');
|
||||||
if (dynamic && dynamic.innerHTML !== dynamicHtml) {
|
if (dynamic) {
|
||||||
dynamic.innerHTML = dynamicHtml;
|
_reconcileDynamicSections(dynamic as HTMLElement, dynamicHtml);
|
||||||
}
|
}
|
||||||
_applyGlobalLayoutAttrs();
|
_applyGlobalLayoutAttrs();
|
||||||
}
|
}
|
||||||
@@ -1004,6 +1294,9 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
_startUptimeTimer();
|
_startUptimeTimer();
|
||||||
startPerfPolling();
|
startPerfPolling();
|
||||||
|
|
||||||
|
// Async-load the Recent Activity widget (non-blocking — never blocks the main render).
|
||||||
|
_loadRecentActivityWidget().catch(() => { /* widget failure is non-fatal */ });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
console.error('Failed to load dashboard:', error);
|
console.error('Failed to load dashboard:', error);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user