diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index 4c637d8..2e6c035 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -1550,17 +1550,18 @@ renders its row + tab), and only then is the highlight applied — asserted by checking the flashed element exists and is the final rendered node at flash time (the render call precedes the flash call within the tick). -### VF-A5 — Brief (<100 ms), white, reduced-motion-aware highlight — VF5 +### VF-A5 — White, reduced-motion-aware highlight (now ~1.2 s; see LV-A1) — VF5 ```bash ( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js ) grep -nE '@media \(prefers-reduced-motion: reduce\)' web/public/assets/styles/base.css -grep -nE '(animation|transition)[^;]*\b(9[0-9]|[1-9][0-9]?)ms' web/public/assets/styles/base.css +grep -nE '(animation|transition)[^;]*(1\.2s|120[0-9]ms)' web/public/assets/styles/base.css # amended by LV-A1 ``` **Expected:** pass / non-empty. The flash helper applies a one-shot highlight class and clears it (or relies on a self-completing CSS animation) with **no layout -shift**. `base.css` carries the highlight keyframe/rule with a duration **< 100 ms** -and a `@media (prefers-reduced-motion: reduce)` guard that suppresses the animation -(data still updates; only the visual is withheld). The white color and sub-100 ms +shift**. `base.css` carries the highlight keyframe/rule with a duration **~1.2 s** +(amended from the original <100 ms by **LV-A1** below) and a +`@media (prefers-reduced-motion: reduce)` guard that suppresses the animation +(data still updates; only the visual is withheld). The white onset and the fade duration are confirmed by reading the rule. ### VF-A6 — Render & cache invariants preserved; #822 holds — VF6 @@ -1648,3 +1649,197 @@ collapse two meshcore messages on different channels" example is **updated** to different channel *names* (the stable identifier) rather than different local indices — it is updated, not removed. No POST/GET/event contract change and no ingestor change, so **C2**, `CONTRACTS.md`, and the Python suite are unaffected. + +--- + +## Bugfix: Live-update DOM handling (map overlay, chat-tab scroll, last_heard fan-out) + +Three defects in how a live SSE update touches the DOM, fixed independently of +the (separately specced) flash visual redesign: +(1) a `positions` / `telemetry` ingest advances the affected node's `last_heard` +server-side (`touch_node_last_seen`) but published only its own collection, so +the live dashboard never re-pulled the node row and the node table's "last seen" +stayed stale until the safety poll; +(2) the channel-tab list's horizontal scroll reset to the first tab on every +refresh because `renderChatTabs` rebuilds the whole subtree (`replaceChildren`) +and force-scrolled the active tab into view; +(3) an open map-marker short-info overlay closed on every refresh because +`renderMap` clears and rebuilds all markers (`clearLayers`), orphaning the +overlay's anchor so `cleanupOrphans` closed it. +Web-side only (Ruby publish fan-out + frontend JS); no POST/GET shape change, so +the apex (I) and privacy (II) invariants are untouched (the new `nodes` publish +is moot under `PRIVATE`, mirroring #822 / PS6). + +### LD-A1 -- positions/telemetry ingest also publishes `nodes` (live last_heard refresh) +```bash +( cd web && bundle exec rspec spec/pubsub_spec.rb \ + -e "publishes nodes on a positions ingest" \ + -e "publishes nodes on a telemetry ingest" \ + -e "does not publish nodes on a neighbors or traces ingest" ) +``` +**Expected:** pass. `POST /api/positions` and `POST /api/telemetry` each publish +both their own collection **and** `nodes` (the telemetry route also now +invalidates `api:nodes:`), so the dashboard re-fetches `/api/nodes` and the +node-table "last seen" refreshes and flashes live -- mirroring the #822 +messages-to-nodes fan-out. `POST /api/neighbors` and `/api/traces` deliberately +do **not** publish `nodes`, honoring the VF3 boundary that neighbors/traces flash +nothing (their `last_heard` refresh is surfaced silently by the safety poll). + +### LD-A2 -- channel-tab horizontal scroll is preserved across a refresh +```bash +( cd web && node --test public/assets/js/app/__tests__/chat-tabs.test.js ) +``` +**Expected:** pass. `renderChatTabs` captures the channel-tab list's `scrollLeft` +before rebuilding the subtree and restores it afterward, and scrolls the active +tab into view **only** on an explicit user tab switch (not on a passive refresh) +-- so a live update no longer yanks the user back to the first tab while they +scroll the channel list. A re-render yields a fresh tab-list element whose +`scrollLeft` equals the pre-render value, and a passive render performs **zero** +`scrollIntoView` calls. + +### LD-A3 -- an open map-marker overlay survives a live re-render +```bash +( cd web && node --test public/assets/js/app/__tests__/short-info-overlay-manager.test.js \ + public/assets/js/app/main/__tests__/marker-overlay-preservation.test.js ) +``` +**Expected:** pass. The overlay stack gains `reanchor(oldAnchor, newAnchor)`, +which carries an open overlay onto a replacement anchor so a subsequent +`cleanupOrphans` keeps it open (it closed it before). `renderMap` snapshots the +node ids whose marker hosts an open overlay before `clearLayers()` and re-anchors +each onto the rebuilt marker (`captureOpenMarkerOverlays` / +`restoreMarkerOverlays`), so an overlay opened on the map stays open while live +updates fire instead of snapping shut on every refresh. + +### LD-R1 -- Regression: prior acceptance still holds +```bash +( cd web && npm test ) && ( cd web && bundle exec rspec ) +( . .venv/bin/activate && pytest -q tests/ ) +``` +**Expected:** all green. At risk and explicitly required to remain green: +**PS-A3 / PS-A4** (per-collection publish + coalescing -- the PS3 "thin event" +and burst-coalescing examples are **updated** to a single-collection route +(`neighbors`) since positions now also publishes `nodes`, not removed); +**VF-A2 / VF-A3** (flash gating + message-to-node fan-out -- the new +positions/telemetry-to-node fan-out reuses the same flash path, and neighbors/ +traces still flash nothing); **CR-A1** (an idle re-render still materialises 0 +entries -- the scroll/overlay preservation touches only already-built DOM); +**A2 / A2a / PS-A6** (privacy -- the new `nodes` publish is moot under `PRIVATE`); +and **B1** (all suites). + +--- + +## Feature: Live-update feedback v2 (fade, stacking, map wave, dedup, full log) + +Maps to SPEC decisions **LV1-LV9**, which deliberately amend VF2/VF3/VF5. The +<100 ms white strobe becomes a ~1.2 s white->role-colour fade with per-element +stacked timers; a node highlight also emits a map-marker wave; the message +highlight blinks only the message's own channel tab; the pub/sub gains a 1 s +per-collection publish cooldown; the Log tab logs every live-event class; and a +channel-tab dropdown selector is added. *Run JS suites from `web/`; run the +server in public mode for the curl/rspec checks.* + +### LV-A1 -- ~1.2 s white->role-colour fade replaces the <100 ms strobe -- LV1, LV3 +```bash +( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js ) +grep -nE '@media \(prefers-reduced-motion: reduce\)' web/public/assets/styles/base.css +grep -nE '(animation|transition)[^;]*(1\.2s|120[0-9]ms)' web/public/assets/styles/base.css +grep -nE -- '--flash-role-color' web/public/assets/styles/base.css +``` +**Expected:** pass / non-empty. The highlight keyframe runs **~1.2 s** (not +<100 ms), starts white and fades through the element's role colour +(`var(--flash-role-color, ...)`) with increasing transparency to nothing, with +**no layout shift** and a `prefers-reduced-motion: reduce` guard that suppresses +it. The flash helper's `FLASH_DURATION_MS` is ~1200 and only toggles a class. + +### LV-A2 -- per-element stacked timers; a re-flash restarts cleanly -- LV2 +```bash +( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js ) +``` +**Expected:** pass. `flashElement` runs each element on its own timer and, when +re-flashed mid-fade, **cancels the prior removal timer** before re-arming so the +class is never cleared early; two distinct elements flashed in the same tick each +keep an independent timer (no shared/global clock). + +### LV-A3 -- role colour is stamped on the element at render -- LV3 +```bash +( cd web && node --test public/assets/js/app/__tests__/node-rendering.test.js \ + public/assets/js/app/__tests__/main-flash.test.js ) +``` +**Expected:** pass. A rendered node-table row and chat message row carry +`--flash-role-color` set from `getRoleColor(role, protocol)` (so the fade lands on +the correct role colour for both protocols); the flash helper performs no colour +lookup of its own. + +### LV-A4 -- a message fades its row and ONLY its own channel tab -- LV4 +```bash +( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js \ + public/assets/js/app/__tests__/main-flash.test.js ) +``` +**Expected:** pass. A `messages` ping fades the message row(s) and highlights the +header of **only the message's own channel tab** (resolved via the message->tab +map), never merely the active tab; the author node's row + marker fade via the +existing message->nodes publish. + +### LV-A5 -- a node highlight emits a map-marker wave -- LV5 +```bash +( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js ) +grep -nE 'live-flash-wave|@keyframes .*wave' web/public/assets/styles/base.css +``` +**Expected:** pass / non-empty. Flashing a marker creates a transient expanding +wave overlay (from ~12 px, growing and fading toward the role colour over ~1.2 s) +added to the map and removed after the animation; `neighbors`/`traces` emit no +wave (VF3 boundary). The wave is non-interactive and causes no layout shift. + +### LV-A6 -- per-collection 1 s publish cooldown dedups duplicate events -- LV6 +```bash +( cd web && bundle exec rspec spec/pubsub_spec.rb -e "cooldown" ) +``` +**Expected:** pass. A burst of `publish(...)` calls is coalesced by the +**settle window** in `Subscriber#drain` (default 1 s, env-tunable +`SSE_PUBLISH_COOLDOWN`): once a change is pending the drain waits out the window, +then returns each changed collection **once** (the structural pending-map +coalescing), so N ingestors hearing a single packet produce one client +refresh/flash. Collections that change during the same window each emit once (not +suppressed). In-process only (no broker; apex-safe); `settle: 0` disables it. + +### LV-A7 -- the Log tab logs every live-event class incl. plaintext messages -- LV7 +```bash +( cd web && node --test public/assets/js/app/__tests__/chat-log-tabs.test.js ) +``` +**Expected:** pass. `buildChatTabModel(...).logEntries` includes a **plaintext** +message entry (previously only encrypted messages reached the Log), so every live +collection - nodes, messages (plain + encrypted), positions, telemetry, neighbors, +traces - has a Log representation. Hidden-protocol and PRIVATE gates already +applied to the chat are unchanged. + +### LV-A8 -- channel-tab dropdown selector -- LV8 +```bash +( cd web && node --test public/assets/js/app/__tests__/chat-tabs.test.js ) +``` +**Expected:** pass. `renderChatTabs` renders a compact selector listing every tab +that, when a channel is chosen, activates that tab - independent of the preserved +horizontal scroll (LD-A2). Tab order, the default-active tab, and all data +surfaces are unchanged. + +### LV-A9 -- engineering bar; invariants untouched -- LV9 +```bash +( cd web && bundle exec rspec ) && ( cd web && npm test ) +``` +**Expected:** pass. New code carries the exact Apache header + JSDoc/RDoc and is +100% unit-tested; `prefers-reduced-motion` suppresses both the fade and the wave. +Apex (I), privacy (II - messages still 404 under PRIVATE, so message fades/log are +moot there; the LV6 cooldown is in-process with no broker), and parity (IV - role +colours via `getRoleColor` for both protocols) are untouched. + +### LV-R1 -- Regression: prior acceptance still holds +```bash +( cd web && npm test ) && ( cd web && bundle exec rspec ) +( . .venv/bin/activate && pytest -q tests/ ) +``` +**Expected:** all green. **VF-A5 is amended** (the duration grep now matches +~1.2 s, not <100 ms) - updated, not removed. At risk and required to remain green: +**VF-A2** (flash still fires only on SSE-ping deltas), **VF-A4** (render before +flash), **VF-A6 / CR-A1** (idle re-render still materialises 0 entries), **LD-A1** +(positions/telemetry->nodes fan-out feeds the fade), **LD-A2** (tab scroll +preserved - the LV8 dropdown composes with it), **A2 / A2a / PS-A6** (privacy), +and **B1** (all suites). diff --git a/SPEC.md b/SPEC.md index 9483e07..f8dddaa 100644 --- a/SPEC.md +++ b/SPEC.md @@ -475,6 +475,38 @@ ingest path, no `/version` change. No invariant is contradicted. | **VF2** | **Flash only on SSE-ping deltas.** The white highlight fires **only** for rows delivered by an SSE-ping-driven targeted refresh (`runLiveRefresh`). The initial load (cold **or** warm-cache catch-up), the reconnect resync, and the slow safety poll do **not** flash — so the screen never strobes on load and a flash unambiguously means "this changed live while you were watching." Accepted limit: a change recovered *only* by resync or the safety poll updates without a flash. | interview | | **VF3** | **What flashes, per collection.** A `nodes` / `positions` / `telemetry` ping flashes the affected node's **map marker and node-table row** (the changed node ids are read from the ping's delta rows by `node_id`). A `messages` ping flashes the **message row** (the whole row) and the **channel tab header**; because the message ingest also touches the author node (#822), `POST /api/messages` **additionally publishes `nodes`** (extends PS4), so the author node's marker + table row flash too, with a freshly-updated "last seen". Detection is by id/collection, identical for both protocols (Invariant IV). **Scope:** `neighbors` / `traces` updates do **not** flash (they update silently) — a deliberate, documented boundary, deferrable to a follow-up. | interview | | **VF4** | **Render before flash.** The highlight is applied **after** the affected DOM is rendered and positioned in the same update tick — the node-table row + map marker, and the chat message row + channel tab, are materialized/placed first, then flashed (a post-render `requestAnimationFrame`/microtask step). This guarantees the flash lands on the final, placed element and is never applied to a not-yet-rendered element or an element still at its old position/offscreen. | interview | -| **VF5** | **Brief, accessible highlight.** The flash is **white**, lasts **<100 ms**, and is a one-shot CSS highlight with **no layout shift** (e.g. background/overlay only). It **respects `prefers-reduced-motion`**: when the user has reduce-motion enabled the highlight is suppressed via an `@media (prefers-reduced-motion: reduce)` guard — the data still updates live, only the animation is withheld. | interview | +| **VF5** | **Brief, accessible highlight.** The flash is **white**, lasts **<100 ms**, and is a one-shot CSS highlight with **no layout shift** (e.g. background/overlay only). It **respects `prefers-reduced-motion`**: when the user has reduce-motion enabled the highlight is suppressed via an `@media (prefers-reduced-motion: reduce)` guard — the data still updates live, only the animation is withheld. **Amended by LV1/LV2/LV5** (Feature: Live-update feedback v2): the <100 ms one-shot white flash is replaced by a ~1.2 s role-colour fade with per-element stacked timers, plus a map-marker wave; the white onset and reduced-motion suppression are retained. | interview | | **VF6** | **Preserve render & cache invariants (read-side only).** The flash is applied to **already-rendered/cached** DOM nodes (the chat entry cache, existing table rows, existing markers); it **never** re-materializes chat entries or issues per-node fetches, so an idle tick still materializes **0** entries (CR-A1) and the seed-then-delta cache (FC-A2) is untouched. The only non-frontend change is the additive `nodes` publish on message ingest, which is moot under `PRIVATE` (the message route 404s first), preserving Invariant II / PS6. | proposed | | **VF7** | **Engineering bar (D9).** The flash-trigger logic (changed-id selection, after-render ordering, SSE-ping-only gating, message⇒node fan-out) ships with 100% unit tests, JSDoc, and the exact Apache header; the `ingest.rb` `nodes`-on-message publish is covered by a Ruby spec; existing view/app specs that assert the removed controls are **updated** to assert their absence (not deleted). CSS keyframes have no gating command, so the criterion is the trigger logic + a view assertion; all existing suites stay green. | D9 + proposed | + +--- + +## Feature: Live-update feedback v2 (fade, stacking, map wave, dedup, full log) + +Reworks the live-update visual feedback shipped as VF1-VF7. The <100 ms white +strobe read as a glitch; this replaces it with a slower, legible fade and closes +the remaining live-update UX gaps. **Deliberately amends VF2/VF3/VF5** (the +flash's duration/colour/scope) while keeping their invariants (SSE-ping gating, +render-before-flash, reduced-motion). Frontend-only except one server change: a +per-collection publish cooldown in `application/pubsub.rb`. + +**Conflict check.** *Apex I / privacy II / parity IV* - consistent: read-side +visuals + one in-process cooldown (no broker, no row data on the push); messages +still 404 under `PRIVATE` so message fades/log are moot there; role colours come +from `getRoleColor` for both protocols. *VF2 (SSE-ping gating)* / *VF4 +(render-before-flash)* / *CR-A1 (idle materialises 0)* - retained. *PS4 +(coalesced/throttled)* - realised: LV6 adds the actual time-window the structural +coalescing lacked. + +| # | Decision | Source | +| --- | --- | --- | +| **LV1** | **Fade replaces strobe (amends VF5).** The highlight is a **~1.2 s** animation: the element flashes white, then fades through its **role colour** with increasing transparency back to its normal appearance. Applies to node-table rows and chat message rows. The white *onset*, no-layout-shift, and `prefers-reduced-motion` suppression of VF5 are retained; only the duration (<100 ms -> ~1.2 s) and the white->role-colour fade are new. | interview | +| **LV2** | **Per-element stacked timers.** Each highlighted element runs its **own** ~1.2 s clock; many elements updating within the window each fade independently, and re-updating the same element **restarts** its clock cleanly (the prior removal timer is cancelled, so a re-flash never truncates early). No shared/global flash clock. | interview | +| **LV3** | **Role-colour stamped at render.** The fade's role colour is `getRoleColor(role, protocol)`, written as a CSS custom property (`--flash-role-color`) onto the element at render time, so the keyframe needs no per-element JS and the flash helper only toggles a class. Protocol-neutral (Meshtastic + MeshCore palettes via `getRoleColor`), preserving Invariant IV. | interview + code | +| **LV4** | **Message fades its row + only its own channel tab (fixes VF3 in practice).** A `messages` ping fades the message row wherever rendered and highlights **only the message's own channel-tab header**, never merely the active tab; the message->tab map drives it. The author-node row + marker also fade via the existing message->nodes publish (#822). | interview | +| **LV5** | **Map-marker wave.** On a node highlight the marker emits an expanding **wave** ring (start ~12 px radius, grow to a bounded radius, fade transparency toward the role colour over ~1.2 s) in addition to the marker's own white->role fade. The wave is a transient, non-interactive overlay removed after the animation (no layout shift). `neighbors`/`traces` still emit nothing (VF3 boundary kept). | interview | +| **LV6** | **Publish settle window (server-side dedup).** The in-process pub/sub holds a brief **settle window** (default **1 s**, env-tunable) when a change lands, coalescing a burst so N ingestors relaying one packet yield one client refresh/flash, not N; each changed collection still emits at most once per window (structural pending-map coalescing). Server-side, in-process (apex-safe, no broker); realises PS4's "short debounce" with an actual time window. | interview | +| **LV7** | **Log tab logs every live-event class.** The chat **Log** tab gains an entry for every live collection, closing today's gaps: **plaintext** chat messages now appear in the Log (previously only encrypted did); every one of nodes/messages/positions/telemetry/neighbors/traces has a Log representation. Presentation-only, protocol-neutral; honours the hidden-protocol and PRIVATE gates already applied to the chat. | interview | +| **LV8** | **Channel-tab dropdown selector.** A compact selector control (a downward triangle) lists all channel tabs and jumps to a chosen one, independent of the (now-preserved, LD-A2) horizontal scroll. Presentation-only; does not change tab order, the default-active tab, or any data surface. | interview | +| **LV9** | **Engineering bar / invariants (D9).** All new code ships with 100% unit tests, JSDoc/RDoc, the exact Apache header, and clean linters; existing suites stay green. Apex (I), privacy (II - messages still 404 under PRIVATE; the LV6 cooldown is in-process), and parity (IV) are untouched. `prefers-reduced-motion` suppresses **all** new motion (fade + wave). | D9 + proposed | + diff --git a/web/lib/potato_mesh/application/pubsub.rb b/web/lib/potato_mesh/application/pubsub.rb index 30e3973..45857f9 100644 --- a/web/lib/potato_mesh/application/pubsub.rb +++ b/web/lib/potato_mesh/application/pubsub.rb @@ -63,6 +63,10 @@ module PotatoMesh # elapses, then returns and clears the pending set. The blocking drain lets # the SSE route emit a heartbeat on timeout without busy-looping. class Subscriber + # Default settle-window sleeper (real time). Injectable so a test can + # drive the LV6 cooldown deterministically without actually sleeping. + DEFAULT_SLEEPER = ->(seconds) { sleep(seconds) } + # Initialize an empty, open subscriber. def initialize @pending = {} @@ -97,11 +101,25 @@ module PotatoMesh # signals a timeout/heartbeat tick or a closed, drained subscriber. # # @param timeout [Numeric] maximum seconds to block when idle. + # @param settle [Numeric] LV6 cooldown: seconds to hold after a change + # lands so a burst coalesces into one batch (0 disables it). + # @param sleeper [#call] settle-window sleeper (injected by tests). # @return [Array Object}>] coalesced pending changes. - def drain(timeout:) + def drain(timeout:, settle: 0, sleeper: DEFAULT_SLEEPER) @mutex.synchronize do @condition.wait(@mutex, timeout) if @pending.empty? && !@closed + end + # Hold a brief settle window so a burst of writes to the same collection + # (e.g. N ingestors relaying one packet) coalesces into a single event + # rather than N (SPEC LV6 cooldown). The sleep runs OUTSIDE the lock so + # concurrent deliver() calls merge into @pending during the window; it + # is skipped on an idle heartbeat tick (nothing pending) and once closed. + if settle.positive? && !closed? && pending_count.positive? + sleeper.call(settle) + end + + @mutex.synchronize do events = @pending.keys.sort.map do |collection| { collection: collection, hint: @pending[collection] } end diff --git a/web/lib/potato_mesh/application/routes/events.rb b/web/lib/potato_mesh/application/routes/events.rb index 3770360..7bfb7b4 100644 --- a/web/lib/potato_mesh/application/routes/events.rb +++ b/web/lib/potato_mesh/application/routes/events.rb @@ -81,11 +81,12 @@ module PotatoMesh # @param subscriber [PubSub::Subscriber] this client's mailbox. # @param heartbeat [Numeric] max seconds to block per drain. # @param deadline_at [Numeric] monotonic time to stop pumping. + # @param settle [Numeric] LV6 per-collection publish cooldown (seconds). # @param clock [#call] monotonic clock source. # @return [void] - def pump(out, subscriber, heartbeat:, deadline_at:, clock: DEFAULT_CLOCK) + def pump(out, subscriber, heartbeat:, deadline_at:, settle: 0, clock: DEFAULT_CLOCK) until out.closed? || clock.call >= deadline_at - write_batch(out, subscriber.drain(timeout: heartbeat)) + write_batch(out, subscriber.drain(timeout: heartbeat, settle: settle)) end rescue IOError, Errno::EPIPE, Errno::ECONNRESET # The client vanished mid-write; stop pumping and let +ensure+ run. @@ -112,6 +113,7 @@ module PotatoMesh end heartbeat = PotatoMesh::Config.sse_heartbeat_seconds + cooldown = PotatoMesh::Config.sse_publish_cooldown_seconds deadline = Events::DEFAULT_CLOCK.call + PotatoMesh::Config.sse_max_lifetime_seconds # Plain +stream+ (not +:keep_open+): {pump} owns the loop, so when it @@ -121,7 +123,7 @@ module PotatoMesh stream do |out| # An initial comment confirms the open stream and flushes headers. out << ": connected\n\n" - Events.pump(out, subscriber, heartbeat: heartbeat, deadline_at: deadline) + Events.pump(out, subscriber, heartbeat: heartbeat, deadline_at: deadline, settle: cooldown) ensure PotatoMesh::App::PubSub.unsubscribe(subscriber) end diff --git a/web/lib/potato_mesh/application/routes/ingest.rb b/web/lib/potato_mesh/application/routes/ingest.rb index 00fc81b..5cb6201 100644 --- a/web/lib/potato_mesh/application/routes/ingest.rb +++ b/web/lib/potato_mesh/application/routes/ingest.rb @@ -388,6 +388,11 @@ module PotatoMesh end PotatoMesh::App::ApiCache.invalidate_prefix("api:positions:", "api:nodes:", "api:stats:") PotatoMesh::App::PubSub.publish("positions", private_mode: private_mode?) + # A position ingest also advances the node's last_heard + # (touch_node_last_seen), so publish a nodes change as well + # (mirrors the messages route, #822): the dashboard re-pulls and + # flashes that node with a freshly-updated "last seen". + PotatoMesh::App::PubSub.publish("nodes", private_mode: private_mode?) status 201 { status: "ok" }.to_json ensure @@ -438,8 +443,14 @@ module PotatoMesh telemetry_packets.each do |packet| insert_telemetry(db, packet, protocol_cache: protocol_cache) end - PotatoMesh::App::ApiCache.invalidate_prefix("api:telemetry:", "api:stats:") + # A telemetry ingest advances the node's last_heard + # (update_node_from_telemetry -> touch_node_last_seen), so also + # invalidate the nodes cache and publish a nodes change (mirrors + # the positions/messages routes): the dashboard re-pulls and + # flashes that node with a freshly-updated "last seen". + PotatoMesh::App::ApiCache.invalidate_prefix("api:telemetry:", "api:nodes:", "api:stats:") PotatoMesh::App::PubSub.publish("telemetry", private_mode: private_mode?) + PotatoMesh::App::PubSub.publish("nodes", private_mode: private_mode?) status 201 { status: "ok" }.to_json ensure diff --git a/web/lib/potato_mesh/config.rb b/web/lib/potato_mesh/config.rb index 2bc5f22..da0bc62 100644 --- a/web/lib/potato_mesh/config.rb +++ b/web/lib/potato_mesh/config.rb @@ -36,6 +36,12 @@ module PotatoMesh # prompting the client to reconnect (and resync). Bounds per-connection # thread occupancy and gives graceful shutdown a hard ceiling. DEFAULT_SSE_MAX_LIFETIME_SECONDS = 600 + + # Default per-collection SSE publish cooldown (seconds). A burst of writes + # to one collection within this settle window coalesces into a single + # client event so N ingestors relaying one packet do not stampede + # subscribers (SPEC LV6). + DEFAULT_SSE_PUBLISH_COOLDOWN_SECONDS = 1.0 DEFAULT_TILE_FILTER_LIGHT = "grayscale(1) saturate(0) brightness(0.92) contrast(1.05)" DEFAULT_TILE_FILTER_DARK = "grayscale(1) invert(1) brightness(0.9) contrast(1.08)" DEFAULT_MAP_CENTER_LAT = 38.761944 @@ -342,6 +348,16 @@ module PotatoMesh fetch_positive_integer("SSE_MAX_LIFETIME_SECONDS", DEFAULT_SSE_MAX_LIFETIME_SECONDS) end + # Per-collection SSE publish cooldown (seconds). Within this settle window + # a burst of writes to one collection coalesces into a single emitted + # event (SPEC LV6). Zero disables the cooldown (emit as soon as a change + # lands). + # + # @return [Float] non-negative cooldown, overridable via +SSE_PUBLISH_COOLDOWN+. + def sse_publish_cooldown_seconds + fetch_nonnegative_float("SSE_PUBLISH_COOLDOWN", DEFAULT_SSE_PUBLISH_COOLDOWN_SECONDS) + end + # Retrieve the CSS filter used for light themed maps. # # @return [String] CSS filter string. @@ -865,6 +881,25 @@ module PotatoMesh parsed.positive? ? parsed : default end + # Fetch a non-negative float from the environment, falling back to + # +default+ when unset, blank, unparseable, or negative. + # + # @param key [String] environment variable name. + # @param default [Float] fallback value. + # @return [Float] the parsed non-negative float, or +default+. + def fetch_nonnegative_float(key, default) + value = ENV[key] + return default if value.nil? + + trimmed = value.strip + return default if trimmed.empty? + + parsed = Float(trimmed, exception: false) + return default if parsed.nil? || parsed.negative? + + parsed + end + # Resolve the effective XDG directory honoring environment overrides. # # @param env_key [String] name of the environment variable to inspect. diff --git a/web/public/assets/js/app/__tests__/chat-log-tabs.test.js b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js index 206a37f..9e6e8ad 100644 --- a/web/public/assets/js/app/__tests__/chat-log-tabs.test.js +++ b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js @@ -117,16 +117,30 @@ function assertChannelMessages(model, { label, id, index, messageIds }) { test('buildChatTabModel returns sorted nodes and channel buckets', () => { const model = buildModel(); - assert.equal(model.logEntries.length, 3); - assert.deepEqual(model.logEntries.map(entry => entry.type), [ - CHAT_LOG_ENTRY_TYPES.NODE_NEW, - CHAT_LOG_ENTRY_TYPES.NODE_NEW, - CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED - ]); + // The Log feed now mirrors plaintext messages too (LV7), alongside node + // events and the encrypted message; assert by type counts so the test is + // robust to chronological interleaving. + const typeCounts = model.logEntries.reduce((acc, entry) => { + acc[entry.type] = (acc[entry.type] || 0) + 1; + return acc; + }, {}); + assert.equal(typeCounts[CHAT_LOG_ENTRY_TYPES.NODE_NEW], 2); + assert.equal(typeCounts[CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED], 1); + assert.equal(typeCounts[CHAT_LOG_ENTRY_TYPES.MESSAGE], 6); + assert.equal(model.logEntries.length, 9); + // Every plaintext channel message is also represented in the Log (LV7). + const loggedMessageIds = model.logEntries + .filter(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE) + .map(entry => entry.message.id) + .sort(); assert.deepEqual( - model.logEntries.map(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED ? entry.message.id : entry.node.id), - ['recent-node', 'iso-node', 'encrypted'] + loggedMessageIds, + ['env-default', 'iso-ts', 'no-index', 'primary-preset', 'recent-alt', 'recent-default'] ); + // Log entries are sorted chronologically. + for (let i = 1; i < model.logEntries.length; i += 1) { + assert.ok(model.logEntries[i].ts >= model.logEntries[i - 1].ts); + } assert.equal(model.channels.length, 6); // Default/primary channels (index 0) lead, then custom channels (index > 0); @@ -176,6 +190,20 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => { assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), ['recent-alt']); }); +test('buildChatTabModel mirrors plaintext messages into the Log feed (LV7)', () => { + const model = buildChatTabModel({ + nodes: [], + messages: [{ id: 'm1', channel: 0, from_id: '!a', text: 'hi', rx_time: NOW }], + nowSeconds: NOW, + windowSeconds: WINDOW + }); + const msgEntries = model.logEntries.filter(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE); + assert.equal(msgEntries.length, 1); + assert.equal(msgEntries[0].message.id, 'm1'); + // The same message still appears in its channel tab. + assert.equal(model.channels[0].entries[0].message.id, 'm1'); +}); + test('buildChatTabModel skips channel buckets when there are no messages', () => { const model = buildChatTabModel({ nodes: [], messages: [], nowSeconds: NOW, windowSeconds: WINDOW }); assert.equal(model.channels.length, 0); diff --git a/web/public/assets/js/app/__tests__/chat-tabs.test.js b/web/public/assets/js/app/__tests__/chat-tabs.test.js index 43210e9..642e54f 100644 --- a/web/public/assets/js/app/__tests__/chat-tabs.test.js +++ b/web/public/assets/js/app/__tests__/chat-tabs.test.js @@ -180,8 +180,8 @@ test('renderChatTabs creates tab markup and selects default active tab', () => { assert.equal(container.children.length, 2); const [tabListWrapper, panelWrapper] = container.children; - // tabListWrapper holds [prevBtn, tabList, nextBtn] - assert.equal(tabListWrapper.children.length, 3); + // tabListWrapper holds [prevBtn, tabList, nextBtn, tabSelect] (LV8 dropdown is 4th) + assert.equal(tabListWrapper.children.length, 4); const [, tabList] = tabListWrapper.children; assert.equal(tabList.children.length, 3); assert.equal(panelWrapper.children.length, 3); @@ -352,3 +352,63 @@ test('renderChatTabs arrow buttons reflect scroll position via scroll event', () assert.equal(prevBtn.hidden, true); assert.equal(nextBtn.hidden, false); }); + + +test('renderChatTabs preserves the tablist horizontal scroll across a re-render', () => { + const document = createMockDocument(); + const container = new MockElement('div'); + const tabs = [ + { id: 'log', label: 'Log', content: new MockElement('div') }, + { id: 'c0', label: 'Default', content: new MockElement('div') }, + { id: 'c1', label: 'Alpha', content: new MockElement('div') }, + { id: 'c2', label: 'Bravo', content: new MockElement('div') } + ]; + renderChatTabs({ document, container, tabs, defaultActiveTabId: 'log' }); + const tabList1 = container.children[0].children[1]; + tabList1.scrollLeft = 120; + + renderChatTabs({ document, container, tabs, defaultActiveTabId: 'log' }); + const tabList2 = container.children[0].children[1]; + + assert.notEqual(tabList2, tabList1); + assert.equal(tabList2.scrollLeft, 120); +}); + +test('renderChatTabs does not scroll the active tab into view on a passive re-render', () => { + const document = createMockDocument(); + const container = new MockElement('div'); + const tabs = [ + { id: 'log', label: 'Log', content: new MockElement('div') }, + { id: 'c0', label: 'Default', content: new MockElement('div') } + ]; + renderChatTabs({ document, container, tabs, defaultActiveTabId: 'c0' }); + const tabList = container.children[0].children[1]; + const totalScrollIntoView = tabList.children.reduce( + (n, button) => n + (button.scrollIntoViewCalls ? button.scrollIntoViewCalls.length : 0), + 0 + ); + assert.equal(totalScrollIntoView, 0); +}); + + +test('renderChatTabs renders a channel dropdown selector that jumps to a tab (LV8)', () => { + const document = createMockDocument(); + const container = new MockElement('div'); + const tabs = [ + { id: 'log', label: 'Log', content: new MockElement('div') }, + { id: 'c0', label: 'Default', content: new MockElement('div') }, + { id: 'c1', label: 'Alpha', content: new MockElement('div') } + ]; + renderChatTabs({ document, container, tabs, defaultActiveTabId: 'log' }); + const tabListWrapper = container.children[0]; + const tabSelect = tabListWrapper.children[3]; + assert.equal(tabSelect.tagName, 'SELECT'); + // One option per tab, in order. + assert.deepEqual(tabSelect.children.map(option => option.value), ['log', 'c0', 'c1']); + // The dropdown reflects the active tab ... + assert.equal(tabSelect.value, 'log'); + // ... and choosing a channel from it activates that tab. + tabSelect.value = 'c1'; + tabSelect.dispatch('change'); + assert.equal(container.dataset.activeTab, 'c1'); +}); diff --git a/web/public/assets/js/app/__tests__/dom-environment.js b/web/public/assets/js/app/__tests__/dom-environment.js index 993f7a8..b18a611 100644 --- a/web/public/assets/js/app/__tests__/dom-environment.js +++ b/web/public/assets/js/app/__tests__/dom-environment.js @@ -101,7 +101,19 @@ class MockElement { this._registry = registry; this.attributes = new Map(); this.dataset = {}; - this.style = {}; + this.style = { + setProperty(name, value) { + this[name] = String(value); + }, + getPropertyValue(name) { + return this[name] != null ? this[name] : ''; + }, + removeProperty(name) { + const previous = this[name]; + delete this[name]; + return previous != null ? previous : ''; + }, + }; this.textContent = ''; this.classList = new MockClassList(); this.childNodes = []; diff --git a/web/public/assets/js/app/__tests__/main-app-leaflet-stub.js b/web/public/assets/js/app/__tests__/main-app-leaflet-stub.js index 3db80c3..fe45cb6 100644 --- a/web/public/assets/js/app/__tests__/main-app-leaflet-stub.js +++ b/web/public/assets/js/app/__tests__/main-app-leaflet-stub.js @@ -62,6 +62,15 @@ export function makeLeafletStub() { clearLayers() { group._layers.length = 0; return group; + }, + addLayer(layer) { + group._layers.push(layer); + return group; + }, + removeLayer(layer) { + const index = group._layers.indexOf(layer); + if (index >= 0) group._layers.splice(index, 1); + return group; } }; recorded.layerGroups.push(group); @@ -94,6 +103,9 @@ export function makeLeafletStub() { eventHandlers.get(event).push(handler); return marker; }, + getLatLng() { + return marker._latLng; + }, _eventHandlers: eventHandlers }; return marker; diff --git a/web/public/assets/js/app/__tests__/main-flash.test.js b/web/public/assets/js/app/__tests__/main-flash.test.js index f1ae392..97a8ff9 100644 --- a/web/public/assets/js/app/__tests__/main-flash.test.js +++ b/web/public/assets/js/app/__tests__/main-flash.test.js @@ -24,6 +24,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { runLiveApp, DEFAULT_RESPONSES } from './sse-app-harness.js'; +import { setupAppWithLeaflet } from './main-app-leaflet-stub.js'; const NOW = Math.floor(Date.now() / 1000); @@ -109,3 +110,22 @@ test('a neighbors ping flashes nothing (out of scope, VF3)', async () => { assert.equal(testUtils.getLiveFlashCount(), 0); }); }); + + +test('flashChangedNodes emits a map-marker wave for a changed node with a marker (LV5)', () => { + const { testUtils, leaflet, cleanup } = setupAppWithLeaflet(); + try { + const divIconsBefore = leaflet._recorded.divIcons.length; + const flashesBefore = testUtils.getLiveFlashCount(); + // Inject a marker (with getLatLng) for the changed node, then flash it. + const marker = leaflet.circleMarker([1, 2], { fillColor: '#abc' }); + testUtils._setMarkerForTests('!a', marker); + testUtils.flashChangedNodes(new Set(['!a'])); + assert.equal(testUtils.getLiveFlashCount(), flashesBefore + 1); + // A wave divIcon ring was created and added to the never-cleared wave layer. + assert.equal(leaflet._recorded.divIcons.length, divIconsBefore + 1); + assert.match(leaflet._recorded.divIcons[divIconsBefore].options.html, /live-flash-wave/); + } finally { + cleanup(); + } +}); diff --git a/web/public/assets/js/app/__tests__/role-helpers.test.js b/web/public/assets/js/app/__tests__/role-helpers.test.js index 373a3e0..5c9fd00 100644 --- a/web/public/assets/js/app/__tests__/role-helpers.test.js +++ b/web/public/assets/js/app/__tests__/role-helpers.test.js @@ -19,6 +19,8 @@ import assert from 'node:assert/strict'; import { getRoleColor, + getRoleFlashColor, + hexToRgba, getRoleKey, getRoleRenderPriority, getRoleColors, @@ -148,3 +150,26 @@ test('getRoleTextColor returns null for meshtastic roles', () => { assert.equal(getRoleTextColor('CLIENT', 'meshtastic'), null); assert.equal(getRoleTextColor('ROUTER', null), null); }); + + +test('hexToRgba converts 6- and 3-digit hex to rgba', () => { + assert.equal(hexToRgba('#f3ef74', 0.55), 'rgba(243, 239, 116, 0.55)'); + assert.equal(hexToRgba('#abc', 1), 'rgba(170, 187, 204, 1)'); + assert.equal(hexToRgba('ff0019', 0.5), 'rgba(255, 0, 25, 0.5)'); +}); + +test('hexToRgba clamps an out-of-range alpha to 1 and rejects non-hex', () => { + assert.equal(hexToRgba('#ffffff', 5), 'rgba(255, 255, 255, 1)'); + assert.equal(hexToRgba('#ffffff', -1), 'rgba(255, 255, 255, 1)'); + assert.equal(hexToRgba('#ffffff', 'x'), 'rgba(255, 255, 255, 1)'); // non-number alpha -> 1 + assert.equal(hexToRgba('not-a-color', 0.5), null); + assert.equal(hexToRgba('#12', 0.5), null); + assert.equal(hexToRgba(123, 0.5), null); +}); + +test('getRoleFlashColor returns the role colour at the given alpha (LV3)', () => { + assert.equal(getRoleFlashColor('CLIENT'), 'rgba(243, 239, 116, 0.55)'); + assert.equal(getRoleFlashColor('ROUTER', null, 0.8), 'rgba(255, 0, 25, 0.8)'); + // Unknown role falls back to the CLIENT colour via getRoleColor. + assert.equal(getRoleFlashColor('NOPE'), 'rgba(243, 239, 116, 0.55)'); +}); diff --git a/web/public/assets/js/app/__tests__/short-info-overlay-manager.test.js b/web/public/assets/js/app/__tests__/short-info-overlay-manager.test.js index a2e58bb..e2da04d 100644 --- a/web/public/assets/js/app/__tests__/short-info-overlay-manager.test.js +++ b/web/public/assets/js/app/__tests__/short-info-overlay-manager.test.js @@ -395,3 +395,43 @@ test('rendered overlays do not swallow click events by default', () => { assert.ok(entry); assert.equal(entry.element.eventHandlers.has('click'), false); }); + + +test('reanchor carries an open overlay to a replacement anchor and survives cleanup', () => { + const { document, window, factory, anchor, body } = createStubDom(); + const stack = createShortInfoOverlayStack({ document, window, factory }); + stack.render(anchor, 'Node details'); + assert.equal(stack.isOpen(anchor), true); + + // Simulate a map re-render: the marker's DOM element is replaced by a fresh + // one for the same node, and the old anchor is detached from the document. + const newAnchor = document.createElement('span'); + newAnchor.setBoundingRect({ left: 60, top: 70, width: 16, height: 16 }); + body.appendChild(newAnchor); + + assert.equal(stack.reanchor(anchor, newAnchor), true); + anchor.remove(); // the old marker element is gone + + // The overlay is now keyed by the new anchor ... + assert.equal(stack.isOpen(anchor), false); + assert.equal(stack.isOpen(newAnchor), true); + // ... and cleanupOrphans (which closed it in the orphan test above) now keeps + // it open because the new anchor is still in the document (item 7). + stack.cleanupOrphans(); + assert.equal(stack.isOpen(newAnchor), true); + assert.equal(stack.getOpenOverlays().length, 1); +}); + +test('reanchor is a no-op for an unknown or invalid anchor', () => { + const { document, window, factory, anchor } = createStubDom(); + const stack = createShortInfoOverlayStack({ document, window, factory }); + const other = document.createElement('span'); + // No overlay open for `anchor` yet. + assert.equal(stack.reanchor(anchor, other), false); + stack.render(anchor, 'x'); + // Invalid replacement (no getBoundingClientRect). + assert.equal(stack.reanchor(anchor, {}), false); + // Re-anchoring to the same element is a trivial success without re-keying. + assert.equal(stack.reanchor(anchor, anchor), true); + assert.equal(stack.isOpen(anchor), true); +}); diff --git a/web/public/assets/js/app/chat-log-tabs.js b/web/public/assets/js/app/chat-log-tabs.js index 6dbbfed..096b809 100644 --- a/web/public/assets/js/app/chat-log-tabs.js +++ b/web/public/assets/js/app/chat-log-tabs.js @@ -75,7 +75,9 @@ function channelPriorityTier(channel) { * TELEMETRY: 'telemetry', * POSITION: 'position', * NEIGHBOR: 'neighbor', - * TRACE: 'trace' + * TRACE: 'trace', + * MESSAGE: 'message', + * MESSAGE_ENCRYPTED: 'message-encrypted' * }} */ export const CHAT_LOG_ENTRY_TYPES = Object.freeze({ @@ -85,6 +87,7 @@ export const CHAT_LOG_ENTRY_TYPES = Object.freeze({ POSITION: 'position', NEIGHBOR: 'neighbor', TRACE: 'trace', + MESSAGE: 'message', MESSAGE_ENCRYPTED: 'message-encrypted' }); @@ -265,6 +268,9 @@ export function buildChatTabModel({ const encryptedLogEntries = []; const encryptedLogKeys = new Set(); + // Plaintext messages also feed the mixed Log tab (LV7), in addition to their + // own channel tab, so every live-event class is represented in the Log. + const plaintextLogEntries = []; for (const message of messages || []) { if (!message) continue; @@ -329,6 +335,8 @@ export function buildChatTabModel({ } bucket.entries.push({ ts, message }); + // Surface the plaintext message in the mixed Log feed too (LV7). + plaintextLogEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.MESSAGE, message }); } const extraLogMessages = Array.isArray(logOnlyMessages) ? logOnlyMessages : []; @@ -347,6 +355,9 @@ export function buildChatTabModel({ if (encryptedLogEntries.length > 0) { logEntries.push(...encryptedLogEntries); } + if (plaintextLogEntries.length > 0) { + logEntries.push(...plaintextLogEntries); + } logEntries.sort((a, b) => a.ts - b.ts); diff --git a/web/public/assets/js/app/chat-tabs.js b/web/public/assets/js/app/chat-tabs.js index d58a605..136805a 100644 --- a/web/public/assets/js/app/chat-tabs.js +++ b/web/public/assets/js/app/chat-tabs.js @@ -81,6 +81,13 @@ export function renderChatTabs({ nextBtn.textContent = '▶'; nextBtn.hidden = true; + // Channel dropdown selector (LV8): a native