Compare commits
3 Commits
48dbdb90e9
...
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bdcc17799 | |||
| f591e258f7 | |||
| f6486f9b34 |
+146
-22
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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秒前",
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user