Compare commits

...

3 Commits

Author SHA1 Message Date
alexei.dolgolyov 8bdcc17799 chore: release v0.7.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 11s
Build Release / build-linux (push) Successful in 2m54s
Build Release / build-docker (push) Successful in 3m50s
Build Release / build-windows (push) Successful in 4m36s
2026-05-26 00:35:38 +03:00
alexei.dolgolyov f591e258f7 fix(storage/database): reopen connection on lifespan restart
Database opened its sqlite3 connection eagerly in __init__ and closed it
in close(); the lifespan called close() on shutdown. In production this
is fine — the lifespan runs once per process. Under pytest the module-
level ``db`` singleton survives across every TestClient session, so the
second test file's lifespan startup hit
``sqlite3.ProgrammingError: Cannot operate on a closed database`` at
fixture-setup time (AutoBackupEngine.__init__ → db.get_setting("…")
was the first reader). 65 spurious "errors" on a full Windows pytest run.

- Database: extract _open() from __init__, add ensure_open() that
  reopens iff _conn is None, and have close() null _conn after the
  TRUNCATE checkpoint so re-close is idempotent.
- main.py lifespan startup: call db.ensure_open() before any setting
  read, so subsequent TestClient sessions get a live connection.
- tests/storage/test_database_reopen.py: pin the four invariants —
  close→ensure_open round-trips data, ensure_open is a no-op when
  open, close is idempotent, and using the DB after close without
  ensure_open raises (callers must opt in).

Full backend suite: 1551 pass / 1 skip / 0 errors. Ruff clean.
2026-05-26 00:26:36 +03:00
alexei.dolgolyov f6486f9b34 perf(dashboard): diff FPS charts + cache spark SVG nodes; i18n perf strings
- dashboard: only destroy/recreate FPS charts for added/removed/detached
  targets; skip the history fetch when local samples already exist.
  Drops sync-clock `is_running` from the structure signature so toggles
  don't trigger a full rebuild; route clock/automation refresh through
  the in-place path.
- perf-charts: cache SVG skeleton per spark host and mutate node
  attributes instead of rewriting `innerHTML` every poll. Memoize
  patches/devices rendering by content signature so unchanged ticks
  no longer restart CSS animations. Skip render for env-hidden cards.
- perf-charts: switch `/system/performance` poll to `fetchWithAuth`,
  re-read `dashboardPollInterval` per tooltip move, and route the
  remaining hardcoded English strings ("no captures", "{n} total",
  "{rate} skipped/s", tooltip age, metric labels) through `t()`.
- locales: add `perf.no_captures`, `perf.captures_count`,
  `perf.ratio_of_requested`, `perf.total_count`, `perf.skipped_per_sec`,
  `perf.tip.now`, `perf.tip.ago` in en/ru/zh.
2026-05-26 00:12:29 +03:00
12 changed files with 553 additions and 167 deletions
+146 -22
View File
@@ -1,43 +1,167 @@
## v0.6.1 (2026-05-10)
## v0.7.0 (2026-05-26)
A device-support release: **seven new device families**, a unified **pairing UX**,
a brand-new **HTTP-endpoint** output type, **multi-broker MQTT + Zigbee2MQTT**
support, a major **shutdown / data-safety** fix, and a deep architectural
refactor pass that landed registry patterns for every dispatch hot path.
### Features
- Per-surface card presentation modes (C/M/D/R) for the UI ([75ca487](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/75ca487))
- Customisable card icon for all entity types ([0f5850e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0f5850e))
- HA-Light: broadcast a single Color Value Source to all entities ([a79f4bf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a79f4bf))
- Targets: customisable card icon plus HA-light stop action ([ced72fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ced72fc))
- Customisable card icon plate for devices ([49ddabb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49ddabb))
#### New device types
- **DDP** — standalone Open-Pixel-Control-style target for Pixelblaze / ESPixelStick / xLights / Falcon endpoints, port 4048 ([8f1140a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f1140a))
- **Yeelight** — Xiaomi/Yeelight bulbs and lightstrips over JSON-RPC on port 55443, SSDP discovery ([4b65005](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b65005))
- **WiZ Connected** — Philips WiZ smart bulbs over UDP on port 38899, broadcast discovery ([ede627b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ede627b))
- **LIFX** — LIFX bulbs and lightstrips over the binary LIFX LAN protocol on port 56700 ([8f9d490](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f9d490))
- **Govee LAN** — Govee Wi-Fi bulbs and ambient kits, multicast discovery (requires "LAN Control" enabled in the Govee Home app) ([887131d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/887131d))
- **Open Pixel Control (OPC)** — Fadecandy boards, xLights/Falcon, OPC bridges, port 7890 with channel addressing ([31c6c3a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/31c6c3a))
- **Nanoleaf** — Light Panels / Canvas / Shapes / Lines / Elements over the documented HTTP REST API on port 16021 ([426484a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/426484a))
#### New output type
- **HTTP endpoint output target** — POST live strip frames to any user-configured HTTP endpoint, alongside WLED / MQTT / Hue. Full editor + storage + routes ([d6cc800](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d6cc800))
#### Pairing flow
- Generic **pairing UX scaffold** — 30-second SVG ring + countdown, instructions, retry/cancel. First concrete consumer is Nanoleaf; Tuya/Twinkly slot into the same shape later ([2f31680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f31680))
#### MQTT / Zigbee2MQTT
- **Multi-broker MQTT** + new **Zigbee2MQTT light output target** sharing the HA-Light editor. Legacy single-broker YAML/env config auto-migrates to a "Default Broker" MQTTSource on startup ([530316c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/530316c))
#### Editor experience
- **Live preview** for color-strip sources of every type that can render without external calibration (audio, math_wave, weather, game_event, api_input, mapped, composite, processed) ([337984c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/337984c))
- **Expanded automations** — new rule shapes + matching UI inputs + 285 lines of dispatch coverage ([3fe66d8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3fe66d8))
- **Expanded value sources** — storage + schema + UI for the new value-source kinds the per-type factory refactor introduced ([737fd72](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/737fd72))
- **Card icon picker expanded** from 44 → 120 icons across 5 new categories (weather, nature, controls, status, office) ([cdf7d94](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cdf7d94))
- **closeIfPristine** modal save-guard — editing an unchanged entity now silently closes the modal instead of firing a misleading "updated" toast ([f03cb30](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f03cb30))
- New **MiniSelect** primitive for compact dropdowns that don't justify the full IconSelect grid; **IconSelect** gains a defence-in-depth XSS sanitiser on the icon channel ([9ff83bd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ff83bd), [507e138](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/507e138))
#### Updater
- **SSRF-validated redirect chain** in the update service so a hostile mirror can't bounce the updater to a private IP. Stricter `restart.ps1` argument handling + clearer logs ([45d12b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45d12b2))
### Bug Fixes
- Shutdown: apply target stop actions before tearing down HA/MQTT so devices end up in their configured state ([6a07a6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6a07a6b))
- **Survive PC restart** — SQLite was running WAL with `synchronous=NORMAL` and `Database.close()` was never called, so an unclean Windows shutdown rolled the DB back to the last checkpoint and silently lost recent edits. Now uses `synchronous=FULL` + `wal_autocheckpoint=100` + explicit `wal_checkpoint(TRUNCATE)` on close, and a hidden WM_QUERYENDSESSION / WM_ENDSESSION window keeps Windows from force-killing the process before the lifespan can finish ([e24f9d3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e24f9d3))
- **Devices PATCH preserves URL** — PATCH-without-`url` (rename / icon-only) used to drop the address into the processor as None ([0dd8d43](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0dd8d43))
- **HA Light brightness scale** — `_send_entity_color` was double-applying `brightness_scale` below 1 (quartered output for a half-scale) and skipping it above 1 (boost lost). Now one `clamp(max(r,g,b) * bs * vs, 0, 255)` pass with regression coverage ([ad84b60](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ad84b60))
- **Dashboard "MODIFIED" badge** no longer fires retroactively on un-edited legacy layouts — `userModified` is now driven by actual edits, not deep-equal drift from defaults ([e4bf58d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4bf58d))
- **Transport-bar uptime** repaints on `/health` response instead of waiting up to ~10 s for the next poll ([f1b0f0e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f1b0f0e))
- **Pre-merge device-support review pass** — `update_device` no longer double-encrypts secrets in memory; `GET /devices` strips paired-only secrets behind boolean flags; SSRF validation on every new driver; corrupt-envelope decrypt returns `""` instead of deleting the device row; `update_device` URL trim matches create; Govee discovery port-4002 collision serialised behind a module lock; Nanoleaf mDNS scan cleans up tasks on cancel; pair endpoint stops logging userinfo / exception bodies ([0e3ae78](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e3ae78))
- **value_source factory contract** — `_build_game_event` raises `NotImplementedError` (preserves the historical store contract) and `create_source` runs `build_source` before `_check_name_unique` so an invalid `source_type` raises the right error ([c1aa2eb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c1aa2eb))
- **`utils/url_scheme` + `utils/net_classify`** were referenced but untracked on a clean checkout — server failed to start with `ModuleNotFoundError`. Now committed ([7736bc6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7736bc6))
### Performance
- **Capture hot paths vectorised** — WGC swaps per-frame ~30 MB BGRA→RGB fancy-index allocations for `cv2.cvtColor` into a 3-slot pre-allocated pool; MSS uses `screenshot.raw + cv2.cvtColor` with 256-byte change-detection; DXcam/BetterCam fixes a silent name-mangling factory leak; dominant-colour reduction is ~10× faster via packed-RGB `np.bincount` ([f184ef0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f184ef0))
- **Event-driven frame hand-off** — `LiveStream` gains a `frame_id` + `Condition`, consumers wait instead of polling, ring buffer grows 3 → 5 slots, `_blend_u16` uses `cv2.addWeighted`. Up to one `frame_time` of glass-to-LED latency saved at matched FPS ([ee4fa81](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ee4fa81))
- **WLED brightness threshold** caches per-frame `np.max` keyed on frame identity instead of reducing the LED array every loop ([6e4c1b6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e4c1b6))
- **Dashboard FPS charts** now diff target ids and only recreate added/removed/detached charts (skipping the history fetch when local samples already exist), and spark SVGs are mutated in place instead of `innerHTML`-rewritten every poll. Memoised patches/devices rendering by content signature so unchanged ticks no longer restart CSS animations ([f6486f9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f6486f9))
---
### Development / Internal
#### CI/Build
#### Architecture audit — registry patterns everywhere
- Android: fail-fast on missing release keystore before SDK setup ([a026f0b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a026f0b))
- **Color-strip stream dispatch** — `ColorStripStreamManager.acquire()` and `ws_stream._create_stream()` now share a `STREAM_BUILDERS` registry keyed by source type, with import-time coverage assertion against `_SOURCE_TYPE_MAP`. CSS response builder gets the same treatment ([563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac))
- **Value-source create / update** — `ValueSourceStore.create_source` shrinks from ~260 → ~25 lines via per-type builder/applier functions in a new `storage.value_source_factories` module ([3b8f00e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3b8f00e))
- **SystemMetricsValueStream** — three parallel `if/elif` chains collapse into a `MetricSpec(name, read_psutil, read_fallback, normalize, prime)` registry in `core.processing.metric_readers` ([9f3f346](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9f3f346))
- **Automation engine** — per-rule-type bodies become `_handle_<kind>` methods, dispatch table built once at class-creation, unknown-type fallback logs instead of silently returning False ([98fb61d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/98fb61d))
- **Effect renderer dispatch** — `@_effect_renderer("fire")` decorators + class-level `_RENDERERS` dict replace per-frame dict-rebuild + silent fire fallback ([97dae2c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/97dae2c))
- **Output-target response builders** — `isinstance` ladder + silent fabricated-LED fallback replaced with `_TARGET_RESPONSE_BUILDERS` dict and a runtime `RuntimeError` for unknown subclasses ([2f15fbb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f15fbb))
- **Versioned data migrations** — replaces a naked `blob.replace(...)` migration with `storage.data_migrations.MigrationRunner` backed by a `data_migrations` audit table and atomic transactions ([563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac))
#### Chores
#### Dedup / refactor
- Clean up `cfg` abbreviation and stale TODO link ([e65dcb4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e65dcb4))
- **Edge-to-LED kernels** in `PixelMapper` + `AdvancedPixelMapper` deduped into a shared `core.capture.edge_interpolation` module ([5fec8db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fec8db))
- **HA/Z2M `_swap_color_source`** unified behind a shared `light_target_helpers.swap_color_source` helper ([29bdacf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/29bdacf))
- **Single-pixel `_average_color`** lifted out of 6 LED drivers into `core.devices.pixel_reduce.average_color` ([cc87fba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc87fba))
- **Static → single rename** for the color-strip source kind. Storage keeps backward-compatible serialisation ([826e680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/826e680))
- **Bindable types** extracted into `types/bindable.ts`; the wider `types.ts` god-module split is staged for a follow-up frontend sprint ([05f73ee](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05f73ee))
- **WebSocket auth** — 11 `except Exception` sites around handshake replaced with a narrow `_WS_SEND_BENIGN_EXC` tuple; receive path adds explicit observability ([ea7ee88](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ea7ee88))
- **Backend hardening bundle** — MQTT task tracking + drain resilience, credential encryption with auto-migration, devices watcher task tracking, WLED scheme inference at boundaries, streaming-upload caps, `asyncio.gather(return_exceptions=True)` on broadcast loops, WebSocket Origin allow-list, `/docs` auth-gate ([898912f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/898912f))
- **Frontend infra** — inbound-event allowlist mirroring the server side, `closeIfPristine` adoption across editors, MiniSelect markup for filter pickers ([ddae571](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ddae571))
- **PEP-604 union sweep** — `ruff --select UP007,UP045 --fix` converted ~1760 sites from `Optional[T]` / `Union[X, Y]` to `T | None` / `X | Y`. Hooks bumped to ruff v0.15.12 to recognise UP045 ([888f8fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/888f8fd))
- **Typed window globals** — 59 `(window as any).foo` sites across 19 feature modules switched to typed `window.foo` against `global-types.d.ts` ([0035172](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0035172))
- **Processing magic numbers** lifted to named module constants so tests can monkeypatch them ([d38021f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d38021f))
- **`Database.ensure_open()`** — module-level singleton reopens cleanly across lifespan cycles, fixing 65 spurious `sqlite3.ProgrammingError` setup failures on Windows pytest aggregate runs ([f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25))
#### Tests
- WLED URL scheme integration + IPv6 regression coverage ([907bdaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/907bdaf))
- Lifespan reopen invariants on `Database` ([f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25))
- Hundreds of new tests covering every registry / factory / migration introduced above
#### Tooling / docs
- `.vex.toml` makes vex the project's primary code-search backend with auto-update + semantic embeddings ([06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba))
- `REVIEW_TODO.md` captures audit items deliberately deferred; `TODO.md` records the architecture-audit remainder ([06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba), [628c6b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/628c6b2))
- Locale + CLAUDE.md upkeep alongside the new features ([fd46c51](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd46c51), [48dbdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/48dbdb9), [17684af](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17684af), [390d2b4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/390d2b4))
---
<details>
<summary>All Commits</summary>
<summary>All Commits (55)</summary>
| Hash | Message | Author |
|------|---------|--------|
| [75ca487](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/75ca487) | feat(ui): per-surface card presentation modes (C/M/D/R) | alexei.dolgolyov |
| [e65dcb4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e65dcb4) | chore: clean up cfg abbreviation and stale TODO link | alexei.dolgolyov |
| [6a07a6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6a07a6b) | fix(shutdown): apply target stop actions before tearing down HA/MQTT | alexei.dolgolyov |
| [0f5850e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0f5850e) | feat(ui): customisable card icon for all entity types | alexei.dolgolyov |
| [a79f4bf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a79f4bf) | feat(ha-light): broadcast a single Color Value Source to all entities | alexei.dolgolyov |
| [ced72fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ced72fc) | feat(targets): customisable card icon + HA-light stop action | alexei.dolgolyov |
| [49ddabb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49ddabb) | feat(ui): customisable card icon plate for devices | alexei.dolgolyov |
| [a026f0b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a026f0b) | ci(android): fail-fast on missing release keystore before SDK setup | alexei.dolgolyov |
| Hash | Message |
|------|---------|
| [f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25) | fix(storage/database): reopen connection on lifespan restart |
| [f6486f9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f6486f9) | perf(dashboard): diff FPS charts + cache spark SVG nodes; i18n perf strings |
| [48dbdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/48dbdb9) | docs(review-todo): check off items addressed in 2026-05-23 autonomous pass |
| [0035172](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0035172) | refactor(types): migrate (window as any) statics to typed window globals |
| [888f8fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/888f8fd) | refactor(types): PEP-604 union sweep + UP007/UP045 enforcement |
| [ea7ee88](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ea7ee88) | refactor(api/auth): narrow WS exception catches + observability log |
| [d38021f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d38021f) | refactor(processing): hot-path magic numbers -> named module constants |
| [507e138](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/507e138) | feat(ui/icon-select): defence-in-depth XSS sanitiser on icon channel |
| [907bdaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/907bdaf) | test(url-scheme): WLED route-level integration + IPv6 regression |
| [0dd8d43](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0dd8d43) | fix(devices): preserve existing URL on PATCH-without-url |
| [fd46c51](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd46c51) | docs: TODO + CLAUDE.md notes + locale keys for new features |
| [ddae571](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ddae571) | chore(frontend-infra): inbound-event allowlist + storage/state touch-ups |
| [898912f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/898912f) | chore(backend): MQTT/WLED/devices/capture/utils + api routes hardening |
| [45d12b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45d12b2) | feat(update-service): SSRF-validated redirects + restart hardening |
| [826e680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/826e680) | refactor(color-strip): rename static -> single + frontend follow-through |
| [737fd72](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/737fd72) | feat(value-sources): extend storage + schema + UI alongside new kinds |
| [3fe66d8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3fe66d8) | feat(automations): expand automation rules + UI + engine coverage |
| [f03cb30](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f03cb30) | feat(modal): closeIfPristine save-guard + per-editor adoption |
| [9ff83bd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ff83bd) | feat(ui): MiniSelect primitive + IconSelect XSS hardening + typed globals |
| [d6cc800](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d6cc800) | feat(http-endpoints): introduce HTTP endpoint output target stack |
| [06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba) | chore(tooling): vex semantic-search config + REVIEW_TODO backlog |
| [628c6b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/628c6b2) | docs: capture architecture-audit remainder for follow-up sessions |
| [2f15fbb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f15fbb) | refactor(output-targets): registry + coverage assertion for response builders |
| [c1aa2eb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c1aa2eb) | fix(value-source): preserve store contract for game_event + error precedence |
| [3b8f00e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3b8f00e) | refactor(value-source): per-type factories for create / update dispatch |
| [05f73ee](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05f73ee) | refactor(types): extract bindable primitives into types/bindable.ts (H6 partial) |
| [9f3f346](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9f3f346) | refactor(value-source): MetricSpec registry for SystemMetricsValueStream |
| [98fb61d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/98fb61d) | refactor(automations): rule dispatch via class-level handler table |
| [5fec8db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fec8db) | refactor(capture): lift duplicated edge-to-LED kernels into shared module |
| [97dae2c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/97dae2c) | refactor(processing): replace inline effect dispatch with @_effect_renderer registry |
| [29bdacf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/29bdacf) | refactor(processing): dedupe HA/Z2M _swap_color_source via shared helper |
| [563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac) | refactor(storage,processing): kind registries + versioned data migrations |
| [e24f9d3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e24f9d3) | fix(shutdown): survive PC restart with WAL fsync + Win32 session-end guard |
| [e4bf58d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4bf58d) | fix(dashboard): stop showing perpetual MODIFIED for un-edited legacy layouts |
| [f1b0f0e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f1b0f0e) | fix(ui): repaint transport-bar uptime as soon as /health responds |
| [17684af](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17684af) | docs: record review-fix pass in TODO.md |
| [0e3ae78](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e3ae78) | fix(devices): address pre-merge review findings |
| [7736bc6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7736bc6) | fix(utils): commit url_scheme + net_classify dependencies |
| [390d2b4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/390d2b4) | docs: mark expand-device-support branch ready for merge |
| [cc87fba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc87fba) | refactor(devices): extract _average_color to pixel_reduce |
| [426484a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/426484a) | feat(devices): Nanoleaf OpenAPI target type + first pair-flow user |
| [2f31680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f31680) | feat(devices): pairing-UX scaffold (Phase 2) |
| [31c6c3a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/31c6c3a) | feat(devices): Open Pixel Control (OPC) target type |
| [887131d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/887131d) | feat(devices): Govee LAN target type |
| [8f9d490](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f9d490) | feat(devices): LIFX LAN target type |
| [ede627b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ede627b) | feat(devices): WiZ Connected LAN target type |
| [4b65005](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b65005) | feat(devices): Yeelight LAN target type |
| [8f1140a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f1140a) | feat(devices): standalone DDP target type |
| [337984c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/337984c) | feat(color-strips): in-editor live preview for all viable source types |
| [530316c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/530316c) | feat(mqtt): multi-broker MQTT + Zigbee2MQTT light target |
| [6e4c1b6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e4c1b6) | perf(wled): cache per-frame max-pixel for brightness threshold |
| [ee4fa81](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ee4fa81) | perf(processing): event-driven frame hand-off and scheduling fixes |
| [f184ef0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f184ef0) | perf(capture): vectorize hot paths and fix engine bugs |
| [ad84b60](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ad84b60) | fix(ha-light): apply brightness_scale once and respect boost multipliers |
| [cdf7d94](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cdf7d94) | feat(ui): expand card icon picker (44 -> 120 icons, +5 categories) |
</details>
+1 -1
View File
@@ -40,7 +40,7 @@ android {
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install.
versionCode = ledgrabVersionCode
versionName = "0.6.1"
versionName = "0.7.0"
ndk {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ledgrab"
version = "0.6.1"
version = "0.7.0"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
+1 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
# In dev (running from source without `pip install -e .`) and on Android
# (Chaquopy embeds the source directly with no dist-info), we additionally
# read pyproject.toml so the version is always correct without manual sync.
_FALLBACK_VERSION = "0.4.2"
_FALLBACK_VERSION = "0.7.0"
def _read_pyproject_version() -> str | None:
+6
View File
@@ -220,6 +220,12 @@ async def lifespan(app: FastAPI):
Handles startup and shutdown events.
"""
# Startup
# Reopen the DB if a previous lifespan cycle closed it. No-op in
# production (lifespan runs once per process) but required under
# pytest where the module-level ``db`` singleton outlives multiple
# TestClient sessions and would otherwise be stuck on a closed
# connection.
db.ensure_open()
logger.info(f"Starting LED Grab v{__version__}")
logger.info(f"Python version: {sys.version}")
logger.info(f"Server listening on {config.server.host}:{config.server.port}")
@@ -160,26 +160,48 @@ function _createFpsChart(canvasId: string, actualHistory: number[], currentHisto
}
async function _initFpsCharts(runningTargetIds: string[]): Promise<void> {
_destroyFpsCharts();
// Diff against current charts so we only tear down/recreate what actually
// changed. The slow-path full-render swaps card DOM, which detaches the
// canvases existing Chart.js instances are bound to — detect those by
// checking whether the chart's canvas is still in the document.
const desired = new Set(runningTargetIds);
const existing = new Set(Object.keys(_fpsCharts));
const removed = [...existing].filter(id => !desired.has(id));
const added = [...desired].filter(id => !existing.has(id));
const detached: string[] = [];
for (const id of existing) {
if (!desired.has(id)) continue;
const canvas = _fpsCharts[id]?.canvas;
if (canvas && !document.body.contains(canvas)) detached.push(id);
}
const toDestroy = new Set([...removed, ...detached]);
const toCreate = new Set([...added, ...detached]);
// Seed FPS history from server ring buffer (on first load and tab switches)
if (runningTargetIds.length > 0) {
for (const id of toDestroy) {
if (_fpsCharts[id]) { _fpsCharts[id].destroy(); delete _fpsCharts[id]; }
}
for (const id of removed) {
delete _fpsHistory[id];
delete _fpsCurrentHistory[id];
}
// Only fetch history for genuinely new ids that have no local samples yet.
// Skips the network round-trip entirely on full renders triggered by
// settings changes — local FPS history accumulated via _pushFps stays
// intact and seeds the recreated chart.
const needSeed = [...toCreate].filter(id => !_fpsHistory[id]);
if (needSeed.length > 0) {
const data = await fetchMetricsHistory();
if (data) {
const serverTargets = data.targets || {};
for (const id of runningTargetIds) {
for (const id of needSeed) {
const samples = serverTargets[id] || [];
_fpsHistory[id] = samples.map(s => s.fps).filter(v => v != null);
_fpsCurrentHistory[id] = samples.map(s => s.fps_current).filter(v => v != null);
}
}
}
// Clean up history for targets that are no longer running
for (const id of Object.keys(_fpsHistory)) {
if (!runningTargetIds.includes(id)) { delete _fpsHistory[id]; delete _fpsCurrentHistory[id]; }
}
for (const id of runningTargetIds) {
for (const id of toCreate) {
const canvas = document.getElementById(`dashboard-fps-${id}`);
if (!canvas) continue;
const actualH = _fpsHistory[id] || [];
@@ -815,23 +837,18 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const sendTimingAvg = sendTimingCount > 0 ? sendTimingSum / sendTimingCount : 0;
updateSendTiming(sendTimingAvg, sendTimingMax, sendTimingCount);
// Check if we can do an in-place metrics update (same targets, not first load)
// Fast path: in-place update when the entity *set* is unchanged.
// Running-state toggles for sync clocks/automations/integrations are
// handled by the in-place updaters below, so they don't need to
// invalidate this check. Structural adds/removes/edits are caught by
// `server:entity_changed` SSE which sets `forceFullRender=true` and
// skips this path so cards rebuild with fresh settings.
const newRunningIds = running.map(t => t.id).sort().join(',');
const prevRunningIds = [..._lastRunningIds].sort().join(',');
const newSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
const newSyncClockIds = syncClocks.map(c => c.id).sort().join(',');
const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent');
const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newSyncClockIds === _lastSyncClockIds;
if (structureUnchanged && !forceFullRender && running.length > 0) {
_updateRunningMetrics(running);
_updateSyncClocksInPlace(syncClocks);
_updateIntegrationsInPlace(haStatus, mqttStatus);
_cacheUptimeElements();
_startUptimeTimer();
startPerfPolling();
set_dashboardLoading(false);
return;
}
if (structureUnchanged && forceFullRender) {
if (structureUnchanged && !forceFullRender) {
if (running.length > 0) _updateRunningMetrics(running);
_updateAutomationsInPlace(automations);
_updateSyncClocksInPlace(syncClocks);
@@ -981,7 +998,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
}
_mountDashboardCardModeToggles();
_lastRunningIds = runningIds;
_lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
_lastSyncClockIds = syncClocks.map(c => c.id).sort().join(',');
_cacheUptimeElements();
await _initFpsCharts(runningIds);
_startUptimeTimer();
@@ -1304,7 +1321,7 @@ export async function dashboardPauseClock(clockId: string): Promise<void> {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.paused'), 'success');
loadDashboard(true);
loadDashboard();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -1316,7 +1333,7 @@ export async function dashboardResumeClock(clockId: string): Promise<void> {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.resumed'), 'success');
loadDashboard(true);
loadDashboard();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -1328,7 +1345,7 @@ export async function dashboardResetClock(clockId: string): Promise<void> {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.reset_done'), 'success');
loadDashboard(true);
loadDashboard();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -1352,7 +1369,7 @@ function _debouncedDashboardReload(forceFullRender: boolean = false): void {
}
document.addEventListener('server:state_change', () => _debouncedDashboardReload());
document.addEventListener('server:automation_state_changed', () => _debouncedDashboardReload(true));
document.addEventListener('server:automation_state_changed', () => _debouncedDashboardReload());
document.addEventListener('server:device_health_changed', () => _debouncedDashboardReload());
const _DASHBOARD_ENTITY_TYPES = new Set(['output_target', 'automation', 'scene_preset', 'sync_clock', 'device', 'home_assistant_source']);
@@ -8,7 +8,7 @@
* cheap for 120-sample lines.
*/
import { API_BASE, getHeaders, fetchMetricsHistory } from '../core/api.ts';
import { fetchMetricsHistory, fetchWithAuth } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { dashboardPollInterval } from '../core/state.ts';
import { isActiveTab } from '../core/tab-registry.ts';
@@ -397,6 +397,16 @@ export function renderPerfSection(): string {
return `<div class="perf-charts-grid">${cellsHtml}</div>`;
}
/** Last `perf-patches-list` element we rendered into + the signature of
* its rendered content. Both must match for us to skip a re-render —
* identity differs when the perf section gets rebuilt (layout change,
* full dashboard re-render) so a stale cached signature can't suppress
* a needed write. The signature mirrors the inputs that actually shape
* the HTML so unchanged polls don't tear down the DOM and restart the
* pulsing-dot CSS animation on every tick. */
let _lastPatchesListEl: HTMLElement | null = null;
let _lastPatchesSig: string | null = null;
/** Externally-called from dashboard.ts whenever the running-target set
* is recomputed. Updates the Active Patches cell with count + a short
* list of running channels and their current FPS. */
@@ -412,6 +422,9 @@ export function updateActivePatches(
const listEl = document.getElementById('perf-patches-list');
if (!listEl) return;
let nextHtml: string;
let sig: string;
if (running.length === 0) {
// Empty-state hint — "Ready to launch" when targets exist but
// none are running, or "No patches yet" when the user hasn't
@@ -420,24 +433,33 @@ export function updateActivePatches(
? 'dashboard.perf.patches.empty.none'
: 'dashboard.perf.patches.empty.idle';
const hintText = t(hintKey) || (totalCount === 0 ? 'No patches yet' : 'Ready to launch');
listEl.innerHTML = `<div class="perf-patches-empty">
sig = `empty:${hintKey}:${hintText}`;
nextHtml = `<div class="perf-patches-empty">
<span class="perf-patches-empty-dot" aria-hidden="true"></span>
<span class="perf-patches-empty-text">${escapeText(hintText)}</span>
</div>`;
return;
} else {
const visible = running.slice(0, 4);
const rows = visible.map((r, i) => {
const colors = ['--ch-signal', '--ch-cyan', '--ch-magenta', '--ch-amber'];
const colorVar = colors[i % colors.length];
const fps = r.fps != null ? `${r.fps.toFixed(1)} FPS` : '—';
return `<div class="perf-patches-row">
<span class="perf-patches-stripe" style="background: var(${colorVar})"></span>
<span class="perf-patches-name">${escapeText(r.name)}</span>
<span class="perf-patches-fps">${fps}</span>
</div>`;
}).join('');
const overflow = Math.max(0, running.length - 4);
const more = overflow > 0 ? `<div class="perf-patches-more">+${overflow} more</div>` : '';
sig = `run:${visible.map(r => `${r.id}|${r.name}|${r.fps != null ? r.fps.toFixed(1) : '-'}`).join(';')}:more=${overflow}`;
nextHtml = rows + more;
}
const rows = running.slice(0, 4).map((r, i) => {
const colors = ['--ch-signal', '--ch-cyan', '--ch-magenta', '--ch-amber'];
const colorVar = colors[i % colors.length];
const fps = r.fps != null ? `${r.fps.toFixed(1)} FPS` : '—';
return `<div class="perf-patches-row">
<span class="perf-patches-stripe" style="background: var(${colorVar})"></span>
<span class="perf-patches-name">${escapeText(r.name)}</span>
<span class="perf-patches-fps">${fps}</span>
</div>`;
}).join('');
const more = running.length > 4 ? `<div class="perf-patches-more">+${running.length - 4} more</div>` : '';
listEl.innerHTML = rows + more;
if (listEl === _lastPatchesListEl && sig === _lastPatchesSig) return;
_lastPatchesListEl = listEl;
_lastPatchesSig = sig;
listEl.innerHTML = nextHtml;
}
function escapeText(s: string): string {
@@ -533,31 +555,7 @@ export function updateTotalCaptureFpsActual(
if (_history.capture_fps_actual.length > MAX_SAMPLES) _history.capture_fps_actual.shift();
if (fps > _captureFpsActualPeak) _captureFpsActualPeak = fps;
const valEl = document.getElementById('perf-capture_fps_actual-value');
if (valEl) {
if (reportingCount === 0) {
valEl.innerHTML = '<span class="perf-chart-hint">no captures</span>';
} else {
const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
const ceilingSuffix = targetSum > 0
? `<span class="perf-fps-ceiling">/ ${Math.round(targetSum)}</span>`
: '';
valEl.innerHTML = `${fpsText}${ceilingSuffix}<span class="perf-fps-unit">fps</span>`;
}
}
const subEl = document.getElementById('perf-capture_fps_actual-sub');
if (subEl) {
if (reportingCount === 0) {
subEl.textContent = '';
} else if (targetSum > 0) {
// Drop ratio reads "how far behind requested" — useful at-a-glance
// diagnostic for capture saturation.
const ratio = Math.max(0, Math.min(1, fps / targetSum));
subEl.textContent = `${Math.round(ratio * 100)}% of requested · ${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
} else {
subEl.textContent = `${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
}
}
_paintCaptureFpsActualValue(fps, targetSum, reportingCount);
_renderChartSvg('capture_fps_actual', /*animate=*/true);
}
@@ -742,13 +740,18 @@ export function updateTotalErrors(
const subEl = document.getElementById('perf-errors-sub');
if (subEl) {
const parts: string[] = [];
if (totalErrors > 0) parts.push(`${totalErrors} total`);
if (skippedRate >= 0.1) parts.push(`${skippedRate.toFixed(skippedRate < 10 ? 1 : 0)} skipped/s`);
if (totalErrors > 0) parts.push(t('perf.total_count', { count: totalErrors }));
if (skippedRate >= 0.1) parts.push(t('perf.skipped_per_sec', { rate: skippedRate.toFixed(skippedRate < 10 ? 1 : 0) }));
subEl.textContent = parts.join(' · ');
}
_renderChartSvg('errors', /*animate=*/true);
}
/** Same identity+signature pair as the patches cell — see
* `_lastPatchesListEl` for the rationale. */
let _lastDevicesDotsEl: HTMLElement | null = null;
let _lastDevicesSig: string | null = null;
/** Devices cell — online / total count with a dot strip showing each
* device's connection state at a glance. */
export function updateDevices(
@@ -773,24 +776,34 @@ export function updateDevices(
const dotsEl = document.getElementById('perf-devices-dots');
if (!dotsEl) return;
let nextHtml: string;
let sig: string;
if (total === 0) {
dotsEl.innerHTML = '';
return;
nextHtml = '';
sig = 'empty';
} else {
// Cap visible dots to avoid wrapping weirdness; indicate overflow.
const MAX_DOTS = 24;
const shown = states.slice(0, MAX_DOTS);
const overflow = total - shown.length;
const dots = shown.map(s => {
const name = s.device_name || s.device_id;
const latency = s.device_latency_ms != null ? ` · ${s.device_latency_ms.toFixed(0)}ms` : '';
const title = `${name} · ${s.device_online ? t('perf.online') : t('perf.offline')}${latency}`;
return `<span class="perf-devices-dot ${s.device_online ? 'is-online' : 'is-offline'}" title="${escapeText(title)}"></span>`;
}).join('');
const more = overflow > 0 ? `<span class="perf-devices-more">+${overflow}</span>` : '';
nextHtml = dots + more;
// Latency is included so a changing ms value re-renders the title
// tooltips; without it the hover would show stale latency.
sig = `n=${total}:o=${overflow}:` + shown.map(s => `${s.device_id}|${s.device_online ? 1 : 0}|${s.device_latency_ms ?? '-'}|${s.device_name ?? ''}`).join(';');
}
// Cap visible dots to avoid wrapping weirdness; indicate overflow.
const MAX_DOTS = 24;
const shown = states.slice(0, MAX_DOTS);
const overflow = total - shown.length;
const dots = shown.map(s => {
const name = s.device_name || s.device_id;
const latency = s.device_latency_ms != null ? ` · ${s.device_latency_ms.toFixed(0)}ms` : '';
const title = `${name} · ${s.device_online ? t('perf.online') : t('perf.offline')}${latency}`;
return `<span class="perf-devices-dot ${s.device_online ? 'is-online' : 'is-offline'}" title="${escapeText(title)}"></span>`;
}).join('');
const more = overflow > 0
? `<span class="perf-devices-more">+${overflow}</span>`
: '';
dotsEl.innerHTML = dots + more;
if (dotsEl === _lastDevicesDotsEl && sig === _lastDevicesSig) return;
_lastDevicesDotsEl = dotsEl;
_lastDevicesSig = sig;
dotsEl.innerHTML = nextHtml;
}
/** Resolve the global animations preference once per render — read from
@@ -843,7 +856,94 @@ function _scrollSpark(host: HTMLElement, sliceN: number): void {
});
}
/** Render the SVG sparkline into its container.
const SVG_NS = 'http://www.w3.org/2000/svg';
interface SparkNodes {
svg: SVGSVGElement;
ref: SVGLineElement;
area: SVGPathElement;
line: SVGPathElement;
appLine: SVGPathElement;
}
/** Cache of host → spark-node refs so we don't pay four `querySelector`
* calls per spark per poll. Entries drop automatically once the host
* element is GC'd; stale entries (svg detached from the host) are
* filtered out at lookup time via `host.contains(cached.svg)`. */
const _sparkNodeCache = new WeakMap<HTMLElement, SparkNodes>();
/** Lazily build (or reuse) the stable SVG skeleton for a spark host. The
* reference line + two system paths + one app path stay in the DOM for
* the life of the card; each render only mutates their attributes. This
* avoids the per-tick `innerHTML` rewrite that previously destroyed and
* recreated the entire SVG subtree. If the host still has an `<svg>` but
* any expected child is missing — or the cached node is detached — the
* skeleton is rebuilt fresh so callers can rely on every field being a
* live element. */
function _ensureSparkNodes(host: HTMLElement): SparkNodes {
const cached = _sparkNodeCache.get(host);
if (cached && host.contains(cached.svg)) {
return cached;
}
const existing = host.querySelector('svg.perf-chart-svg') as SVGSVGElement | null;
if (existing) {
const ref = existing.querySelector('line.perf-chart-ref') as SVGLineElement | null;
const area = existing.querySelector('path.perf-chart-area') as SVGPathElement | null;
const line = existing.querySelector('path.perf-chart-line') as SVGPathElement | null;
const appLine = existing.querySelector('path.perf-chart-app-line') as SVGPathElement | null;
if (ref && area && line && appLine) {
const nodes: SparkNodes = { svg: existing, ref, area, line, appLine };
_sparkNodeCache.set(host, nodes);
return nodes;
}
existing.remove();
}
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('class', 'perf-chart-svg');
svg.setAttribute('viewBox', `0 0 ${SPARK_W} ${SPARK_H}`);
svg.setAttribute('preserveAspectRatio', 'none');
svg.setAttribute('aria-hidden', 'true');
const ref = document.createElementNS(SVG_NS, 'line');
ref.setAttribute('class', 'perf-chart-ref');
ref.setAttribute('stroke-width', '1');
ref.setAttribute('stroke-dasharray', '5 4');
ref.setAttribute('opacity', '0.4');
ref.style.display = 'none';
svg.appendChild(ref);
const area = document.createElementNS(SVG_NS, 'path');
area.setAttribute('class', 'perf-chart-area');
area.setAttribute('opacity', '0.14');
area.style.display = 'none';
svg.appendChild(area);
const line = document.createElementNS(SVG_NS, 'path');
line.setAttribute('class', 'perf-chart-line');
line.setAttribute('fill', 'none');
line.setAttribute('stroke-width', '1.5');
line.setAttribute('stroke-linejoin', 'round');
line.style.display = 'none';
svg.appendChild(line);
const appLine = document.createElementNS(SVG_NS, 'path');
appLine.setAttribute('class', 'perf-chart-app-line');
appLine.setAttribute('fill', 'none');
appLine.setAttribute('stroke-width', '1.1');
appLine.setAttribute('stroke-dasharray', '4 3');
appLine.setAttribute('stroke-linejoin', 'round');
appLine.setAttribute('opacity', '0.75');
appLine.style.display = 'none';
svg.appendChild(appLine);
host.appendChild(svg);
const nodes: SparkNodes = { svg, ref, area, line, appLine };
_sparkNodeCache.set(host, nodes);
return nodes;
}
/** Render the SVG sparkline into its container by mutating the existing
* path nodes (created once via `_ensureSparkNodes`).
*
* `animate` triggers the smooth left-scroll animation — only set on
* paths that just pushed a fresh sample. Non-sample paths (mode
@@ -852,6 +952,11 @@ function _scrollSpark(host: HTMLElement, sliceN: number): void {
function _renderChartSvg(key: string, animate: boolean = false): void {
const host = document.getElementById(`perf-chart-${key}`);
if (!host) return;
// Cards env-hidden (e.g. GPU on a host without one, Temp without
// LibreHardwareMonitor) keep their DOM but should skip every render
// cycle — the SVG never paints anyway.
const card = host.closest('.perf-chart-card') as HTMLElement | null;
if (card?.hasAttribute('hidden')) return;
// Effective window (in seconds) for this cell — global default
// unless the cell pinned its own. With 1 sample/sec polling the
// window in seconds equals the desired sample count; we trim the
@@ -886,7 +991,7 @@ function _renderChartSvg(key: string, animate: boolean = false): void {
: key === 'send_timing' ? Math.max(20, _sendTimingPeak * 1.2)
: 100;
const paths: string[] = [];
const nodes = _ensureSparkNodes(host);
// FPS-only: dashed "target ceiling" reference line at the sum of
// fps_target across running targets, so the spark reads as "live
@@ -894,34 +999,45 @@ function _renderChartSvg(key: string, animate: boolean = false): void {
if (key === 'fps' && _fpsTargetSum > 0 && _fpsTargetSum <= yMax) {
const span = yMax - yMin || 1;
const refY = SPARK_H - ((_fpsTargetSum - yMin) / span) * (SPARK_H - 2) - 1;
paths.push(`<line x1="0" y1="${refY.toFixed(1)}" x2="${SPARK_W}" y2="${refY.toFixed(1)}" stroke="${color}" stroke-width="1" stroke-dasharray="5 4" opacity="0.4" />`);
nodes.ref.setAttribute('x1', '0');
nodes.ref.setAttribute('y1', refY.toFixed(1));
nodes.ref.setAttribute('x2', String(SPARK_W));
nodes.ref.setAttribute('y2', refY.toFixed(1));
nodes.ref.setAttribute('stroke', color);
nodes.ref.style.display = '';
} else {
nodes.ref.style.display = 'none';
}
if (showSystem && sys.length > 1) {
paths.push(_pathFor(sys, yMin, yMax, color, 'sys', sliceN));
}
if (showApp && app.length > 1) {
paths.push(_pathFor(app, yMin, yMax, color, 'app', sliceN));
const built = _buildPath(sys, yMin, yMax, sliceN);
nodes.area.setAttribute('d', built.area);
nodes.area.setAttribute('fill', color);
nodes.area.style.display = '';
nodes.line.setAttribute('d', built.line);
nodes.line.setAttribute('stroke', color);
nodes.line.style.display = '';
} else {
nodes.area.style.display = 'none';
nodes.line.style.display = 'none';
}
host.innerHTML = `
<svg class="perf-chart-svg" viewBox="0 0 ${SPARK_W} ${SPARK_H}" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="perf-fade-${key}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${color}" stop-opacity="0.32"/>
<stop offset="100%" stop-color="${color}" stop-opacity="0"/>
</linearGradient>
</defs>
${paths.join('')}
</svg>`;
if (showApp && app.length > 1) {
const built = _buildPath(app, yMin, yMax, sliceN);
nodes.appLine.setAttribute('d', built.line);
nodes.appLine.setAttribute('stroke', color);
nodes.appLine.style.display = '';
} else {
nodes.appLine.style.display = 'none';
}
if (animate) _scrollSpark(host, sliceN);
}
/** Build <path> elements (area + stroke) for one series. */
function _pathFor(history: number[], yMin: number, yMax: number, color: string, kind: 'sys' | 'app', sliceN: number = MAX_SAMPLES): string {
/** Compute the line + area path `d` strings for one series. */
function _buildPath(history: number[], yMin: number, yMax: number, sliceN: number): { line: string; area: string } {
const n = history.length;
if (n < 2) return '';
if (n < 2) return { line: '', area: '' };
// Right-align so the most recent sample sits at the right edge —
// matches an instrument display where new values tick in from the
// right. `sliceN` is the spark's logical sample-count "width" (set
@@ -943,15 +1059,7 @@ function _pathFor(history: number[], yMin: number, yMax: number, color: string,
const firstX = offset;
const lastX = offset + (n - 1) * step;
const area = `M${firstX.toFixed(1)},${SPARK_H} L${points.join(' L')} L${lastX.toFixed(1)},${SPARK_H} Z`;
if (kind === 'sys') {
const gradientId = `perf-fade-${color.replace(/[^a-z0-9]/gi, '')}`;
return `
<path d="${area}" fill="${color}" opacity="0.14" />
<path d="${line}" stroke="${color}" stroke-width="1.5" fill="none" stroke-linejoin="round" />`;
}
// App line: thinner, dashed, no fill
return `<path d="${line}" stroke="${color}" stroke-width="1.1" fill="none" stroke-dasharray="4 3" stroke-linejoin="round" opacity="0.75" />`;
return { line, area };
}
function _pushSample(key: string, sysValue: number, appValue: number | null): void {
@@ -994,13 +1102,15 @@ function _renderValuePair(key: string, sysVal: string, appVal: string | null): v
async function _fetchPerformance(): Promise<void> {
try {
const resp = await fetch(`${API_BASE}/system/performance`, { headers: getHeaders() });
const resp = await fetchWithAuth('/system/performance');
if (!resp.ok) return;
const data = await resp.json();
_lastFetchData = data;
_applyPerfDataToDom(data, /*pushHistory=*/true);
} catch {
// Silently ignore transient fetch errors
} catch (err) {
// Auth failures are surfaced via fetchWithAuth's redirect flow; swallow
// other transient fetch errors so the next tick can recover.
if ((err as { isAuth?: boolean })?.isAuth) return;
}
}
@@ -1288,7 +1398,7 @@ function _seedAggregateHistories(samples: any[]): void {
// series. `null` samples (no devices online) become 0 in the
// history so the spark drops to floor instead of going jagged.
const latencySeries = samples
.map((s: any) => (typeof s.device_latency_avg_ms === 'number' && Number.isFinite(s.device_latency_avg_ms)) ? s.device_latency_avg_ms : 0)
.map((s: any) => (typeof s.device_latency_avg_ms === 'number' && Number.isFinite(s.device_latency_avg_ms)) ? s.device_latency_avg_ms : 0);
if (latencySeries.length > 0) {
_history.device_latency = latencySeries.slice(-MAX_SAMPLES);
_deviceLatencyPeak = Math.max(50, ..._history.device_latency);
@@ -1306,7 +1416,7 @@ function _seedAggregateHistories(samples: any[]): void {
// the subtitle/tooltip but isn't a separate spark line to avoid
// adding visual noise.
const sendSeries = samples
.map((s: any) => (typeof s.send_timing_avg_ms === 'number' && Number.isFinite(s.send_timing_avg_ms)) ? s.send_timing_avg_ms : 0)
.map((s: any) => (typeof s.send_timing_avg_ms === 'number' && Number.isFinite(s.send_timing_avg_ms)) ? s.send_timing_avg_ms : 0);
if (sendSeries.length > 0) {
_history.send_timing = sendSeries.slice(-MAX_SAMPLES);
const maxes = samples
@@ -1350,7 +1460,7 @@ function _paintCaptureFpsActualValue(fps: number, targetSum: number, reportingCo
const valEl = document.getElementById('perf-capture_fps_actual-value');
if (valEl) {
if (reportingCount === 0) {
valEl.innerHTML = '<span class="perf-chart-hint">no captures</span>';
valEl.innerHTML = `<span class="perf-chart-hint">${t('perf.no_captures')}</span>`;
} else {
const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
const ceilingSuffix = targetSum > 0
@@ -1363,11 +1473,14 @@ function _paintCaptureFpsActualValue(fps: number, targetSum: number, reportingCo
if (subEl) {
if (reportingCount === 0) {
subEl.textContent = '';
} else if (targetSum > 0) {
const ratio = Math.max(0, Math.min(1, fps / targetSum));
subEl.textContent = `${Math.round(ratio * 100)}% of requested · ${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
} else {
subEl.textContent = `${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
const captures = t('perf.captures_count', { count: reportingCount });
if (targetSum > 0) {
const ratio = Math.max(0, Math.min(1, fps / targetSum));
subEl.textContent = t('perf.ratio_of_requested', { percent: Math.round(ratio * 100), captures });
} else {
subEl.textContent = captures;
}
}
}
}
@@ -1388,8 +1501,8 @@ function _paintErrorsValue(errorsRate: number, totalErrors: number, skippedRate:
const subEl = document.getElementById('perf-errors-sub');
if (subEl) {
const parts: string[] = [];
if (totalErrors > 0) parts.push(`${totalErrors} total`);
if (skippedRate >= 0.1) parts.push(`${skippedRate.toFixed(skippedRate < 10 ? 1 : 0)} skipped/s`);
if (totalErrors > 0) parts.push(t('perf.total_count', { count: totalErrors }));
if (skippedRate >= 0.1) parts.push(t('perf.skipped_per_sec', { rate: skippedRate.toFixed(skippedRate < 10 ? 1 : 0) }));
subEl.textContent = parts.join(' · ');
}
}
@@ -1614,31 +1727,44 @@ function _formatSampleValue(key: string, v: number): string {
return `${v.toFixed(1)}%`;
}
/** Map metric-key → locale-key for the card title shown on tooltip.
* Most keys are `dashboard.perf.<key>` but the three FPS metrics live
* under `total_*` to match the card headers (the bare `fps` etc. keys
* do not exist). Keeps the tooltip header in lockstep with the card. */
const METRIC_LABEL_KEYS: Record<string, string> = {
cpu: 'dashboard.perf.cpu',
ram: 'dashboard.perf.ram',
gpu: 'dashboard.perf.gpu',
temp: 'dashboard.perf.temp',
fps: 'dashboard.perf.total_fps',
capture_fps: 'dashboard.perf.total_capture_fps',
capture_fps_actual: 'dashboard.perf.total_capture_fps_actual',
network: 'dashboard.perf.network',
device_latency: 'dashboard.perf.device_latency',
send_timing: 'dashboard.perf.send_timing',
errors: 'dashboard.perf.errors',
};
function _metricLabel(key: string): string {
if (key === 'cpu') return 'CPU';
if (key === 'ram') return 'RAM';
if (key === 'gpu') return 'GPU';
if (key === 'temp') return 'Temp';
if (key === 'fps') return 'Total FPS';
if (key === 'capture_fps') return 'Total Source FPS';
if (key === 'capture_fps_actual') return 'Total Capture FPS';
if (key === 'network') return 'Network';
if (key === 'device_latency') return 'Device Latency';
if (key === 'send_timing') return 'Send Timing';
if (key === 'errors') return 'Errors';
return key.toUpperCase();
const labelKey = METRIC_LABEL_KEYS[key];
if (!labelKey) return key.toUpperCase();
const translated = t(labelKey);
return translated === labelKey ? key.toUpperCase() : translated;
}
let _tooltipBound = false;
function _initSparkTooltip(): void {
if (_tooltipBound) return;
_tooltipBound = true;
const intervalMs = dashboardPollInterval || 2000;
// Bound on `document.body` instead of `.perf-charts-grid` so the
// listener survives `rerenderPerfGrid()` replacing the grid element.
// The handler bails out unless the cursor is actually over a spark,
// so the hot-path cost is just one `closest()` call per mousemove.
document.body.addEventListener('mousemove', (rawEv) => {
// Re-read poll interval per handler tick — the user can change it
// mid-session via the transport bar slider, and a captured value
// would skew the "N seconds ago" calculation after every change.
const intervalMs = dashboardPollInterval || 2000;
const ev = rawEv as MouseEvent;
const target = ev.target as HTMLElement;
if (!target || !target.closest) { _hideTooltip(); return; }
@@ -1693,7 +1819,7 @@ function _initSparkTooltip(): void {
<span class="perf-tip-v">${_formatSampleValue(key, appValue)}</span>
</div>`
: '';
const ageLine = `<div class="perf-tip-age">${ageSecs === 0 ? 'now' : `${ageSecs}s`}</div>`;
const ageLine = `<div class="perf-tip-age">${ageSecs === 0 ? t('perf.tip.now') : t('perf.tip.ago', { seconds: ageSecs })}</div>`;
tip.innerHTML = `<div class="perf-tip-row">${sysLine}</div>${appLine}${ageLine}`;
tip.style.display = 'block';
@@ -453,6 +453,14 @@
"perf.max_ms": "max {ms}ms",
"perf.targets_count.one": "{count} target",
"perf.targets_count.other": "{count} targets",
"perf.no_captures": "no captures",
"perf.captures_count.one": "{count} capture",
"perf.captures_count.other": "{count} captures",
"perf.ratio_of_requested": "{percent}% of requested · {captures}",
"perf.total_count": "{count} total",
"perf.skipped_per_sec": "{rate} skipped/s",
"perf.tip.now": "now",
"perf.tip.ago": "{seconds}s",
"device.last_seen.label": "Last seen",
"device.last_seen.just_now": "just now",
"device.last_seen.seconds": "%ds ago",
@@ -509,6 +509,15 @@
"perf.targets_count.one": "{count} цель",
"perf.targets_count.few": "{count} цели",
"perf.targets_count.many": "{count} целей",
"perf.no_captures": "нет источников",
"perf.captures_count.one": "{count} источник",
"perf.captures_count.few": "{count} источника",
"perf.captures_count.many": "{count} источников",
"perf.ratio_of_requested": "{percent}% от запрошенного · {captures}",
"perf.total_count": "{count} всего",
"perf.skipped_per_sec": "{rate} пропущ/с",
"perf.tip.now": "сейчас",
"perf.tip.ago": "{seconds}с",
"device.last_seen.label": "Последний раз",
"device.last_seen.just_now": "только что",
"device.last_seen.seconds": "%d с назад",
@@ -506,6 +506,14 @@
"perf.max_ms": "最大 {ms}毫秒",
"perf.targets_count.one": "{count} 个目标",
"perf.targets_count.other": "{count} 个目标",
"perf.no_captures": "无源",
"perf.captures_count.one": "{count} 个源",
"perf.captures_count.other": "{count} 个源",
"perf.ratio_of_requested": "请求的 {percent}% · {captures}",
"perf.total_count": "共 {count}",
"perf.skipped_per_sec": "跳过 {rate}/秒",
"perf.tip.now": "现在",
"perf.tip.ago": "{seconds} 秒",
"device.last_seen.label": "最近检测",
"device.last_seen.just_now": "刚刚",
"device.last_seen.seconds": "%d秒前",
+27 -1
View File
@@ -83,6 +83,18 @@ class Database:
def __init__(self, db_path: str | Path):
self._path = Path(db_path)
self._path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._conn: sqlite3.Connection | None = None
self._open()
def _open(self) -> None:
"""Open the SQLite connection, apply PRAGMAs, and ensure schema.
Split out from ``__init__`` so :meth:`ensure_open` can re-establish
the connection after a previous :meth:`close` — needed under pytest
where the same module-level ``db`` singleton survives across multiple
FastAPI lifespan cycles.
"""
self._conn = sqlite3.connect(
str(self._path),
check_same_thread=False,
@@ -98,11 +110,22 @@ class Database:
# window of unsynced data stays small even if close() is skipped.
self._conn.execute("PRAGMA wal_autocheckpoint=100")
self._conn.execute("PRAGMA busy_timeout=5000")
self._lock = threading.RLock()
self._ensure_schema()
logger.info(f"Database opened: {self._path}")
def ensure_open(self) -> None:
"""Reopen the connection if a prior :meth:`close` left it released.
Idempotent — a no-op when the connection is already live. Production
only calls this once per process (lifespan startup); under pytest a
single ``Database`` instance can outlive multiple TestClient
lifespans, each of which closes the connection on shutdown.
"""
with self._lock:
if self._conn is None:
self._open()
# -- Schema management ---------------------------------------------------
def _ensure_schema(self) -> None:
@@ -352,9 +375,12 @@ class Database:
loses the WAL between graceful app shutdown and a later PC shutdown.
"""
with self._lock:
if self._conn is None:
return
try:
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except sqlite3.Error as e:
logger.warning("WAL checkpoint on close failed: %s", e)
self._conn.close()
self._conn = None
logger.info("Database connection closed")
@@ -0,0 +1,62 @@
"""Regression coverage for the lifespan reopen path on ``Database``.
The production server only runs a single FastAPI lifespan per process, so
``Database.close()`` releasing the connection is fine. Pytest is different:
``ledgrab.main`` is imported once and its module-level ``db`` singleton
survives across every TestClient session, each of which closes the
connection on shutdown.
Before :meth:`Database.ensure_open` existed, the second TestClient session
hit ``sqlite3.ProgrammingError: Cannot operate on a closed database`` at
fixture-setup time when the lifespan startup tried to read settings (the
``AutoBackupEngine`` constructor was the first reader). These tests pin
the close+reopen cycle so the cascade can't silently come back.
"""
import sqlite3
import pytest
from ledgrab.storage.database import Database
def test_close_then_ensure_open_reopens_connection(tmp_path):
db = Database(tmp_path / "reopen.db")
db.set_setting("k", {"v": 1})
db.close()
db.ensure_open()
assert db.get_setting("k") == {"v": 1}
db.close()
def test_ensure_open_is_idempotent_when_already_open(tmp_path):
db = Database(tmp_path / "idempotent.db")
original_conn = db._conn
db.ensure_open()
# Same live connection — no spurious reconnect when already open.
assert db._conn is original_conn
db.close()
def test_close_is_idempotent(tmp_path):
db = Database(tmp_path / "double_close.db")
db.close()
# Second close must not raise (lifespan shutdown can run twice in
# quick test sessions). Connection stays released.
db.close()
assert db._conn is None
def test_operation_after_close_without_reopen_raises(tmp_path):
db = Database(tmp_path / "no_reopen.db")
db.close()
# Without ensure_open, attempting to use the DB fails loudly rather
# than silently re-connecting — callers must opt in.
with pytest.raises((sqlite3.ProgrammingError, AttributeError)):
db.get_setting("anything")