mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-04 16:03:02 +02:00
web: fix live-update DOM handling (#826)
* web: fix live-update DOM handling * web: live-update feedback upgrades (v2) * web: more live-update feedback * web: address review comments
This commit is contained in:
+200
-5
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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<Hash{Symbol => 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -81,6 +81,13 @@ export function renderChatTabs({
|
||||
nextBtn.textContent = '▶';
|
||||
nextBtn.hidden = true;
|
||||
|
||||
// Channel dropdown selector (LV8): a native <select> listing every tab so
|
||||
// the user can jump to a channel regardless of the horizontal scroll
|
||||
// position (the native control supplies the downward-triangle affordance).
|
||||
const tabSelect = document.createElement('select');
|
||||
tabSelect.className = 'chat-tab-select';
|
||||
tabSelect.setAttribute('aria-label', 'Jump to channel');
|
||||
|
||||
const tabList = document.createElement('div');
|
||||
tabList.className = 'chat-tablist';
|
||||
tabList.setAttribute('role', 'tablist');
|
||||
@@ -88,6 +95,7 @@ export function renderChatTabs({
|
||||
tabListWrapper.appendChild(prevBtn);
|
||||
tabListWrapper.appendChild(tabList);
|
||||
tabListWrapper.appendChild(nextBtn);
|
||||
tabListWrapper.appendChild(tabSelect);
|
||||
|
||||
const panelWrapper = document.createElement('div');
|
||||
panelWrapper.className = 'chat-tabpanels';
|
||||
@@ -97,6 +105,22 @@ export function renderChatTabs({
|
||||
|
||||
const tabElements = [];
|
||||
const existingActive = container.dataset?.activeTab || null;
|
||||
// Preserve the channel-tab list's horizontal scroll across the full-subtree
|
||||
// rebuild below (item 5): without this, every live refresh resets scrollLeft
|
||||
// to 0 and yanks the user back to the first tab. The previous render's tab
|
||||
// list is the second child of the first wrapper (see the structure built
|
||||
// below); guard defensively in case the container held no prior tab list.
|
||||
const previousTabListWrapper = container.children && container.children[0];
|
||||
const previousTabList =
|
||||
previousTabListWrapper && previousTabListWrapper.children
|
||||
? previousTabListWrapper.children[1]
|
||||
: null;
|
||||
const previousScrollLeft =
|
||||
previousTabList &&
|
||||
previousTabList.className === 'chat-tablist' &&
|
||||
typeof previousTabList.scrollLeft === 'number'
|
||||
? previousTabList.scrollLeft
|
||||
: 0;
|
||||
const activeCandidateOrder = [existingActive, previousActiveTabId, defaultActiveTabId];
|
||||
let activeTabId = null;
|
||||
|
||||
@@ -149,6 +173,10 @@ export function renderChatTabs({
|
||||
|
||||
tabList.appendChild(button);
|
||||
panelWrapper.appendChild(panel);
|
||||
const option = document.createElement('option');
|
||||
option.value = uniqueId;
|
||||
option.textContent = tab.label || uniqueId;
|
||||
tabSelect.appendChild(option);
|
||||
tabElements.push({ id: uniqueId, button, panel });
|
||||
}
|
||||
|
||||
@@ -218,7 +246,7 @@ export function renderChatTabs({
|
||||
// Initial arrow state after the DOM is in place.
|
||||
updateArrows();
|
||||
|
||||
const setActiveTab = newId => {
|
||||
const setActiveTab = (newId, { scrollActiveIntoView = false } = {}) => {
|
||||
if (!newId) return;
|
||||
let matched = false;
|
||||
for (const entry of tabElements) {
|
||||
@@ -230,11 +258,14 @@ export function renderChatTabs({
|
||||
entry.panel.hidden = false;
|
||||
matched = true;
|
||||
container.dataset.activeTab = newId;
|
||||
tabSelect.value = newId;
|
||||
if (typeof entry.panel.scrollHeight === 'number' && typeof entry.panel.scrollTop === 'number') {
|
||||
entry.panel.scrollTop = entry.panel.scrollHeight;
|
||||
}
|
||||
// Scroll the active tab button into view within the overflow tab list.
|
||||
if (typeof entry.button.scrollIntoView === 'function') {
|
||||
// Scroll the active tab button into view within the overflow tab list,
|
||||
// but only on an explicit user tab switch (item 5): a passive re-render
|
||||
// keeps the user's current horizontal scroll instead of yanking it.
|
||||
if (scrollActiveIntoView && typeof entry.button.scrollIntoView === 'function') {
|
||||
entry.button.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
}
|
||||
} else {
|
||||
@@ -249,12 +280,26 @@ export function renderChatTabs({
|
||||
|
||||
setActiveTab(activeTabId);
|
||||
|
||||
// Restore the horizontal scroll captured before the rebuild so a live
|
||||
// refresh does not reset the channel-tab list to the first tab (item 5).
|
||||
// Applied after setActiveTab, which no longer force-scrolls on a passive
|
||||
// render, so the restored position is authoritative.
|
||||
if (previousScrollLeft > 0 && typeof tabList.scrollLeft === 'number') {
|
||||
tabList.scrollLeft = previousScrollLeft;
|
||||
updateArrows();
|
||||
}
|
||||
|
||||
for (const entry of tabElements) {
|
||||
entry.button.addEventListener('click', () => {
|
||||
setActiveTab(entry.id);
|
||||
setActiveTab(entry.id, { scrollActiveIntoView: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Jump to the chosen channel when the dropdown selection changes (LV8).
|
||||
tabSelect.addEventListener('change', () => {
|
||||
setActiveTab(tabSelect.value, { scrollActiveIntoView: true });
|
||||
});
|
||||
|
||||
return container.dataset.activeTab || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ import { maxRecordTimestamp, minRecordTimestamp, mergeById, mergeByCompositeKey,
|
||||
import { buildTraceSegments } from './trace-paths.js';
|
||||
import {
|
||||
getRoleColor,
|
||||
getRoleFlashColor,
|
||||
getRoleKey,
|
||||
getRoleRenderPriority,
|
||||
getRoleTextColor,
|
||||
@@ -193,7 +194,8 @@ import { buildNeighborTooltipHtml, buildTraceTooltipHtml } from './main/tooltip-
|
||||
import { createOfflineTileLayer as createOfflineTileLayerImpl } from './main/offline-tile-layer.js';
|
||||
import { getActiveFullscreenElement, legendClickHandler } from './main/fullscreen-helpers.js';
|
||||
import { createEventStream } from './main/event-stream.js';
|
||||
import { flashNodeTargets, flashMessageTargets } from './main/flash.js';
|
||||
import { flashNodeTargets, flashMessageTargets, emitNodeWaves } from './main/flash.js';
|
||||
import { captureOpenMarkerOverlays, restoreMarkerOverlays } from './main/marker-overlay-preservation.js';
|
||||
import { collectNodeIds, collectMessageIds, entryMessageId } from './main/flash-targets.js';
|
||||
|
||||
/**
|
||||
@@ -737,6 +739,20 @@ export function initializeApp(config) {
|
||||
liveFlashCount += 1;
|
||||
lastFlashedNodeIds = [...nodeIds];
|
||||
flashNodeTargets(nodeIds, { documentRef: document, markerByNodeId });
|
||||
// Emit an expanding wave from each changed node's marker (SPEC LV5). Guarded
|
||||
// on Leaflet so the poll / no-map paths stay no-ops; the wave colour resolves
|
||||
// to the node's role colour.
|
||||
if (hasLeaflet && flashWavesLayer) {
|
||||
emitNodeWaves(nodeIds, {
|
||||
markerByNodeId,
|
||||
leaflet: L,
|
||||
layer: flashWavesLayer,
|
||||
colorForNodeId: (id) => {
|
||||
const node = nodesById.get(id);
|
||||
return getRoleFlashColor(node && node.role, node && node.protocol, 0.85);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -895,6 +911,9 @@ export function initializeApp(config) {
|
||||
let traceLinesToggleButton = null;
|
||||
let markersLayer = null;
|
||||
let spiderLinesLayer = null;
|
||||
// Dedicated, never-cleared layer hosting transient LV5 wave rings; each wave
|
||||
// self-removes after its animation, so a map re-render never clears it.
|
||||
let flashWavesLayer = null;
|
||||
// Per-render map of canonical node id → its Leaflet marker, so a live update
|
||||
// can flash the marker for a changed node (SPEC VF3). Rebuilt every renderMap.
|
||||
let markerByNodeId = new Map();
|
||||
@@ -1573,6 +1592,7 @@ export function initializeApp(config) {
|
||||
});
|
||||
|
||||
neighborLinesLayer = L.layerGroup().addTo(map);
|
||||
flashWavesLayer = L.layerGroup().addTo(map);
|
||||
traceLinesLayer = L.layerGroup().addTo(map);
|
||||
// Spider lines render between the connection lines and the markers so the
|
||||
// dashed white "leader" lines are visible against neighbour/trace overlays
|
||||
@@ -2792,6 +2812,7 @@ export function initializeApp(config) {
|
||||
return buildNeighborChatEntryParts(entry, context);
|
||||
case CHAT_LOG_ENTRY_TYPES.TRACE:
|
||||
return buildTraceChatEntryParts(entry, context);
|
||||
case CHAT_LOG_ENTRY_TYPES.MESSAGE:
|
||||
case CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED:
|
||||
return entry?.message ? buildMessageChatEntryParts(entry.message) : null;
|
||||
default:
|
||||
@@ -3469,6 +3490,14 @@ export function initializeApp(config) {
|
||||
if (messageId) {
|
||||
node.dataset.messageId = messageId;
|
||||
if (namespace !== 'log') messageTabId.set(messageId, namespace);
|
||||
// Stamp the sender's role colour so the live-update fade lands on it
|
||||
// (LV3). Falls back to the CSS default when the sender node is unknown.
|
||||
const flashMessage = entry.item || entry.message;
|
||||
const senderId = flashMessage && (flashMessage.from_id || flashMessage.fromId);
|
||||
const senderNode = senderId ? nodesById.get(senderId) : null;
|
||||
if (senderNode && node.style && typeof node.style.setProperty === 'function') {
|
||||
node.style.setProperty('--flash-role-color', getRoleFlashColor(senderNode.role, senderNode.protocol));
|
||||
}
|
||||
}
|
||||
const divider = getDivider(entry.ts);
|
||||
if (divider) fragment.appendChild(divider);
|
||||
@@ -3621,6 +3650,11 @@ export function initializeApp(config) {
|
||||
if (typeof n.node_id === 'string' && n.node_id) {
|
||||
tr.dataset.nodeRow = n.node_id;
|
||||
}
|
||||
// Stamp the role colour so the live-update fade lands on it (LV3); the CSS
|
||||
// keyframe reads --flash-role-color, so the flash helper needs no colour.
|
||||
if (tr.style && typeof tr.style.setProperty === 'function') {
|
||||
tr.style.setProperty('--flash-role-color', getRoleFlashColor(n.role, n.protocol));
|
||||
}
|
||||
const lastPositionTime = toFiniteNumber(n.position_time ?? n.positionTime);
|
||||
const lastPositionCell = lastPositionTime != null ? timeAgo(lastPositionTime, nowSec) : '';
|
||||
const latitudeDisplay = fmtCoords(n.latitude);
|
||||
@@ -3897,6 +3931,11 @@ export function initializeApp(config) {
|
||||
// Capture the zoom bucket the upcoming render targets so the zoomend
|
||||
// handler can detect threshold crossings on the next zoom event.
|
||||
lastRenderedZoomBucket = currentZoomBucket();
|
||||
// Snapshot any open marker overlay before clearing the layer (item 7):
|
||||
// clearLayers() destroys each marker's DOM element, which would orphan an
|
||||
// open overlay and let cleanupOrphans() close it. We re-anchor to the
|
||||
// rebuilt markers after the render below so the overlay stays open.
|
||||
const preservedMarkerOverlays = captureOpenMarkerOverlays(overlayStack, markerByNodeId);
|
||||
markersLayer.clearLayers();
|
||||
// Reset the node→marker map for this render so live-update flashes target
|
||||
// the current markers (SPEC VF3).
|
||||
@@ -4273,6 +4312,10 @@ export function initializeApp(config) {
|
||||
},
|
||||
});
|
||||
}
|
||||
// Re-anchor any overlay preserved above onto its rebuilt marker so it
|
||||
// stays open across the re-render instead of being closed by
|
||||
// cleanupOrphans (item 7).
|
||||
restoreMarkerOverlays(overlayStack, preservedMarkerOverlays, markerByNodeId);
|
||||
overlayStack.cleanupOrphans();
|
||||
}
|
||||
|
||||
@@ -4838,6 +4881,10 @@ export function initializeApp(config) {
|
||||
getLiveFlashCount: () => liveFlashCount,
|
||||
/** Node ids flashed by the most recent SSE-ping refresh (test hook). */
|
||||
getLastFlashedNodeIds: () => lastFlashedNodeIds,
|
||||
/** Flash (and wave) the given changed node ids — test hook for the LV5 wiring. */
|
||||
flashChangedNodes,
|
||||
/** Inject a marker into the node->marker map — test hook for the LV5 wiring. */
|
||||
_setMarkerForTests: (id, marker) => markerByNodeId.set(id, marker),
|
||||
/** Message ids flashed by the most recent SSE-ping refresh (test hook). */
|
||||
getLastFlashedMessageIds: () => lastFlashedMessageIds,
|
||||
/**
|
||||
|
||||
@@ -20,11 +20,14 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
FLASH_CLASS,
|
||||
FLASH_DURATION_MS,
|
||||
WAVE_DURATION_MS,
|
||||
flashElement,
|
||||
flashElements,
|
||||
flashMarker,
|
||||
flashNodeTargets,
|
||||
flashMessageTargets,
|
||||
emitMarkerWave,
|
||||
emitNodeWaves,
|
||||
} from '../flash.js';
|
||||
|
||||
/** A minimal element exposing a tracked classList. */
|
||||
@@ -40,8 +43,32 @@ function fakeElement() {
|
||||
};
|
||||
}
|
||||
|
||||
test('FLASH_DURATION_MS is below 100ms (VF5)', () => {
|
||||
assert.ok(FLASH_DURATION_MS < 100);
|
||||
test('FLASH_DURATION_MS is ~1.2s for the LV1 role-colour fade', () => {
|
||||
assert.equal(FLASH_DURATION_MS, 1200);
|
||||
});
|
||||
|
||||
test('flashElement cancels a prior removal timer when re-flashed (LV2 stacking)', () => {
|
||||
const el = fakeElement();
|
||||
const cancelled = [];
|
||||
let n = 0;
|
||||
const schedule = () => { n += 1; return `t${n}`; };
|
||||
const cancel = (h) => cancelled.push(h);
|
||||
flashElement(el, { schedule, cancel }); // arms t1
|
||||
flashElement(el, { schedule, cancel }); // cancels t1, arms t2
|
||||
assert.deepEqual(cancelled, ['t1']);
|
||||
assert.equal(el.classList.contains(FLASH_CLASS), true, 're-flash keeps the class on');
|
||||
});
|
||||
|
||||
test('flashElement clears its per-element handle once the removal timer fires', () => {
|
||||
const el = fakeElement();
|
||||
let removal = null;
|
||||
flashElement(el, { schedule: (cb) => { removal = cb; return 'h'; } });
|
||||
removal(); // fire the scheduled removal
|
||||
assert.equal(el.classList.contains(FLASH_CLASS), false);
|
||||
// A later flash must not try to cancel the now-stale handle.
|
||||
const cancelled = [];
|
||||
flashElement(el, { schedule: () => 'h2', cancel: (h) => cancelled.push(h) });
|
||||
assert.deepEqual(cancelled, []);
|
||||
});
|
||||
|
||||
test('flashElement adds the class immediately, then removes it when the timer fires', () => {
|
||||
@@ -219,3 +246,116 @@ test('flashMessageTargets returns 0 for nullish or non-iterable ids', () => {
|
||||
assert.equal(flashMessageTargets(undefined), 0);
|
||||
assert.equal(flashMessageTargets(123), 0);
|
||||
});
|
||||
|
||||
|
||||
test('flashMessageTargets flashes only the message channel tab, never an unrelated/active tab (LV4)', () => {
|
||||
const msgRow = fakeElement();
|
||||
const ownTab = fakeElement();
|
||||
const activeTab = fakeElement();
|
||||
const documentRef = {
|
||||
querySelectorAll: (sel) => {
|
||||
if (sel.includes('data-message-id="9"')) return [msgRow];
|
||||
if (sel.includes('data-tab-id="c-test"')) return [ownTab];
|
||||
if (sel.includes('data-tab-id="c-primary"')) return [activeTab];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
// The message belongs to #test; #primary happens to be the active tab.
|
||||
const messageTabId = new Map([['9', 'c-test']]);
|
||||
flashMessageTargets(['9'], { documentRef, messageTabId, flashOptions: { schedule: () => {} } });
|
||||
assert.equal(msgRow.classList.contains(FLASH_CLASS), true, 'message row flashed');
|
||||
assert.equal(ownTab.classList.contains(FLASH_CLASS), true, "message's own channel tab flashed");
|
||||
assert.equal(activeTab.classList.contains(FLASH_CLASS), false, 'the unrelated/active tab is NOT flashed');
|
||||
});
|
||||
|
||||
|
||||
test('emitMarkerWave adds a wave divIcon marker and removes it after the duration', () => {
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const layer = { addLayer: (l) => added.push(l), removeLayer: (l) => removed.push(l) };
|
||||
let divIconOpts = null;
|
||||
let markerArgs = null;
|
||||
const leaflet = {
|
||||
divIcon: (opts) => { divIconOpts = opts; return { __icon: true }; },
|
||||
marker: (latlng, opts) => { markerArgs = { latlng, opts }; return { __wave: true }; },
|
||||
};
|
||||
let captured = null;
|
||||
const marker = { getLatLng: () => [1, 2] };
|
||||
assert.equal(emitMarkerWave(marker, {
|
||||
leaflet, layer, color: 'rgba(1, 2, 3, 0.85)', schedule: (cb) => { captured = cb; },
|
||||
}), true);
|
||||
assert.deepEqual(markerArgs.latlng, [1, 2]);
|
||||
assert.equal(markerArgs.opts.interactive, false);
|
||||
assert.match(divIconOpts.html, /live-flash-wave/);
|
||||
assert.match(divIconOpts.html, /--flash-role-color: rgba\(1, 2, 3, 0.85\)/);
|
||||
assert.deepEqual(added, [{ __wave: true }]);
|
||||
assert.deepEqual(removed, []);
|
||||
captured();
|
||||
assert.deepEqual(removed, [{ __wave: true }]);
|
||||
});
|
||||
|
||||
test('emitMarkerWave defaults the colour and duration', () => {
|
||||
let divIconOpts = null;
|
||||
let seenDelay = null;
|
||||
const leaflet = { divIcon: (o) => { divIconOpts = o; return {}; }, marker: () => ({}) };
|
||||
const layer = { addLayer: () => {}, removeLayer: () => {} };
|
||||
emitMarkerWave({ getLatLng: () => [0, 0] }, { leaflet, layer, schedule: (_cb, delay) => { seenDelay = delay; } });
|
||||
assert.match(divIconOpts.html, /--flash-role-color: rgba\(255, 255, 255, 0.85\)/);
|
||||
assert.equal(seenDelay, WAVE_DURATION_MS);
|
||||
});
|
||||
|
||||
test('emitMarkerWave is a safe no-op for a bad marker, leaflet, or layer', () => {
|
||||
const leaflet = { divIcon: () => ({}), marker: () => ({}) };
|
||||
const layer = { addLayer: () => {}, removeLayer: () => {} };
|
||||
assert.equal(emitMarkerWave(null, { leaflet, layer }), false);
|
||||
assert.equal(emitMarkerWave({}, { leaflet, layer }), false);
|
||||
assert.equal(emitMarkerWave({ getLatLng: () => [0, 0] }, { layer }), false);
|
||||
assert.equal(emitMarkerWave({ getLatLng: () => [0, 0] }, { leaflet }), false);
|
||||
assert.equal(emitMarkerWave({ getLatLng: () => [0, 0] }, { leaflet: {}, layer }), false);
|
||||
});
|
||||
|
||||
test('emitNodeWaves emits a wave per node that has a marker, skipping the rest', () => {
|
||||
const waved = [];
|
||||
const leaflet = { divIcon: () => ({}), marker: () => ({ __w: true }) };
|
||||
const layer = { addLayer: (l) => waved.push(l), removeLayer: () => {} };
|
||||
const markerByNodeId = new Map([
|
||||
['!a', { getLatLng: () => [1, 1] }],
|
||||
['!b', { getLatLng: () => [2, 2] }],
|
||||
]);
|
||||
const count = emitNodeWaves(['!a', '!b', '!missing'], {
|
||||
markerByNodeId, leaflet, layer,
|
||||
colorForNodeId: (id) => `c-${id}`,
|
||||
waveOptions: { schedule: () => {} },
|
||||
});
|
||||
assert.equal(count, 2);
|
||||
assert.equal(waved.length, 2);
|
||||
});
|
||||
|
||||
test('emitNodeWaves defaults the colour when no resolver is given', () => {
|
||||
let html = null;
|
||||
const leaflet = { divIcon: (o) => { html = o.html; return {}; }, marker: () => ({}) };
|
||||
const layer = { addLayer: () => {}, removeLayer: () => {} };
|
||||
const markerByNodeId = new Map([['!a', { getLatLng: () => [0, 0] }]]);
|
||||
assert.equal(emitNodeWaves(['!a'], { markerByNodeId, leaflet, layer, waveOptions: { schedule: () => {} } }), 1);
|
||||
assert.match(html, /rgba\(255, 255, 255, 0.85\)/);
|
||||
});
|
||||
|
||||
test('emitNodeWaves is a no-op for bad ids or a missing marker map', () => {
|
||||
assert.equal(emitNodeWaves(null, {}), 0);
|
||||
assert.equal(emitNodeWaves(123, {}), 0);
|
||||
assert.equal(emitNodeWaves(['!a'], {}), 0);
|
||||
assert.equal(emitNodeWaves(['!a'], { markerByNodeId: new Map() }), 0);
|
||||
});
|
||||
|
||||
|
||||
test('flashElement cancels the prior real timer on re-flash via the default canceller (LV2)', async () => {
|
||||
const el = fakeElement();
|
||||
// First flash arms a real 60ms removal timer; the re-flash (no injected cancel)
|
||||
// must clearTimeout it via the default canceller so the class is not removed at
|
||||
// the first timer's mark, only by the second (live) timer.
|
||||
flashElement(el, { duration: 60 });
|
||||
flashElement(el, { duration: 60 });
|
||||
assert.equal(el.classList.contains(FLASH_CLASS), true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 90));
|
||||
assert.equal(el.classList.contains(FLASH_CLASS), false, 'removed by the second (live) timer');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
captureOpenMarkerOverlays,
|
||||
restoreMarkerOverlays,
|
||||
} from '../marker-overlay-preservation.js';
|
||||
|
||||
/** Minimal overlay-stack double tracking open anchors and reanchor calls. */
|
||||
function fakeStack() {
|
||||
const open = new Set();
|
||||
const reanchored = [];
|
||||
return {
|
||||
open,
|
||||
reanchored,
|
||||
isOpen: (anchor) => open.has(anchor),
|
||||
reanchor: (oldAnchor, newAnchor) => {
|
||||
if (!open.has(oldAnchor)) return false;
|
||||
open.delete(oldAnchor);
|
||||
open.add(newAnchor);
|
||||
reanchored.push([oldAnchor, newAnchor]);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a marker double whose getElement returns `el`. */
|
||||
const marker = (el) => ({ getElement: () => el });
|
||||
|
||||
test('captureOpenMarkerOverlays snapshots only nodes whose marker overlay is open', () => {
|
||||
const stack = fakeStack();
|
||||
const elA = { id: 'A' };
|
||||
const elB = { id: 'B' };
|
||||
stack.open.add(elA);
|
||||
const markerByNodeId = new Map([
|
||||
['!a', marker(elA)],
|
||||
['!b', marker(elB)],
|
||||
]);
|
||||
assert.deepEqual(captureOpenMarkerOverlays(stack, markerByNodeId), [
|
||||
{ nodeId: '!a', anchor: elA },
|
||||
]);
|
||||
});
|
||||
|
||||
test('captureOpenMarkerOverlays ignores markers without getElement and bad args', () => {
|
||||
const stack = fakeStack();
|
||||
assert.deepEqual(captureOpenMarkerOverlays(null, new Map()), []);
|
||||
assert.deepEqual(captureOpenMarkerOverlays({}, new Map()), []);
|
||||
assert.deepEqual(captureOpenMarkerOverlays(stack, null), []);
|
||||
// Marker without a getElement() is skipped (no anchor to key on).
|
||||
assert.deepEqual(captureOpenMarkerOverlays(stack, new Map([['!x', {}]])), []);
|
||||
});
|
||||
|
||||
test('restoreMarkerOverlays re-anchors captured overlays onto rebuilt markers', () => {
|
||||
const stack = fakeStack();
|
||||
const oldEl = { id: 'old' };
|
||||
const newEl = { id: 'new' };
|
||||
stack.open.add(oldEl);
|
||||
const captured = [{ nodeId: '!a', anchor: oldEl }];
|
||||
const rebuilt = new Map([['!a', marker(newEl)]]);
|
||||
assert.equal(restoreMarkerOverlays(stack, captured, rebuilt), 1);
|
||||
assert.deepEqual(stack.reanchored, [[oldEl, newEl]]);
|
||||
assert.equal(stack.isOpen(newEl), true);
|
||||
assert.equal(stack.isOpen(oldEl), false);
|
||||
});
|
||||
|
||||
test('restoreMarkerOverlays leaves a vanished node for cleanup (no rebuilt marker)', () => {
|
||||
const stack = fakeStack();
|
||||
const oldEl = { id: 'old' };
|
||||
stack.open.add(oldEl);
|
||||
const captured = [{ nodeId: '!gone', anchor: oldEl }, null];
|
||||
assert.equal(restoreMarkerOverlays(stack, captured, new Map()), 0);
|
||||
assert.deepEqual(stack.reanchored, []);
|
||||
});
|
||||
|
||||
test('restoreMarkerOverlays tolerates bad args', () => {
|
||||
assert.equal(restoreMarkerOverlays(null, [], new Map()), 0);
|
||||
assert.equal(restoreMarkerOverlays({}, [], new Map()), 0);
|
||||
const stack = fakeStack();
|
||||
assert.equal(restoreMarkerOverlays(stack, null, new Map()), 0);
|
||||
assert.equal(restoreMarkerOverlays(stack, [], null), 0);
|
||||
});
|
||||
|
||||
test('a full capture->rebuild->restore cycle keeps the overlay on the new marker', () => {
|
||||
const stack = fakeStack();
|
||||
const elA = { id: 'A' };
|
||||
stack.open.add(elA);
|
||||
const before = new Map([['!a', marker(elA)]]);
|
||||
const captured = captureOpenMarkerOverlays(stack, before);
|
||||
// Map re-render: a fresh marker element for the same node id.
|
||||
const elA2 = { id: 'A2' };
|
||||
const after = new Map([['!a', marker(elA2)]]);
|
||||
restoreMarkerOverlays(stack, captured, after);
|
||||
assert.equal(stack.isOpen(elA2), true);
|
||||
assert.equal(stack.isOpen(elA), false);
|
||||
});
|
||||
@@ -17,8 +17,9 @@
|
||||
/**
|
||||
* Live-update flash helper.
|
||||
*
|
||||
* Applies a brief (<100 ms) white highlight to a DOM element when a live SSE
|
||||
* update lands on it (SPEC VF5). The visual itself — and its suppression under
|
||||
* Applies a ~1.2 s white->role-colour fade to a DOM element when a live SSE
|
||||
* update lands on it (SPEC LV1/LV2/LV3, amends VF5). The visual itself — and its
|
||||
* suppression under
|
||||
* `prefers-reduced-motion` — lives entirely in CSS (`.live-flash` in
|
||||
* `base.css`); this module only toggles the class, so it has no dependency on a
|
||||
* real layout engine and is fully unit-testable. The class is removed after the
|
||||
@@ -30,8 +31,17 @@
|
||||
/** CSS class that drives the one-shot highlight animation. */
|
||||
export const FLASH_CLASS = 'live-flash';
|
||||
|
||||
/** Highlight lifetime in ms; kept below 100 ms per VF5. Matches the keyframe. */
|
||||
export const FLASH_DURATION_MS = 90;
|
||||
/** Element highlight lifetime in ms; the role-colour fade keyframe runs ~1.2 s (LV1). */
|
||||
export const FLASH_DURATION_MS = 1200;
|
||||
|
||||
/** Marker white-pulse lifetime in ms (interim; the LV5 wave layers on top). */
|
||||
export const MARKER_FLASH_DURATION_MS = 90;
|
||||
|
||||
/** Map-marker wave ring lifetime in ms; matches the LV5 keyframe (~1.2s). */
|
||||
export const WAVE_DURATION_MS = 1200;
|
||||
|
||||
/** Property key holding an element's pending flash-removal timer (LV2). */
|
||||
const FLASH_TIMER_KEY = '__liveFlashTimer';
|
||||
|
||||
/**
|
||||
* Default removal scheduler (real timer). Injectable so tests stay deterministic.
|
||||
@@ -41,7 +51,21 @@ export const FLASH_DURATION_MS = 90;
|
||||
* @returns {*} The timer handle.
|
||||
*/
|
||||
function defaultSchedule(callback, delay) {
|
||||
return setTimeout(callback, delay);
|
||||
const handle = setTimeout(callback, delay);
|
||||
// Don't let a pending fade-removal timer keep a Node process alive (tests).
|
||||
if (handle && typeof handle.unref === 'function') handle.unref();
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default timer canceller (pairs with {@link defaultSchedule}). Injectable so
|
||||
* tests can assert that a re-flash cancels the prior removal timer.
|
||||
*
|
||||
* @param {*} handle Timer handle returned by the scheduler.
|
||||
* @returns {void}
|
||||
*/
|
||||
function defaultCancel(handle) {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,10 +87,24 @@ export function flashElement(element, options = {}) {
|
||||
}
|
||||
const duration = typeof options.duration === 'number' ? options.duration : FLASH_DURATION_MS;
|
||||
const schedule = typeof options.schedule === 'function' ? options.schedule : defaultSchedule;
|
||||
// Restart the animation by clearing any in-flight highlight before re-adding.
|
||||
const cancel = typeof options.cancel === 'function' ? options.cancel : defaultCancel;
|
||||
// Cancel any in-flight removal timer so a re-flash mid-fade restarts cleanly
|
||||
// and is never cut short by the previous timer (LV2 stacked, per-element timers).
|
||||
if (element[FLASH_TIMER_KEY] != null) {
|
||||
cancel(element[FLASH_TIMER_KEY]);
|
||||
element[FLASH_TIMER_KEY] = null;
|
||||
}
|
||||
// Restart the animation: clearing the class and forcing a style read between
|
||||
// remove and re-add restarts the CSS animation even while one is mid-flight.
|
||||
element.classList.remove(FLASH_CLASS);
|
||||
// Reading offsetWidth forces the reflow that restarts the animation; harmless
|
||||
// (undefined) when no layout engine is present (tests).
|
||||
void element.offsetWidth;
|
||||
element.classList.add(FLASH_CLASS);
|
||||
schedule(() => element.classList.remove(FLASH_CLASS), duration);
|
||||
element[FLASH_TIMER_KEY] = schedule(() => {
|
||||
element.classList.remove(FLASH_CLASS);
|
||||
element[FLASH_TIMER_KEY] = null;
|
||||
}, duration);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -84,7 +122,7 @@ export function flashElement(element, options = {}) {
|
||||
*/
|
||||
export function flashMarker(marker, options = {}) {
|
||||
if (!marker || typeof marker.setStyle !== 'function') return false;
|
||||
const duration = typeof options.duration === 'number' ? options.duration : FLASH_DURATION_MS;
|
||||
const duration = typeof options.duration === 'number' ? options.duration : MARKER_FLASH_DURATION_MS;
|
||||
const schedule = typeof options.schedule === 'function' ? options.schedule : defaultSchedule;
|
||||
const current = marker.options || {};
|
||||
const original = { fillColor: current.fillColor, fillOpacity: current.fillOpacity };
|
||||
@@ -93,6 +131,78 @@ export function flashMarker(marker, options = {}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an expanding "wave" ring from a Leaflet marker (SPEC LV5).
|
||||
*
|
||||
* Creates a transient, non-interactive divIcon marker at the marker's location
|
||||
* whose `.live-flash-wave` ring grows and fades toward the role colour over
|
||||
* ~1.2s (the animation lives in `base.css`), then removes it from the host
|
||||
* layer. Leaflet and the layer are injected so this unit-tests without a real
|
||||
* map. A marker without `getLatLng`, or a missing Leaflet/layer, is a safe no-op.
|
||||
*
|
||||
* @param {?{getLatLng: Function}} marker Source Leaflet marker.
|
||||
* @param {Object} [options] Wave configuration.
|
||||
* @param {?Object} [options.leaflet] Leaflet namespace (`L`), injected by the caller.
|
||||
* @param {?{addLayer: Function, removeLayer: Function}} [options.layer] Layer to host the wave.
|
||||
* @param {?string} [options.color] Role colour for the ring (`--flash-role-color`).
|
||||
* @param {number} [options.duration] Lifetime before removal (ms).
|
||||
* @param {Function} [options.schedule] Removal scheduler (tests inject this).
|
||||
* @returns {boolean} true when a wave was emitted; false when skipped.
|
||||
*/
|
||||
export function emitMarkerWave(marker, options = {}) {
|
||||
if (!marker || typeof marker.getLatLng !== 'function') return false;
|
||||
const leaflet = options.leaflet || null;
|
||||
const layer = options.layer || null;
|
||||
if (!leaflet || typeof leaflet.divIcon !== 'function' || typeof leaflet.marker !== 'function') {
|
||||
return false;
|
||||
}
|
||||
if (!layer || typeof layer.addLayer !== 'function') return false;
|
||||
const duration = typeof options.duration === 'number' ? options.duration : WAVE_DURATION_MS;
|
||||
const schedule = typeof options.schedule === 'function' ? options.schedule : defaultSchedule;
|
||||
const color = typeof options.color === 'string' && options.color ? options.color : 'rgba(255, 255, 255, 0.85)';
|
||||
const icon = leaflet.divIcon({
|
||||
className: 'live-flash-wave-icon',
|
||||
html: `<div class="live-flash-wave" style="--flash-role-color: ${color}"></div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
});
|
||||
const wave = leaflet.marker(marker.getLatLng(), { icon, interactive: false, keyboard: false });
|
||||
layer.addLayer(wave);
|
||||
schedule(() => {
|
||||
if (typeof layer.removeLayer === 'function') layer.removeLayer(wave);
|
||||
}, duration);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a wave from each changed node's marker (SPEC LV5). Looks up each node's
|
||||
* marker and role colour and delegates to {@link emitMarkerWave}; nodes without
|
||||
* a marker are skipped. Pure over its injected lookups, so it unit-tests without
|
||||
* a real map.
|
||||
*
|
||||
* @param {?Iterable<string>} nodeIds Canonical node ids that changed.
|
||||
* @param {Object} [options] Lookups + wave config.
|
||||
* @param {?{get: Function}} [options.markerByNodeId] node id -> Leaflet marker.
|
||||
* @param {?Object} [options.leaflet] Leaflet namespace (`L`).
|
||||
* @param {?{addLayer: Function, removeLayer: Function}} [options.layer] Wave host layer.
|
||||
* @param {?(id: string) => ?string} [options.colorForNodeId] Resolves a node's wave colour.
|
||||
* @param {Object} [options.waveOptions] Extra options forwarded to {@link emitMarkerWave}.
|
||||
* @returns {number} count of waves emitted.
|
||||
*/
|
||||
export function emitNodeWaves(nodeIds, options = {}) {
|
||||
if (!nodeIds || typeof nodeIds[Symbol.iterator] !== 'function') return 0;
|
||||
const { markerByNodeId = null, leaflet = null, layer = null, colorForNodeId = null, waveOptions = {} } = options;
|
||||
if (!markerByNodeId || typeof markerByNodeId.get !== 'function') return 0;
|
||||
let count = 0;
|
||||
for (const id of nodeIds) {
|
||||
const marker = markerByNodeId.get(id);
|
||||
if (!marker) continue;
|
||||
const color = typeof colorForNodeId === 'function' ? colorForNodeId(id) : null;
|
||||
if (emitMarkerWave(marker, { ...waveOptions, leaflet, layer, color })) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash every UI target for a set of changed nodes: each node's table row(s)
|
||||
* (`[data-node-row="<id>"]`) and its map marker (SPEC VF3).
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Carry an open map-marker short-info overlay across a full map re-render
|
||||
* (item 7).
|
||||
*
|
||||
* `renderMap` clears and rebuilds every Leaflet marker on each refresh, which
|
||||
* destroys the marker DOM element an open overlay is anchored to. The overlay
|
||||
* stack's `cleanupOrphans` then closes that now-orphaned overlay, so any
|
||||
* overlay the user opened snaps shut the instant a live update lands. These two
|
||||
* pure helpers snapshot which node's marker currently hosts an open overlay
|
||||
* *before* the rebuild and re-anchor it to the rebuilt marker *after*, so the
|
||||
* overlay stays open while updates fire. They take the overlay stack and the
|
||||
* node->marker map as arguments, so they unit-test without a real map or DOM.
|
||||
*
|
||||
* @module main/marker-overlay-preservation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Snapshot the open marker overlays, keyed by node id, before a map rebuild.
|
||||
*
|
||||
* @param {{ isOpen: Function }} overlayStack Short-info overlay stack.
|
||||
* @param {Map<string, { getElement?: Function }>} markerByNodeId Current
|
||||
* node-id -> Leaflet marker map (the render about to be replaced).
|
||||
* @returns {Array<{ nodeId: string, anchor: Element }>} the overlays to
|
||||
* preserve; empty when the stack/map is missing or nothing is open.
|
||||
*/
|
||||
export function captureOpenMarkerOverlays(overlayStack, markerByNodeId) {
|
||||
const captured = [];
|
||||
if (!overlayStack || typeof overlayStack.isOpen !== 'function' || !markerByNodeId) {
|
||||
return captured;
|
||||
}
|
||||
for (const [nodeId, marker] of markerByNodeId) {
|
||||
const anchor = marker && typeof marker.getElement === 'function' ? marker.getElement() : null;
|
||||
if (anchor && overlayStack.isOpen(anchor)) {
|
||||
captured.push({ nodeId, anchor });
|
||||
}
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-anchor previously-captured overlays onto the rebuilt markers.
|
||||
*
|
||||
* For each snapshot entry, the rebuilt marker for the same node id is looked up
|
||||
* and the overlay re-pointed from its old (now-detached) anchor to the new
|
||||
* marker's element. A node that vanished from the rebuild (no marker) is left
|
||||
* for `cleanupOrphans` to close, which is the correct behaviour.
|
||||
*
|
||||
* @param {{ reanchor: Function }} overlayStack Short-info overlay stack.
|
||||
* @param {Array<{ nodeId: string, anchor: Element }>} captured Snapshot from
|
||||
* {@link captureOpenMarkerOverlays}.
|
||||
* @param {Map<string, { getElement?: Function }>} markerByNodeId Rebuilt
|
||||
* node-id -> Leaflet marker map.
|
||||
* @returns {number} count of overlays re-anchored.
|
||||
*/
|
||||
export function restoreMarkerOverlays(overlayStack, captured, markerByNodeId) {
|
||||
if (
|
||||
!overlayStack ||
|
||||
typeof overlayStack.reanchor !== 'function' ||
|
||||
!Array.isArray(captured) ||
|
||||
!markerByNodeId ||
|
||||
typeof markerByNodeId.get !== 'function'
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
let restored = 0;
|
||||
for (const entry of captured) {
|
||||
if (!entry || !entry.nodeId) {
|
||||
continue;
|
||||
}
|
||||
const marker = markerByNodeId.get(entry.nodeId);
|
||||
const newAnchor = marker && typeof marker.getElement === 'function' ? marker.getElement() : null;
|
||||
if (newAnchor && overlayStack.reanchor(entry.anchor, newAnchor)) {
|
||||
restored += 1;
|
||||
}
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
@@ -245,3 +245,45 @@ export function getRoleRenderPriority(role, protocol = null) {
|
||||
const priority = meshtasticRoleRenderOrder[key];
|
||||
return typeof priority === 'number' ? priority : 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Convert a ``#rrggbb`` (or ``#rgb``) hex colour to an ``rgba(r, g, b, a)``
|
||||
* string. Used by {@link getRoleFlashColor} to express a role colour at a fixed
|
||||
* alpha for the live-update fade keyframe (LV3).
|
||||
*
|
||||
* @param {*} hex Hex colour such as ``#f3ef74`` or ``#abc``.
|
||||
* @param {number} alpha Alpha channel in the inclusive range 0..1.
|
||||
* @returns {?string} An ``rgba(...)`` string, or ``null`` when ``hex`` is not a
|
||||
* parseable 3/6-digit hex colour.
|
||||
*/
|
||||
export function hexToRgba(hex, alpha) {
|
||||
if (typeof hex !== 'string') return null;
|
||||
let h = hex.trim();
|
||||
if (h[0] === '#') h = h.slice(1);
|
||||
if (h.length === 3) {
|
||||
h = h.split('').map(c => c + c).join('');
|
||||
}
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
const a = typeof alpha === 'number' && alpha >= 0 && alpha <= 1 ? alpha : 1;
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role colour as a translucent ``rgba()`` string for the live-update highlight
|
||||
* fade (SPEC LV1/LV3). Stamped onto a flashable element as the
|
||||
* ``--flash-role-color`` custom property so the CSS keyframe can fade white
|
||||
* through the element's role colour without any per-element JS.
|
||||
*
|
||||
* @param {*} role Raw role value.
|
||||
* @param {string|null|undefined} [protocol] Protocol string from the API.
|
||||
* @param {number} [alpha=0.55] Alpha for the mid-fade role-colour stop.
|
||||
* @returns {string} The role colour as an ``rgba(...)`` string. {@link getRoleColor}
|
||||
* always resolves to a parseable hex, so a real ``rgba(...)`` is always returned.
|
||||
*/
|
||||
export function getRoleFlashColor(role, protocol = null, alpha = 0.55) {
|
||||
return hexToRgba(getRoleColor(role, protocol), alpha);
|
||||
}
|
||||
|
||||
@@ -155,6 +155,9 @@ function createNoopOverlayStack() {
|
||||
isTokenCurrent() {
|
||||
return false;
|
||||
},
|
||||
reanchor() {
|
||||
return false;
|
||||
},
|
||||
getOpenOverlays() {
|
||||
return [];
|
||||
},
|
||||
@@ -181,6 +184,7 @@ function createNoopOverlayStack() {
|
||||
* cleanupOrphans: () => void,
|
||||
* incrementRequestToken: (anchor: Element) => number,
|
||||
* isTokenCurrent: (anchor: Element, token: number) => boolean,
|
||||
* reanchor: (oldAnchor: Element, newAnchor: Element) => boolean,
|
||||
* getOpenOverlays: () => Array<{ anchor: Element, element: Element }>
|
||||
* }} Overlay stack interface.
|
||||
*/
|
||||
@@ -460,6 +464,36 @@ export function createShortInfoOverlayStack(options = {}) {
|
||||
return overlayStates.has(anchor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-point an open overlay from one anchor element to another, preserving
|
||||
* its content and request token. Used when the anchored element is replaced
|
||||
* by a fresh DOM node (e.g. a Leaflet marker rebuilt on a map re-render) so
|
||||
* the overlay is carried across instead of orphaned and closed by
|
||||
* {@link cleanupOrphans} (item 7).
|
||||
*
|
||||
* @param {Element} oldAnchor Anchor the overlay is currently keyed by.
|
||||
* @param {Element} newAnchor Replacement anchor element.
|
||||
* @returns {boolean} ``true`` when an overlay was re-anchored (or the two
|
||||
* anchors are identical); ``false`` when no overlay exists for
|
||||
* ``oldAnchor`` or ``newAnchor`` is not a valid anchor.
|
||||
*/
|
||||
function reanchor(oldAnchor, newAnchor) {
|
||||
const state = overlayStates.get(oldAnchor);
|
||||
if (!state || !isValidAnchor(newAnchor)) {
|
||||
return false;
|
||||
}
|
||||
if (oldAnchor === newAnchor) {
|
||||
return true;
|
||||
}
|
||||
overlayStates.delete(oldAnchor);
|
||||
state.anchor = newAnchor;
|
||||
overlayStates.set(newAnchor, state);
|
||||
// Keep the overlay attached and reposition it against the new anchor box.
|
||||
ensureOverlayAttached(state.element);
|
||||
schedulePosition(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every active overlay.
|
||||
*
|
||||
@@ -563,6 +597,7 @@ export function createShortInfoOverlayStack(options = {}) {
|
||||
cleanupOrphans,
|
||||
incrementRequestToken,
|
||||
isTokenCurrent,
|
||||
reanchor,
|
||||
getOpenOverlays,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1058,6 +1058,37 @@ body.view-chat .page-shell {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Channel dropdown selector (SPEC LV8): a compact jump-to-channel control at the
|
||||
end of the tab bar. Mirrors the federation/region `.instance-select` chrome
|
||||
(theme tokens + a CSS-gradient triangle) so the two dropdowns match, sized down
|
||||
to sit beside the channel tabs. */
|
||||
.chat-tab-select {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
margin-left: 0.5rem;
|
||||
max-width: 11rem;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-color: var(--input-bg);
|
||||
color: var(--input-fg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 8px;
|
||||
padding: 4px 26px 4px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%),
|
||||
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
|
||||
background-position: calc(100% - 14px) calc(50% - 3px), calc(100% - 9px) calc(50% - 3px);
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.chat-tab-select:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.chat-tab:is(:focus-visible, :hover) {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
@@ -2554,22 +2585,45 @@ body.dark #map .leaflet-tile.map-tiles {
|
||||
.markdown-body h2 { font-size: 1.15em; }
|
||||
}
|
||||
|
||||
/* Live-update flash (SPEC VF5): a brief (<100ms) white highlight applied to a
|
||||
node row / map marker / chat message / channel tab when a live SSE update
|
||||
lands on it. Uses an inset box-shadow overlay so there is no layout shift, and
|
||||
self-completes (no fill) so the element reverts to its normal appearance. */
|
||||
@keyframes live-flash-highlight {
|
||||
from { box-shadow: inset 0 0 0 100vmax rgba(255, 255, 255, 0.85); }
|
||||
to { box-shadow: inset 0 0 0 100vmax rgba(255, 255, 255, 0); }
|
||||
/* Live-update highlight (SPEC LV1/LV2/LV3, amends VF5): a ~1.2s fade applied to a
|
||||
node row / chat message row when a live SSE update lands on it. It starts
|
||||
white, fades through the element's role colour (the --flash-role-color custom
|
||||
property stamped at render time) with increasing transparency, then clears.
|
||||
An inset box-shadow overlay means there is no layout shift; each element runs
|
||||
its own animation, so concurrent updates stack independently (LV2). */
|
||||
@keyframes live-flash-fade {
|
||||
0% { box-shadow: inset 0 0 0 100vmax rgba(255, 255, 255, 0.9); }
|
||||
30% { box-shadow: inset 0 0 0 100vmax var(--flash-role-color, rgba(255, 255, 255, 0.55)); }
|
||||
100% { box-shadow: inset 0 0 0 100vmax transparent; }
|
||||
}
|
||||
|
||||
.live-flash {
|
||||
animation: live-flash-highlight 90ms ease-out;
|
||||
animation: live-flash-fade 1.2s ease-out;
|
||||
}
|
||||
|
||||
/* Honor reduced-motion: data still updates live, only the flash is withheld. */
|
||||
/* Map-marker wave (SPEC LV5): an expanding ring emitted from a flashed marker.
|
||||
The ring grows from ~12px radius and fades toward the role colour over ~1.2s.
|
||||
Rendered as a Leaflet divIcon, so it is a positioned overlay with no layout
|
||||
shift; the marker layer removes it after the animation completes. */
|
||||
@keyframes live-flash-wave {
|
||||
0% { transform: scale(1); opacity: 0.85; }
|
||||
100% { transform: scale(3.5); opacity: 0; }
|
||||
}
|
||||
|
||||
.live-flash-wave {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--flash-role-color, rgba(255, 255, 255, 0.85));
|
||||
pointer-events: none;
|
||||
animation: live-flash-wave 1.2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Honor reduced-motion: data still updates live, only the highlight/wave is
|
||||
withheld. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.live-flash {
|
||||
.live-flash,
|
||||
.live-flash-wave {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +526,35 @@ RSpec.describe PotatoMesh::Config do
|
||||
end
|
||||
end
|
||||
|
||||
describe ".sse_publish_cooldown_seconds" do
|
||||
it "returns the baked-in default when unset" do
|
||||
within_env("SSE_PUBLISH_COOLDOWN" => nil) do
|
||||
expect(described_class.sse_publish_cooldown_seconds).to eq(
|
||||
PotatoMesh::Config::DEFAULT_SSE_PUBLISH_COOLDOWN_SECONDS,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it "accepts a non-negative float override (including 0 to disable)" do
|
||||
within_env("SSE_PUBLISH_COOLDOWN" => "0.5") do
|
||||
expect(described_class.sse_publish_cooldown_seconds).to eq(0.5)
|
||||
end
|
||||
within_env("SSE_PUBLISH_COOLDOWN" => "0") do
|
||||
expect(described_class.sse_publish_cooldown_seconds).to eq(0.0)
|
||||
end
|
||||
end
|
||||
|
||||
it "falls back to the default for blank, unparseable, or negative values" do
|
||||
["", " ", "abc", "-2"].each do |raw|
|
||||
within_env("SSE_PUBLISH_COOLDOWN" => raw) do
|
||||
expect(described_class.sse_publish_cooldown_seconds).to eq(
|
||||
PotatoMesh::Config::DEFAULT_SSE_PUBLISH_COOLDOWN_SECONDS,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ".prom_report_id_list" do
|
||||
it "returns an empty collection when no identifiers are configured" do
|
||||
expect(described_class.prom_report_id_list).to eq([])
|
||||
|
||||
+88
-6
@@ -171,6 +171,49 @@ RSpec.describe PotatoMesh::App::PubSub do
|
||||
end
|
||||
end
|
||||
|
||||
describe "#drain settle window (LV6 cooldown)" do
|
||||
it "coalesces a burst within the settle window into one drain" do
|
||||
subscriber.deliver("positions", 1)
|
||||
# The injected sleeper stands in for the cooldown window; more ingestors
|
||||
# deliver the same/another collection while it "sleeps".
|
||||
sleeper = lambda do |_seconds|
|
||||
subscriber.deliver("positions", 2)
|
||||
subscriber.deliver("telemetry", 5)
|
||||
end
|
||||
events = subscriber.drain(timeout: 0, settle: 1, sleeper: sleeper)
|
||||
expect(events).to eq(
|
||||
[
|
||||
{ collection: "positions", hint: 2 },
|
||||
{ collection: "telemetry", hint: 5 },
|
||||
],
|
||||
)
|
||||
end
|
||||
|
||||
it "sleeps for the settle window only when a change is pending" do
|
||||
slept = []
|
||||
sleeper = ->(seconds) { slept << seconds }
|
||||
|
||||
# Idle heartbeat tick: nothing pending, so no settle sleep.
|
||||
expect(subscriber.drain(timeout: 0, settle: 1, sleeper: sleeper)).to eq([])
|
||||
expect(slept).to eq([])
|
||||
|
||||
# A pending change triggers exactly one settle sleep.
|
||||
subscriber.deliver("nodes", nil)
|
||||
expect(subscriber.drain(timeout: 0, settle: 1, sleeper: sleeper)).to eq(
|
||||
[{ collection: "nodes", hint: nil }],
|
||||
)
|
||||
expect(slept).to eq([1])
|
||||
end
|
||||
|
||||
it "does not sleep when settle is zero (cooldown disabled)" do
|
||||
slept = []
|
||||
sleeper = ->(seconds) { slept << seconds }
|
||||
subscriber.deliver("nodes", nil)
|
||||
subscriber.drain(timeout: 0, settle: 0, sleeper: sleeper)
|
||||
expect(slept).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
describe "#close" do
|
||||
it "ignores deliveries and drains immediately once closed" do
|
||||
subscriber.close
|
||||
@@ -225,11 +268,12 @@ RSpec.describe PotatoMesh::App::PubSub do
|
||||
|
||||
it "publishes a thin per-collection event (PS3)" do
|
||||
subscriber = PotatoMesh::App::PubSub.subscribe
|
||||
# Use positions: it publishes exactly one collection. (The messages route
|
||||
# additionally publishes nodes — covered by the message-ingest example.)
|
||||
post "/api/positions", "[]", auth
|
||||
# Use neighbors: it publishes exactly one collection. (The messages,
|
||||
# positions, and telemetry routes additionally publish nodes because they
|
||||
# advance a node's last_heard - covered by the dedicated examples below.)
|
||||
post "/api/neighbors", "[]", auth
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "positions", hint: nil }])
|
||||
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "neighbors", hint: nil }])
|
||||
end
|
||||
|
||||
it "publishes nodes on a message ingest (#822 touches the author node)" do
|
||||
@@ -240,6 +284,41 @@ RSpec.describe PotatoMesh::App::PubSub do
|
||||
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("nodes", private_mode: false)
|
||||
end
|
||||
|
||||
# A positions / telemetry ingest advances the affected node's last_heard
|
||||
# server-side (touch_node_last_seen), but unlike the messages route it
|
||||
# historically published only its own collection, so the live dashboard
|
||||
# fetched only that collection and never re-pulled the node row, leaving
|
||||
# the node table "last seen" stale until the safety poll. These routes now
|
||||
# also publish "nodes" (mirroring #822) so the changed node last_heard
|
||||
# refreshes and flashes live.
|
||||
it "publishes nodes on a positions ingest (advances the node last_heard)" do
|
||||
allow(PotatoMesh::App::PubSub).to receive(:publish).and_call_original
|
||||
post "/api/positions", "[]", auth
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("positions", private_mode: false)
|
||||
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("nodes", private_mode: false)
|
||||
end
|
||||
|
||||
it "publishes nodes on a telemetry ingest (advances the node last_heard)" do
|
||||
allow(PotatoMesh::App::PubSub).to receive(:publish).and_call_original
|
||||
post "/api/telemetry", "[]", auth
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("telemetry", private_mode: false)
|
||||
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("nodes", private_mode: false)
|
||||
end
|
||||
|
||||
# Neighbors / traces also advance last_heard, but SPEC VF3 keeps them out
|
||||
# of the flash scope, so they deliberately do NOT publish "nodes" (their
|
||||
# last_heard refresh is surfaced silently by the safety poll, never flashed).
|
||||
it "does not publish nodes on a neighbors or traces ingest (VF3 boundary)" do
|
||||
allow(PotatoMesh::App::PubSub).to receive(:publish).and_call_original
|
||||
post "/api/neighbors", "[]", auth
|
||||
expect(last_response.status).to eq(201)
|
||||
post "/api/traces", "[]", auth
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(PotatoMesh::App::PubSub).not_to have_received(:publish).with("nodes", private_mode: false)
|
||||
end
|
||||
|
||||
it "publishes on every ingest route" do
|
||||
allow(PotatoMesh::App::PubSub).to receive(:publish).and_call_original
|
||||
|
||||
@@ -258,12 +337,15 @@ RSpec.describe PotatoMesh::App::PubSub do
|
||||
|
||||
it "coalesces bursts of one collection into a single pending event" do
|
||||
subscriber = PotatoMesh::App::PubSub.subscribe
|
||||
# neighbors publishes exactly one collection (positions/telemetry now
|
||||
# also publish nodes for the last_heard hook-in), so it isolates the
|
||||
# per-collection coalescing under test.
|
||||
5.times do
|
||||
post "/api/positions", "[]", auth
|
||||
post "/api/neighbors", "[]", auth
|
||||
expect(last_response.status).to eq(201)
|
||||
end
|
||||
expect(subscriber.pending_count).to eq(1)
|
||||
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "positions", hint: nil }])
|
||||
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "neighbors", hint: nil }])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -121,6 +121,26 @@ RSpec.describe PotatoMesh::App::Routes::Events do
|
||||
end
|
||||
end.not_to raise_error
|
||||
end
|
||||
|
||||
it "passes the settle cooldown through to drain (LV6)" do
|
||||
out = fake_out_class.new(close_after: 1)
|
||||
recorded = {}
|
||||
subscriber = Object.new
|
||||
subscriber.define_singleton_method(:drain) do |timeout:, settle: 0|
|
||||
recorded[:timeout] = timeout
|
||||
recorded[:settle] = settle
|
||||
[{ collection: "nodes", hint: nil }]
|
||||
end
|
||||
|
||||
Timeout.timeout(5) do
|
||||
described_class.pump(
|
||||
out, subscriber, heartbeat: 0.01, deadline_at: 1_000, settle: 0.25, clock: -> { 0 },
|
||||
)
|
||||
end
|
||||
|
||||
expect(recorded[:timeout]).to eq(0.01)
|
||||
expect(recorded[:settle]).to eq(0.25)
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /api/events route" do
|
||||
|
||||
Reference in New Issue
Block a user