web: add visual feedback to pubsub events (#824)

This commit is contained in:
l5y
2026-06-24 20:34:35 +02:00
committed by GitHub
parent 4831e37a52
commit 1804a8a75e
16 changed files with 1124 additions and 148 deletions
+121
View File
@@ -1487,3 +1487,124 @@ only adds a `last_heard` carry to the same merge helpers), the #755 / #756
synthetic-merge specs in `database_spec.rb` / `data_processing_spec.rb`, and **B1**
(all suites). No POST/GET/event contract change, so the Python ingestor and
`CONTRACTS.md` are unaffected.
---
## Feature: Live-update visual feedback (flash + control cleanup)
Maps to SPEC decisions **VF1VF7**. Live SSE updates now flash the affected
element white (<100 ms); the poll-era Refresh button and "last updated" field are
removed (play/pause stays). The flash-trigger logic + a flash helper live under
`web/public/assets/js/app/main/` (with co-located `__tests__`); the highlight
keyframe lives in `web/public/assets/styles/base.css`; the only server change is an
additive `nodes` publish on `POST /api/messages`
(`web/lib/potato_mesh/application/routes/ingest.rb`). *Run the server in public
mode for the curl checks; run JS suites from `web/`.*
### VF-A1 — Poll-era controls removed; play/pause kept — VF1
```bash
git grep -nE 'id="refreshBtn"|id="status"' -- web/views
git grep -nE 'id="autorefreshToggle"' -- web/views/layouts/app.erb
( cd web && bundle exec rspec spec/app_spec.rb -e "does not render the Refresh button or last-updated field" )
```
**Expected:** the first grep prints **no output** — the `#refreshBtn` button and the
`#status` "last updated" field are gone from the views. The second prints the
`#autorefreshToggle` line — the play/pause control remains. The rspec example
passes: the rendered dashboard contains no `id="refreshBtn"` and no `id="status"`
refresh-timestamp element, and still contains `id="autorefreshToggle"`. `main.js`
no longer writes `refreshing…` / `updated <time>` status text (it has no `#status`
element to write to).
### VF-A2 — Flash fires only on SSE-ping deltas, never on load/resync/poll — VF2
```bash
( cd web && node --test public/assets/js/app/__tests__/main-flash.test.js )
```
**Expected:** pass. With a fake `EventSource` + stub fetch: the **initial load**
applies **no** flash (no strobe on paint); a subsequent SSE `change` ping for a
collection flashes the affected element; a reconnect (`open` → resync) and a
safety-poll refresh apply **no** flash. The flash is driven only from the
SSE-ping-driven targeted refresh (`runLiveRefresh`), confirmed by asserting a
resync/poll-shaped refresh leaves the flash count unchanged.
### VF-A3 — Correct element flashes per collection (incl. message⇒node) — VF3
```bash
( cd web && node --test public/assets/js/app/__tests__/main-flash.test.js )
( cd web && bundle exec rspec spec/pubsub_spec.rb -e "publishes nodes on a message ingest" )
```
**Expected:** pass. A `nodes`/`positions`/`telemetry` ping flashes the affected
node's **node-table row** (`[data-node-id]`) and **map marker**. A `messages` ping
flashes the **message row** and the **channel tab header**; and because
`POST /api/messages` **also publishes `nodes`** (extends PS4 — verified by the rspec
example: a single message POST publishes both `messages` and `nodes`), the author
node's row + marker flash too. `neighbors` / `traces` pings flash **nothing** (the
documented out-of-scope boundary). Detection is by id/collection and identical for
both protocols (Invariant IV).
### VF-A4 — Flash is applied after render, never to an unrendered element — VF4
```bash
( cd web && node --test public/assets/js/app/__tests__/main-flash.test.js )
```
**Expected:** pass. The flash is applied in a post-render step: a ping for a node
not yet present in the DOM first renders/positions the row + marker (and a message
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
```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
```
**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
duration are confirmed by reading the rule.
### VF-A6 — Render & cache invariants preserved; #822 holds — VF6
```bash
( cd web && node --test public/assets/js/app/__tests__/main-chat-render-incremental.test.js )
( cd web && bundle exec rspec spec/app_spec.rb -e "updates node last_heard for plaintext messages" )
```
**Expected:** pass. With the flash code present, an **idle** re-render still
materialises **0** entries and issues **0** per-node `/api/nodes/:id` requests
(**CR-A1** unchanged — the flash touches only already-rendered/cached DOM and never
re-materialises). The existing #822 example confirms a message ingest still bumps
the author node's `last_heard` (also covered at the unit level by
`data_processing_spec.rb` "advances the reconciled real node's last_heard when a
chat message arrives"), which is what makes the message⇒node flash reflect real
data. The seed-then-delta cache (FC-A2) is untouched.
### VF-A7 — Engineering bar — VF7
```bash
( cd web && bundle exec rspec ) && ( cd web && npm test )
git ls-files 'web/public/assets/js/app/main/flash.js' \
'web/public/assets/js/app/__tests__/main-flash.test.js' \
| xargs grep -L 'Copyright © 2025-26 l5yth & contributors'
```
**Expected:** pass / no output. The Ruby and JS suites are green with new coverage
for the flash trigger (changed-id selection, after-render ordering, ping-only
gating, message⇒node fan-out), the flash helper, and the `nodes`-on-message publish.
Every new source file carries the exact Apache header (B4a) and JSDoc (B3).
### VF-R1 — Regression: prior acceptance still holds
```bash
( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )
```
**Expected:** every prior check still passes. **At risk and explicitly required to
remain green:**
- **CR-A1 / CR-A2 / CR-A3** (incremental render + map-only hydration — the flash
never re-materialises or fetches per node).
- **PS-A5 / PS-A7** (SSE targeted fetch + cache delta) and **FC-A2** (seed-then-delta
— flashing is gated to SSE-ping deltas, so warm-start/resync/poll never flash).
- **PS-A6 / A2 / A2a** (privacy — `/api/messages` still 404s in `PRIVATE`, so the new
`nodes`-on-message publish is moot there; node events are not privacy-gated).
- **PL-A1 / PL-A2** (progressive load), **A4c** (chat parity — same render path), and
the **autorefresh/pause** specs (the toggle still pauses live + poll after the
Refresh/status controls are removed).
- **B1** (all suites). The only contract change is the additive `nodes` publish on
message ingest (a new SSE event, documented in `CONTRACTS.md`); no POST/GET shape
changes, so **C2** and the Python suite are unaffected.
+42
View File
@@ -436,3 +436,45 @@ untouched.
| **PS6** | **Privacy: no `messages` events when PRIVATE (Invariant II).** Under `PRIVATE=1` the server emits **no `messages` change events** and the `GET /api/events` stream carries none, mirroring the `/api/messages` 404 (A2a). Because events are thin (no row data) and the client re-fetches through the already-filtered `/api` endpoints, opt-out / `CLIENT_HIDDEN` rows never traverse the push. Defense-in-depth; Invariant II is preserved. | interview + Invariant II |
| **PS7** | **Amends FC2/FC-A2/FC-R1 cadence wording (named, not silent).** The seed-then-delta cache *mechanism* is unchanged (still `since=<high-water>`, only-misses-fetch, merge-by-id). Only the **trigger** changes: from a fixed 60 s cadence to event-driven (SSE ping / reconnect resync / slow safety poll). FC-A2 and FC-R1's "auto-refresh cadence is unchanged" clause is **explicitly amended** to "the fetch trigger is event-driven with a slow safety-poll fallback; the delta/merge/cache contract is unchanged." | interview (FC2 amendment) |
| **PS8** | **Graceful degradation & engineering bar (D9).** If `EventSource` is unavailable, the stream errors, or a config flag disables it, the client silently falls back to the safety poll (today's network-only behavior) — the push is **never load-bearing**. All new code (the pub/sub registry, the `GET /api/events` route, the frontend SSE client) ships with 100% unit tests, full API docs (RDoc/JSDoc), the exact Apache header, and `rufo`-clean formatting; all existing suites stay green. | D9 + proposed |
---
## Feature: Live-update visual feedback (flash + control cleanup)
Builds on the SSE pub/sub feature (PS1PS8) to make live updates *visible*: when a
change arrives over SSE the affected element briefly flashes white, so a watching
operator sees **where** an update landed without scanning. The poll-era controls
(the "Refresh" button and the "last updated" timestamp) are removed, since push
makes them redundant; the play/pause toggle stays. Frontend-only (vanilla JS +
`base.css`) except one additive server change: `POST /api/messages` also publishes
a `nodes` change event (because a message ingest already touches the author node's
`last_heard`, #822). Integrates with `views/layouts/app.erb` (control removal),
`web/public/assets/js/app/main.js` (`refresh`/`runLiveRefresh` wiring, node-table +
map-marker + chat render paths), `chat-tabs.js` (tab header), `node-rendering.js`
(`data-node-id` rows), a new flash helper under `web/public/assets/js/app/main/`
(+ `__tests__`), `web/public/assets/styles/base.css` (the highlight keyframe), and
`web/lib/potato_mesh/application/routes/ingest.rb` (the `nodes`-on-message publish).
**Conflict check against existing decisions.** *PS5 (push replaced poll)*
**realizes**: removing the Refresh button + "last updated" field is PS5's UI
consequence. *PS4 (publish-on-change)***extends**: the messages route now also
publishes `nodes` (the table it already writes via #822). *PS6 / Invariant II
(privacy)* — **consistent**: in `PRIVATE` the message route 404s before publish, so
no `messages`/`nodes` event fires from a message; node events are not privacy-gated.
*CR-A1/CR-A2/CR-A3 (incremental render)* — **consistent (guarded)**: the flash
touches only already-rendered/cached DOM after render, never re-materializing, so an
idle tick still materializes 0 entries. *FC-A2 / PS-A7 (seed-then-delta)*
**consistent**: flashing is gated to SSE-ping deltas, so cold/warm seed, resync, and
the safety poll never flash (no strobe). *Apex I / §3.3 / Invariant IV / A4c / D1*
**consistent**: read-side visual + one in-process publish call; protocol-neutral; no
ingest path, no `/version` change. No invariant is contradicted.
| # | Decision | Source |
| --- | --- | --- |
| **VF1** | **Remove poll-era controls.** The `#refreshBtn` button and the `#status` "last updated" field are removed from the dashboard (along with their `main.js` handlers, the `refreshing…`/`updated <time>` status writes, and the `.refresh-timestamp` style). The `#autorefreshToggle` play/pause control is **kept** — it still pauses both the live stream and the safety poll (PS5). Manual refresh and a timestamp are redundant once push delivers updates. | interview |
| **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 |
| **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 |
+5
View File
@@ -291,6 +291,11 @@ data: {"collection":"messages","hint":1700000000}
`neighbors`, `traces` — exactly the dashboard ingest collections. The client
reacts by re-running its existing delta fetch (`GET /api/<collection>?since=…`)
and merging by id; no row data is delivered over the stream.
- **A `POST /api/messages` ingest publishes *two* events — `messages` and
`nodes`** — because a message also touches the author node's `last_heard`
(#822). One ingest route may therefore emit more than one collection event; a
client must handle each event independently and must not assume a 1:1
route→event mapping.
- **`hint`** (optional integer) is the newest `rx_time`/`last_heard` seen for the
collection — a skip hint; the client may ignore it and use its own high-water
mark. It is currently not emitted by the server (reserved).
@@ -78,8 +78,13 @@ module PotatoMesh
messages.each do |msg|
insert_message(db, msg, protocol_cache: protocol_cache)
end
PotatoMesh::App::ApiCache.invalidate_prefix("api:messages:", "api:stats:")
# A message ingest also touches the author node's last_heard (#822),
# so invalidate the nodes cache and publish a nodes change in addition
# to messages — the dashboard then refreshes (and flashes) that node.
# Mirrors how the positions route invalidates api:nodes:.
PotatoMesh::App::ApiCache.invalidate_prefix("api:messages:", "api:nodes:", "api:stats:")
PotatoMesh::App::PubSub.publish("messages", private_mode: private_mode?)
PotatoMesh::App::PubSub.publish("nodes", private_mode: private_mode?)
status 201
{ status: "ok" }.to_json
ensure
@@ -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.
*/
/**
* VF-A2/VF-A3 live-update flash is driven *only* by SSE-ping deltas, and a
* node/position/telemetry ping flashes the affected node. Verified via the flash
* counter + the ids handed to the flash (the row/marker DOM application itself is
* unit-tested in main/__tests__/flash.test.js).
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { runLiveApp, DEFAULT_RESPONSES } from './sse-app-harness.js';
const NOW = Math.floor(Date.now() / 1000);
function ping(FakeEventSource, collection) {
FakeEventSource.instances[0].dispatch('change', { data: JSON.stringify({ collection }) });
}
test('the initial load does not flash (no strobe on paint)', async () => {
await runLiveApp({}, async ({ testUtils }) => {
assert.equal(testUtils.getLiveFlashCount(), 0);
});
});
test('a nodes ping flashes the changed node', async () => {
await runLiveApp({}, async ({ testUtils, FakeEventSource }) => {
ping(FakeEventSource, 'nodes');
await testUtils.flushLiveRefresh();
assert.equal(testUtils.getLiveFlashCount(), 1);
assert.deepEqual(testUtils.getLastFlashedNodeIds(), ['!a']);
});
});
test('a positions ping flashes the position\'s node', async () => {
const responses = {
...DEFAULT_RESPONSES,
'/api/positions': [{ id: 1, node_id: '!p', rx_time: NOW, latitude: 1, longitude: 2 }],
};
await runLiveApp({ responses }, async ({ testUtils, FakeEventSource }) => {
ping(FakeEventSource, 'positions');
await testUtils.flushLiveRefresh();
assert.deepEqual(testUtils.getLastFlashedNodeIds(), ['!p']);
});
});
test('a telemetry ping flashes the telemetry node', async () => {
const responses = {
...DEFAULT_RESPONSES,
'/api/telemetry': [{ id: 1, node_id: '!t', rx_time: NOW, battery_level: 80 }],
};
await runLiveApp({ responses }, async ({ testUtils, FakeEventSource }) => {
ping(FakeEventSource, 'telemetry');
await testUtils.flushLiveRefresh();
assert.deepEqual(testUtils.getLastFlashedNodeIds(), ['!t']);
});
});
test('a messages ping flashes the message', async () => {
await runLiveApp({}, async ({ testUtils, FakeEventSource }) => {
ping(FakeEventSource, 'messages');
await testUtils.flushLiveRefresh();
assert.deepEqual(testUtils.getLastFlashedMessageIds(), ['1']);
});
});
test('a message (server publishes messages + nodes) flashes both the message and its author node', async () => {
await runLiveApp({}, async ({ testUtils, FakeEventSource }) => {
const es = FakeEventSource.instances[0];
// The server publishes BOTH on a message ingest (#822 / PS4 extension).
es.dispatch('change', { data: JSON.stringify({ collection: 'messages' }) });
es.dispatch('change', { data: JSON.stringify({ collection: 'nodes' }) });
await testUtils.flushLiveRefresh();
assert.deepEqual(testUtils.getLastFlashedMessageIds(), ['1']);
assert.deepEqual(testUtils.getLastFlashedNodeIds(), ['!a']);
});
});
test('a reconnect resync does not flash (VF2 — SSE pings only)', async () => {
await runLiveApp({}, async ({ testUtils, FakeEventSource }) => {
FakeEventSource.instances[0].dispatch('open', {}); // resync = full refresh, no flash
await testUtils.flushLiveRefresh();
assert.equal(testUtils.getLiveFlashCount(), 0);
});
});
test('a neighbors ping flashes nothing (out of scope, VF3)', async () => {
const responses = {
...DEFAULT_RESPONSES,
'/api/neighbors': [{ node_id: '!n', neighbor_id: '!m', rx_time: NOW }],
};
await runLiveApp({ responses }, async ({ testUtils, FakeEventSource }) => {
ping(FakeEventSource, 'neighbors');
await testUtils.flushLiveRefresh();
assert.equal(testUtils.getLiveFlashCount(), 0);
});
});
@@ -23,117 +23,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDomEnvironment } from './dom-environment.js';
import { initializeApp } from '../main.js';
const NOW = Math.floor(Date.now() / 1000);
const BASE_CONFIG = Object.freeze({
channel: 'Primary',
frequency: '915MHz',
refreshMs: 60_000,
refreshIntervalSeconds: 60,
safetyPollMs: 300_000,
liveUpdatesEnabled: true,
liveUpdatesPath: '/api/events',
chatEnabled: true,
mapCenter: { lat: 0, lon: 0 },
mapZoom: null,
maxDistanceKm: 0,
tileFilters: { light: '', dark: '' },
instancesFeatureEnabled: false,
instanceDomain: null,
snapshotWindowSeconds: 3600,
});
const NODES = [
{ node_id: '!a', short_name: 'A', long_name: 'Node A', last_heard: NOW, protocol: 'meshtastic' },
];
const MESSAGES = [
{ id: 1, channel: 0, from_id: '!a', to_id: '^all', text: 'hello', rx_time: NOW, protocol: 'meshtastic' },
];
/** Build a recording stub fetch answering from a URL-substring map. */
function buildStubFetch(responses) {
const calls = [];
return {
calls,
fetch(url) {
calls.push({ url });
for (const [prefix, body] of Object.entries(responses)) {
if (url.includes(prefix)) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
}
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve([]) });
},
};
}
/** A fresh fake EventSource class recording listeners + instances. */
function makeFakeEventSource() {
class FakeEventSource {
constructor(url) {
this.url = url;
this.listeners = {};
FakeEventSource.instances.push(this);
}
addEventListener(type, fn) {
(this.listeners[type] = this.listeners[type] || []).push(fn);
}
close() {
this.closed = true;
}
dispatch(type, event) {
(this.listeners[type] || []).forEach((fn) => fn(event));
}
}
FakeEventSource.instances = [];
return FakeEventSource;
}
/**
* Boot one app instance with a fake EventSource + stub fetch (cache disabled),
* await the initial load, run the body, then tear the timer/stream down.
*/
async function runLiveApp({ configOverrides = {}, withEventSource = true, responses } = {}, fn) {
const env = createDomEnvironment({ includeBody: true });
env.registerElement('chat', env.createElement('div', 'chat'));
const originalFetch = globalThis.fetch;
const originalES = globalThis.EventSource;
const originalIdb = globalThis.indexedDB;
const { fetch: stubFetch, calls } = buildStubFetch(
responses || { '/api/nodes': NODES, '/api/messages': MESSAGES },
);
globalThis.fetch = stubFetch;
globalThis.indexedDB = undefined; // disable the persistent cache for a clean cold path
const FakeEventSource = makeFakeEventSource();
if (withEventSource) globalThis.EventSource = FakeEventSource;
else delete globalThis.EventSource;
let testUtils = null;
try {
({ _testUtils: testUtils } = initializeApp({ ...BASE_CONFIG, ...configOverrides }));
await testUtils.initialLoad;
await testUtils.flushBackfill();
await fn({ calls, testUtils, FakeEventSource });
} finally {
// Let fire-and-forget refresh tails (the stats footer update at main.js
// `void fetchActiveNodeStats(...).then(...)`, cache write-back) settle
// against the live DOM before teardown, so no async work touches a
// torn-down document.
if (testUtils) await testUtils.flushCacheWrites();
for (let i = 0; i < 3; i += 1) await new Promise((resolve) => setTimeout(resolve, 0));
if (testUtils) testUtils.stopAutoRefresh(); // clear the armed interval so node can exit
globalThis.fetch = originalFetch;
if (originalES === undefined) delete globalThis.EventSource;
else globalThis.EventSource = originalES;
globalThis.indexedDB = originalIdb;
env.cleanup();
}
}
import { runLiveApp } from './sse-app-harness.js';
test('live updates active: SSE stream opens and the cadence is the slow safety poll', async () => {
await runLiveApp({}, async ({ testUtils, FakeEventSource }) => {
@@ -0,0 +1,149 @@
/*
* 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.
*/
/**
* Shared harness for driving `initializeApp` over a fake `EventSource` + stub
* `fetch` (cache disabled), used by the SSE live-update and flash test suites.
*
* @module __tests__/sse-app-harness
*/
import { createDomEnvironment } from './dom-environment.js';
import { initializeApp } from '../main.js';
const NOW = Math.floor(Date.now() / 1000);
/** App config with live updates enabled and a slow safety poll. */
export const SSE_BASE_CONFIG = Object.freeze({
channel: 'Primary',
frequency: '915MHz',
refreshMs: 60_000,
refreshIntervalSeconds: 60,
safetyPollMs: 300_000,
liveUpdatesEnabled: true,
liveUpdatesPath: '/api/events',
chatEnabled: true,
mapCenter: { lat: 0, lon: 0 },
mapZoom: null,
maxDistanceKm: 0,
tileFilters: { light: '', dark: '' },
instancesFeatureEnabled: false,
instanceDomain: null,
snapshotWindowSeconds: 3600,
});
/** Default stub-fetch payloads keyed by URL substring. */
export const DEFAULT_RESPONSES = Object.freeze({
'/api/nodes': [
{ node_id: '!a', short_name: 'A', long_name: 'Node A', last_heard: NOW, protocol: 'meshtastic' },
],
'/api/messages': [
{ id: 1, channel: 0, from_id: '!a', to_id: '^all', text: 'hello', rx_time: NOW, protocol: 'meshtastic' },
],
});
/**
* Build a recording stub fetch answering from a URL-substring map.
*
* @param {Object<string, *>} responses URL-substring JSON body.
* @returns {{ fetch: Function, calls: Array<{ url: string }> }}
*/
export function buildStubFetch(responses) {
const calls = [];
return {
calls,
fetch(url) {
calls.push({ url });
for (const [prefix, body] of Object.entries(responses)) {
if (url.includes(prefix)) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
}
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve([]) });
},
};
}
/**
* Create a fresh fake EventSource class recording listeners + instances so a
* test can dispatch `open` / `change` / `error` events.
*
* @returns {Function} the FakeEventSource constructor (with `.instances`).
*/
export function makeFakeEventSource() {
class FakeEventSource {
constructor(url) {
this.url = url;
this.listeners = {};
FakeEventSource.instances.push(this);
}
addEventListener(type, fn) {
(this.listeners[type] = this.listeners[type] || []).push(fn);
}
close() {
this.closed = true;
}
dispatch(type, event) {
(this.listeners[type] || []).forEach((fn) => fn(event));
}
}
FakeEventSource.instances = [];
return FakeEventSource;
}
/**
* Boot one app instance with a fake EventSource + stub fetch (cache disabled),
* await the initial load, run the body, then tear the timer/stream down so the
* Node test runner can exit cleanly.
*
* @param {{ configOverrides?: Object, withEventSource?: boolean, responses?: Object }} [opts]
* @param {(ctx: { calls: Array, testUtils: Object, FakeEventSource: Function }) => Promise<void>} fn Body.
* @returns {Promise<void>}
*/
export async function runLiveApp({ configOverrides = {}, withEventSource = true, responses } = {}, fn) {
const env = createDomEnvironment({ includeBody: true });
env.registerElement('chat', env.createElement('div', 'chat'));
const originalFetch = globalThis.fetch;
const originalES = globalThis.EventSource;
const originalIdb = globalThis.indexedDB;
const { fetch: stubFetch, calls } = buildStubFetch(responses || { ...DEFAULT_RESPONSES });
globalThis.fetch = stubFetch;
globalThis.indexedDB = undefined; // disable the persistent cache for a clean cold path
const FakeEventSource = makeFakeEventSource();
if (withEventSource) globalThis.EventSource = FakeEventSource;
else delete globalThis.EventSource;
let testUtils = null;
try {
({ _testUtils: testUtils } = initializeApp({ ...SSE_BASE_CONFIG, ...configOverrides }));
await testUtils.initialLoad;
await testUtils.flushBackfill();
await fn({ calls, testUtils, FakeEventSource });
} finally {
// Let fire-and-forget refresh tails (the stats footer update, cache write)
// settle against the live DOM before teardown.
if (testUtils) await testUtils.flushCacheWrites();
for (let i = 0; i < 3; i += 1) await new Promise((resolve) => setTimeout(resolve, 0));
if (testUtils) testUtils.stopAutoRefresh();
globalThis.fetch = originalFetch;
if (originalES === undefined) delete globalThis.EventSource;
else globalThis.EventSource = originalES;
globalThis.indexedDB = originalIdb;
env.cleanup();
}
}
+85 -18
View File
@@ -193,6 +193,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 { collectNodeIds, collectMessageIds, entryMessageId } from './main/flash-targets.js';
/**
* Entry point for the interactive dashboard. Wires up event listeners,
@@ -213,9 +215,7 @@ import { createEventStream } from './main/event-stream.js';
* inner closures for unit tests. Production callers may ignore this.
*/
export function initializeApp(config) {
const statusEl = document.getElementById('status');
const footerActiveNodes = document.getElementById('footerActiveNodes');
const refreshBtn = document.getElementById('refreshBtn');
const autorefreshToggle = document.getElementById('autorefreshToggle');
const protocolToggleMeshcore = document.getElementById('protocolToggleMeshcore');
const protocolToggleMeshtastic = document.getElementById('protocolToggleMeshtastic');
@@ -568,6 +568,14 @@ export function initializeApp(config) {
let liveRefreshTimer = null;
/** Promise of the most recent live-driven refresh (test hook). */
let liveRefreshPromise = Promise.resolve();
/** Count of flash rounds triggered by SSE pings (VF2 gating; test hook). */
let liveFlashCount = 0;
/** Node ids flashed by the most recent SSE-ping refresh (test hook). */
let lastFlashedNodeIds = [];
/** Message ids flashed by the most recent SSE-ping refresh (test hook). */
let lastFlashedMessageIds = [];
/** Per-render map of message id → its channel tab id, for the tab-header flash. */
let messageTabId = new Map();
/**
* Close any open short-info overlays that do not contain the provided anchor.
@@ -707,10 +715,44 @@ export function initializeApp(config) {
liveRefreshTimer = null;
const collections = new Set(dirtyCollections);
dirtyCollections.clear();
liveRefreshPromise = refresh({ collections });
// flash: true marks this as the SSE-ping path, the only refresh that
// flashes changed rows (SPEC VF2). Resync / safety poll / initial load
// call refresh() without it, so they never flash.
liveRefreshPromise = refresh({ collections, flash: true });
return liveRefreshPromise;
}
/**
* Flash each changed node's table row(s) and map marker white (SPEC VF3).
* Called after the table + map have rendered so the highlight lands on the
* final element. Targets already-rendered DOM only it never re-materialises
* rows or fetches, so the incremental-render invariants (CR-A1) are preserved.
*
* @param {Set<string>} nodeIds Canonical node ids to flash.
* @returns {void}
*/
function flashChangedNodes(nodeIds) {
if (!nodeIds || nodeIds.size === 0) return;
// Record that a flash round was triggered (VF2 gating — test hook).
liveFlashCount += 1;
lastFlashedNodeIds = [...nodeIds];
flashNodeTargets(nodeIds, { documentRef: document, markerByNodeId });
}
/**
* Flash each changed message's chat row(s) and its channel tab header (SPEC
* VF3). Called after the chat has rendered (so the rows + tabs exist and the
* messagetab map is populated). Targets already-rendered DOM only.
*
* @param {Set<string>} messageIds Message ids to flash.
* @returns {void}
*/
function flashChangedMessages(messageIds) {
if (!messageIds || messageIds.size === 0) return;
lastFlashedMessageIds = [...messageIds];
flashMessageTargets(messageIds, { documentRef: document, messageTabId });
}
/**
* Flag a collection dirty in response to an SSE change ping and arm the
* debounce timer so a burst of pings collapses into one delta fetch.
@@ -853,6 +895,9 @@ export function initializeApp(config) {
let traceLinesToggleButton = null;
let markersLayer = null;
let spiderLinesLayer = 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();
// Per-render record of the offset markers we created so the zoom event
// handlers can re-project them and keep the on-screen pixel gap constant
// regardless of zoom level. Each entry is
@@ -3258,6 +3303,9 @@ export function initializeApp(config) {
filterQuery = ''
}) {
if (!CHAT_ENABLED || !chatEl) return;
// Reset the message→tab map for this render; buildChatFragment repopulates it
// as it materialises each channel tab's entries (SPEC VF3 tab flash).
messageTabId = new Map();
const combinedMessages = Array.isArray(messages) ? [...messages] : [];
if (Array.isArray(encryptedMessages) && encryptedMessages.length > 0) {
combinedMessages.push(...encryptedMessages);
@@ -3414,6 +3462,14 @@ export function initializeApp(config) {
continue;
}
const node = chatEntryCache.materialize(namespace, keyOf(entry), parts.className, parts.html);
// Tag message rows so a live update can flash them (SPEC VF3); for channel
// tabs (namespace is the tab id, not 'log') record the message→tab id so
// the channel's tab header can flash too.
const messageId = entryMessageId(entry);
if (messageId) {
node.dataset.messageId = messageId;
if (namespace !== 'log') messageTabId.set(messageId, namespace);
}
const divider = getDivider(entry.ts);
if (divider) fragment.appendChild(divider);
fragment.appendChild(node);
@@ -3560,6 +3616,11 @@ export function initializeApp(config) {
const frag = document.createDocumentFragment();
for (const n of nodes) {
const tr = document.createElement('tr');
// Row-level node id hook for live-update flashes (SPEC VF3); kept distinct
// from the inner link's data-node-id so it never affects click handling.
if (typeof n.node_id === 'string' && n.node_id) {
tr.dataset.nodeRow = n.node_id;
}
const lastPositionTime = toFiniteNumber(n.position_time ?? n.positionTime);
const lastPositionCell = lastPositionTime != null ? timeAgo(lastPositionTime, nowSec) : '';
const latitudeDisplay = fmtCoords(n.latitude);
@@ -3837,6 +3898,9 @@ export function initializeApp(config) {
// handler can detect threshold crossings on the next zoom event.
lastRenderedZoomBucket = currentZoomBucket();
markersLayer.clearLayers();
// Reset the node→marker map for this render so live-update flashes target
// the current markers (SPEC VF3).
markerByNodeId = new Map();
const pts = [];
const nodesById = new Map();
for (const node of nodes) {
@@ -4151,6 +4215,10 @@ export function initializeApp(config) {
const fallbackOverlayProvider = () => mergeOverlayDetails(null, n);
let markerToken = 0;
marker.addTo(markersLayer);
// Remember this node's marker so a live update can flash it (SPEC VF3).
if (n && typeof n.node_id === 'string' && n.node_id) {
markerByNodeId.set(n.node_id, marker);
}
// Track every offset marker so the zoomend handler can reposition the
// marker + leader line in lock-step. Markers rendered at the shared
// centre (singletons / low-zoom overlap / collapsed-group fallback)
@@ -4416,9 +4484,6 @@ export function initializeApp(config) {
*/
async function refresh(refreshOptions = {}) {
try {
if (statusEl) {
statusEl.textContent = 'refreshing…';
}
// On the first load fetch the full dataset; subsequent refreshes pass
// the ``since`` timestamp so only new/changed rows are transferred.
// A 1-second overlap avoids missing rows that arrive at the boundary.
@@ -4574,6 +4639,14 @@ export function initializeApp(config) {
allTraces = Array.isArray(traceEntries) ? traceEntries : [];
initialFetchDone = true;
applyFilter();
// SPEC VF2/VF3/VF4: only an SSE-ping refresh flashes (refreshOptions.flash),
// and only after the table + map have rendered (applyFilter above), so the
// highlight lands on the final, placed element. useSince excludes the
// initial fill. A node/position/telemetry delta flashes the changed node.
if (refreshOptions.flash && useSince) {
flashChangedNodes(collectNodeIds(incomingNodes, incomingPositions, incomingTelemetry));
flashChangedMessages(collectMessageIds(incomingMessages, incomingEncryptedMessages));
}
// Persist the freshly-merged state for the next reload/revisit (SPEC FC2),
// throttled and fire-and-forget so it never blocks the paint.
writeBackCache();
@@ -4587,13 +4660,7 @@ export function initializeApp(config) {
backfillPromise = backfillChatHistory();
void backfillPromise;
}
if (statusEl) {
statusEl.textContent = 'updated ' + new Date().toLocaleTimeString();
}
} catch (e) {
if (statusEl) {
statusEl.textContent = 'error: ' + e.message;
}
console.error(e);
}
}
@@ -4608,11 +4675,6 @@ export function initializeApp(config) {
void initialLoadPromise;
restartAutoRefresh();
if (refreshBtn) {
// Manual refresh button bypasses the interval and runs a fetch right away.
refreshBtn.addEventListener('click', refresh);
}
// --- Auto-refresh play/pause toggle ---
if (autorefreshToggle) {
autorefreshToggle.addEventListener('click', () => {
@@ -4627,7 +4689,6 @@ export function initializeApp(config) {
autorefreshToggle.textContent = '\u25B6';
autorefreshToggle.setAttribute('aria-label', 'Resume auto-refresh');
autorefreshToggle.setAttribute('aria-pressed', 'true');
if (statusEl) statusEl.textContent = 'Refresh paused.';
} else {
autorefreshToggle.textContent = '\u23F8';
autorefreshToggle.setAttribute('aria-label', 'Pause auto-refresh');
@@ -4773,6 +4834,12 @@ export function initializeApp(config) {
isLiveActive: () => liveActive,
/** The auto-refresh cadence last armed, in ms (test hook). */
getAutoRefreshIntervalMs: () => autoRefreshIntervalMs,
/** Count of flash rounds triggered by SSE pings (VF2 gating; test hook). */
getLiveFlashCount: () => liveFlashCount,
/** Node ids flashed by the most recent SSE-ping refresh (test hook). */
getLastFlashedNodeIds: () => lastFlashedNodeIds,
/** Message ids flashed by the most recent SSE-ping refresh (test hook). */
getLastFlashedMessageIds: () => lastFlashedMessageIds,
/**
* Flush any pending debounced live refresh and await the latest
* live-driven refresh (test hook).
@@ -0,0 +1,68 @@
/*
* 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 { collectNodeIds, collectMessageIds, entryMessageId } from '../flash-targets.js';
test('collectNodeIds flattens and de-duplicates node_id across arrays', () => {
const nodes = [{ node_id: '!a' }, { node_id: '!b' }];
const positions = [{ node_id: '!b' }, { node_id: '!c' }];
const telemetry = [{ node_id: '!a' }];
const ids = collectNodeIds(nodes, positions, telemetry);
assert.deepEqual([...ids].sort(), ['!a', '!b', '!c']);
});
test('collectNodeIds ignores non-array inputs and rows without a string node_id', () => {
const ids = collectNodeIds(
null,
undefined,
'not-an-array',
[{ node_id: '!a' }, { node_id: 42 }, {}, null, { node_id: '' }],
);
assert.deepEqual([...ids], ['!a']);
});
test('collectNodeIds returns an empty set when nothing matches', () => {
assert.equal(collectNodeIds().size, 0);
assert.equal(collectNodeIds([], [{}]).size, 0);
});
test('collectMessageIds collects string ids from plaintext + encrypted deltas', () => {
const plain = [{ id: 1 }, { id: 2 }];
const encrypted = [{ id: 2 }, { id: 3 }];
assert.deepEqual([...collectMessageIds(plain, encrypted)].sort(), ['1', '2', '3']);
});
test('collectMessageIds ignores non-arrays and rows without an id', () => {
assert.deepEqual([...collectMessageIds(null, [{ id: '' }, {}, { id: 7 }])], ['7']);
assert.equal(collectMessageIds().size, 0);
});
test('collectMessageIds + entryMessageId honor message_id / messageId fallbacks', () => {
assert.deepEqual([...collectMessageIds([{ message_id: 5 }, { messageId: 6 }])], ['5', '6']);
assert.equal(entryMessageId({ item: { message_id: 7 } }), '7');
assert.equal(entryMessageId({ message: { messageId: 8 } }), '8');
});
test('entryMessageId reads a channel-tab item, a Log-tab message, or neither', () => {
assert.equal(entryMessageId({ item: { id: 5 } }), '5'); // channel tab
assert.equal(entryMessageId({ message: { id: 9 } }), '9'); // Log tab
assert.equal(entryMessageId({ type: 'position', nodeId: '!a' }), null); // non-message
assert.equal(entryMessageId(null), null);
assert.equal(entryMessageId({ item: { id: '' } }), null);
});
@@ -0,0 +1,221 @@
/*
* 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 {
FLASH_CLASS,
FLASH_DURATION_MS,
flashElement,
flashElements,
flashMarker,
flashNodeTargets,
flashMessageTargets,
} from '../flash.js';
/** A minimal element exposing a tracked classList. */
function fakeElement() {
const classes = new Set();
return {
classes,
classList: {
add: (c) => classes.add(c),
remove: (c) => classes.delete(c),
contains: (c) => classes.has(c),
},
};
}
test('FLASH_DURATION_MS is below 100ms (VF5)', () => {
assert.ok(FLASH_DURATION_MS < 100);
});
test('flashElement adds the class immediately, then removes it when the timer fires', () => {
const el = fakeElement();
let captured = null;
const schedule = (cb) => {
captured = cb;
};
assert.equal(flashElement(el, { schedule }), true);
assert.equal(el.classList.contains(FLASH_CLASS), true, 'class added on flash');
captured(); // fire the scheduled removal
assert.equal(el.classList.contains(FLASH_CLASS), false, 'class removed after duration');
});
test('flashElement restarts an in-flight flash (remove then re-add)', () => {
const el = fakeElement();
const ops = [];
el.classList.add = (c) => ops.push(`add:${c}`);
el.classList.remove = (c) => ops.push(`remove:${c}`);
flashElement(el, { schedule: () => {} });
// The class is cleared before being re-added so the CSS animation restarts.
assert.deepEqual(ops, [`remove:${FLASH_CLASS}`, `add:${FLASH_CLASS}`]);
});
test('flashElement passes the duration to the scheduler', () => {
const el = fakeElement();
let seenDelay = null;
flashElement(el, { duration: 42, schedule: (_cb, delay) => {
seenDelay = delay;
} });
assert.equal(seenDelay, 42);
});
test('flashElement uses the real timer by default', async () => {
const el = fakeElement();
flashElement(el, { duration: 1 }); // default schedule = setTimeout
assert.equal(el.classList.contains(FLASH_CLASS), true);
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(el.classList.contains(FLASH_CLASS), false, 'real timer removed the class');
});
test('flashElement is a safe no-op for missing or non-element arguments', () => {
assert.equal(flashElement(null), false);
assert.equal(flashElement(undefined), false);
assert.equal(flashElement({}), false); // no classList
assert.equal(flashElement({ classList: {} }), false); // classList without add()
});
test('flashElements flashes each valid element and counts them', () => {
const a = fakeElement();
const b = fakeElement();
const count = flashElements([a, null, b, {}], { schedule: () => {} });
assert.equal(count, 2);
assert.equal(a.classList.contains(FLASH_CLASS), true);
assert.equal(b.classList.contains(FLASH_CLASS), true);
});
test('flashElements returns 0 for nullish or non-iterable input', () => {
assert.equal(flashElements(null), 0);
assert.equal(flashElements(undefined), 0);
assert.equal(flashElements(123), 0);
});
test('flashMarker overrides the marker fill to white, then restores it', () => {
const styles = [];
const marker = {
options: { fillColor: '#abcdef', fillOpacity: 0.7 },
setStyle: (s) => styles.push(s),
};
let captured = null;
assert.equal(flashMarker(marker, { schedule: (cb) => {
captured = cb;
} }), true);
assert.deepEqual(styles[0], { fillColor: '#ffffff', fillOpacity: 1 });
captured();
assert.deepEqual(styles[1], { fillColor: '#abcdef', fillOpacity: 0.7 });
});
test('flashMarker tolerates a marker without options', () => {
const styles = [];
const marker = { setStyle: (s) => styles.push(s) };
assert.equal(flashMarker(marker, { schedule: (cb) => cb() }), true);
assert.deepEqual(styles[1], { fillColor: undefined, fillOpacity: undefined });
});
test('flashMarker uses the real timer by default', async () => {
const styles = [];
const marker = { options: { fillColor: '#111', fillOpacity: 0.5 }, setStyle: (s) => styles.push(s) };
flashMarker(marker, { duration: 1 }); // default schedule = setTimeout
assert.deepEqual(styles[0], { fillColor: '#ffffff', fillOpacity: 1 });
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(styles[1], { fillColor: '#111', fillOpacity: 0.5 });
});
test('flashMarker is a no-op without setStyle', () => {
assert.equal(flashMarker(null), false);
assert.equal(flashMarker({}), false);
});
test('flashNodeTargets flashes each node row and marker', () => {
const rowA = fakeElement();
let markerFlashed = false;
const markerA = {
options: { fillColor: '#123', fillOpacity: 0.7 },
setStyle: () => {
markerFlashed = true;
},
};
const documentRef = { querySelectorAll: (sel) => (sel.includes('"!a"') ? [rowA] : []) };
const markerByNodeId = new Map([['!a', markerA]]);
const count = flashNodeTargets(['!a'], {
documentRef,
markerByNodeId,
flashOptions: { schedule: () => {} },
});
assert.equal(rowA.classList.contains(FLASH_CLASS), true, 'row flashed');
assert.equal(markerFlashed, true, 'marker flashed');
assert.equal(count, 2); // one row + one marker
});
test('flashNodeTargets skips a missing document and unmapped markers', () => {
// No documentRef → no row flash; marker not in the map → no marker flash.
assert.equal(flashNodeTargets(['!x'], { markerByNodeId: new Map() }), 0);
// documentRef present but no matching row; no markerByNodeId at all.
assert.equal(flashNodeTargets(['!x'], { documentRef: { querySelectorAll: () => [] } }), 0);
});
test('flashNodeTargets returns 0 for nullish or non-iterable ids', () => {
assert.equal(flashNodeTargets(null), 0);
assert.equal(flashNodeTargets(123), 0);
});
test('flashMessageTargets flashes message rows and each channel tab header once', () => {
// Two messages on the same tab → the tab header is flashed only once.
const rows = { 1: [fakeElement()], 2: [fakeElement()] };
const tab = fakeElement();
const documentRef = {
querySelectorAll: (sel) => {
const msg = sel.match(/data-message-id="([^"]+)"/);
if (msg) return rows[msg[1]] || [];
return sel.includes('data-tab-id="t-pub"') ? [tab] : [];
},
};
const messageTabId = new Map([['1', 't-pub'], ['2', 't-pub']]);
const count = flashMessageTargets(['1', '2'], {
documentRef,
messageTabId,
flashOptions: { schedule: () => {} },
});
assert.equal(rows[1][0].classList.contains(FLASH_CLASS), true, 'row 1 flashed');
assert.equal(rows[2][0].classList.contains(FLASH_CLASS), true, 'row 2 flashed');
assert.equal(tab.classList.contains(FLASH_CLASS), true, 'tab header flashed');
assert.equal(count, 3); // two rows + one tab (deduped)
});
test('flashMessageTargets skips tab flashing without a tab map or document', () => {
// No messageTabId → no tab flash; rows still attempted via the document.
const row = fakeElement();
const documentRef = { querySelectorAll: (sel) => (sel.includes('"1"') ? [row] : []) };
assert.equal(flashMessageTargets(['1'], { documentRef, flashOptions: { schedule: () => {} } }), 1);
// No documentRef at all → nothing queried, but a mapped tab is still collected harmlessly.
assert.equal(flashMessageTargets(['1'], { messageTabId: new Map([['1', 't']]) }), 0);
});
test('flashMessageTargets returns 0 for nullish or non-iterable ids', () => {
assert.equal(flashMessageTargets(null), 0);
assert.equal(flashMessageTargets(undefined), 0);
assert.equal(flashMessageTargets(123), 0);
});
@@ -0,0 +1,82 @@
/*
* 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.
*/
/**
* Pure helpers that derive *what to flash* from the rows an SSE-ping refresh
* fetched (SPEC VF3). Kept free of DOM/Leaflet so they unit-test trivially; the
* actual highlighting lives in {@link module:main/flash}.
*
* @module main/flash-targets
*/
/**
* Collect the canonical node ids touched by one or more delta row arrays.
*
* Each `nodes` / `positions` / `telemetry` row carries a `node_id`; this
* flattens them into a de-duplicated set so the caller can flash each affected
* node's table row and map marker exactly once. Non-array inputs and rows
* without a string `node_id` are ignored.
*
* @param {...(Array<Object>|*)} rowArrays One or more delta row arrays.
* @returns {Set<string>} de-duplicated canonical node ids.
*/
export function collectNodeIds(...rowArrays) {
const ids = new Set();
for (const rows of rowArrays) {
if (!Array.isArray(rows)) continue;
for (const row of rows) {
const id = row && typeof row.node_id === 'string' ? row.node_id : null;
if (id) ids.add(id);
}
}
return ids;
}
/**
* Collect message ids touched by one or more delta row arrays (the plaintext
* and encrypted message deltas), as strings so they match `data-message-id`.
*
* @param {...(Array<Object>|*)} rowArrays One or more message delta arrays.
* @returns {Set<string>} de-duplicated message ids.
*/
export function collectMessageIds(...rowArrays) {
const ids = new Set();
for (const rows of rowArrays) {
if (!Array.isArray(rows)) continue;
for (const row of rows) {
const id = row && (row.id ?? row.message_id ?? row.messageId);
if (id != null && id !== '') ids.add(String(id));
}
}
return ids;
}
/**
* Derive the message id carried by a rendered chat-log entry, as a string.
*
* Channel-tab entries wrap the message as `entry.item`; Log-tab message entries
* carry it as `entry.message`. Non-message entries (node/position/telemetry/)
* have neither and yield null, so only message rows get tagged for flashing.
*
* @param {?Object} entry A chat-render entry.
* @returns {?string} the message id, or null when the entry is not a message.
*/
export function entryMessageId(entry) {
const message = entry && (entry.item || entry.message);
if (!message || typeof message !== 'object') return null;
const id = message.id ?? message.message_id ?? message.messageId;
return id != null && id !== '' ? String(id) : null;
}
+176
View File
@@ -0,0 +1,176 @@
/*
* 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.
*/
/**
* 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
* `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
* highlight so a subsequent update can re-trigger it.
*
* @module main/flash
*/
/** 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;
/**
* Default removal scheduler (real timer). Injectable so tests stay deterministic.
*
* @param {Function} callback Removal callback.
* @param {number} delay Delay in ms.
* @returns {*} The timer handle.
*/
function defaultSchedule(callback, delay) {
return setTimeout(callback, delay);
}
/**
* Flash a single element white.
*
* Removes then re-adds {@link FLASH_CLASS} so the animation restarts even when a
* previous flash is still mid-flight, then schedules class removal so a future
* update can flash the same element again. A missing or non-element argument is
* a safe no-op.
*
* @param {?Element} element Target element (must expose `classList`).
* @param {{ duration?: number, schedule?: Function }} [options] Overrides for
* the highlight lifetime and the removal scheduler (tests inject `schedule`).
* @returns {boolean} true when the element was flashed; false when skipped.
*/
export function flashElement(element, options = {}) {
if (!element || !element.classList || typeof element.classList.add !== 'function') {
return false;
}
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.
element.classList.remove(FLASH_CLASS);
element.classList.add(FLASH_CLASS);
schedule(() => element.classList.remove(FLASH_CLASS), duration);
return true;
}
/**
* Flash a Leaflet vector marker (e.g. a node's `circleMarker`) white.
*
* SVG markers can't use the `.live-flash` box-shadow, so this briefly overrides
* the marker's fill to white via Leaflet's `setStyle`, then restores the
* original fill after {@link FLASH_DURATION_MS}. A marker without `setStyle` is
* a safe no-op.
*
* @param {?{setStyle: Function, options?: Object}} marker Leaflet vector marker.
* @param {{ duration?: number, schedule?: Function }} [options] See {@link flashElement}.
* @returns {boolean} true when the marker was flashed; false when skipped.
*/
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 schedule = typeof options.schedule === 'function' ? options.schedule : defaultSchedule;
const current = marker.options || {};
const original = { fillColor: current.fillColor, fillOpacity: current.fillOpacity };
marker.setStyle({ fillColor: '#ffffff', fillOpacity: 1 });
schedule(() => marker.setStyle(original), duration);
return true;
}
/**
* 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).
*
* DOM and marker lookups are injected so this is unit-testable without a real
* document or Leaflet. Each is optional a caller on a page without the node
* table (or without a map) simply passes one of them.
*
* @param {?Iterable<string>} nodeIds Canonical node ids to flash.
* @param {Object} [options] Lookups + flash overrides.
* @param {?{querySelectorAll: Function}} [options.documentRef] Document to query rows in.
* @param {?{get: Function}} [options.markerByNodeId] Map of node id Leaflet marker.
* @param {{ duration?: number, schedule?: Function }} [options.flashOptions] Passed to the flash primitives.
* @returns {number} count of row + marker targets flashed.
*/
export function flashNodeTargets(nodeIds, options = {}) {
if (!nodeIds || typeof nodeIds[Symbol.iterator] !== 'function') return 0;
const { documentRef = null, markerByNodeId = null, flashOptions = {} } = options;
let count = 0;
for (const id of nodeIds) {
if (documentRef && typeof documentRef.querySelectorAll === 'function') {
count += flashElements(documentRef.querySelectorAll(`[data-node-row="${id}"]`), flashOptions);
}
const marker = markerByNodeId && typeof markerByNodeId.get === 'function'
? markerByNodeId.get(id)
: null;
if (marker && flashMarker(marker, flashOptions)) count += 1;
}
return count;
}
/**
* Flash every UI target for a set of changed messages: each message's chat
* row(s) (`[data-message-id="<id>"]`, present in the Log tab and the channel
* tab) and the header of each affected channel tab (`[data-tab-id="<id>"]`)
* once (SPEC VF3).
*
* @param {?Iterable<string>} messageIds Message ids that changed.
* @param {Object} [options] Lookups + flash overrides.
* @param {?{querySelectorAll: Function}} [options.documentRef] Document to query in.
* @param {?{get: Function}} [options.messageTabId] Map of message id channel tab id.
* @param {{ duration?: number, schedule?: Function }} [options.flashOptions] Passed to the flash primitives.
* @returns {number} count of row + tab-header targets flashed.
*/
export function flashMessageTargets(messageIds, options = {}) {
if (!messageIds || typeof messageIds[Symbol.iterator] !== 'function') return 0;
const { documentRef = null, messageTabId = null, flashOptions = {} } = options;
const canQuery = Boolean(documentRef) && typeof documentRef.querySelectorAll === 'function';
let count = 0;
const tabIds = new Set();
for (const id of messageIds) {
if (canQuery) {
count += flashElements(documentRef.querySelectorAll(`[data-message-id="${id}"]`), flashOptions);
}
const tabId = messageTabId && typeof messageTabId.get === 'function' ? messageTabId.get(id) : null;
if (tabId) tabIds.add(tabId);
}
// Flash each affected channel tab header exactly once.
if (canQuery) {
for (const tabId of tabIds) {
count += flashElements(documentRef.querySelectorAll(`[data-tab-id="${tabId}"]`), flashOptions);
}
}
return count;
}
/**
* Flash several elements, skipping any that are missing/invalid.
*
* @param {?Iterable<Element>} elements Elements to flash.
* @param {{ duration?: number, schedule?: Function }} [options] See {@link flashElement}.
* @returns {number} count of elements actually flashed.
*/
export function flashElements(elements, options = {}) {
if (!elements || typeof elements[Symbol.iterator] !== 'function') return 0;
let count = 0;
for (const element of elements) {
if (flashElement(element, options)) count += 1;
}
return count;
}
+20 -6
View File
@@ -1594,12 +1594,6 @@ body.dark .node-detail__chart-grid-line {
position: relative;
}
.refresh-timestamp {
font-size: 0.85em;
color: var(--muted);
white-space: nowrap;
}
.filter-input input[type="text"] {
flex: 1 1 auto;
width: 100%;
@@ -2559,3 +2553,23 @@ body.dark #map .leaflet-tile.map-tiles {
.markdown-body h1 { font-size: 1.3em; }
.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-flash {
animation: live-flash-highlight 90ms ease-out;
}
/* Honor reduced-motion: data still updates live, only the flash is withheld. */
@media (prefers-reduced-motion: reduce) {
.live-flash {
animation: none;
}
}
+21 -6
View File
@@ -1371,6 +1371,18 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
end
it "does not render the Refresh button or last-updated field" do
get "/"
expect(last_response).to be_ok
# Live SSE updates replace manual refresh + a timestamp (VF1).
expect(last_response.body).not_to include('id="refreshBtn"')
expect(last_response.body).not_to include('id="status"')
expect(last_response.body).not_to include('class="refresh-timestamp"')
# The play/pause toggle is kept.
expect(last_response.body).to include('id="autorefreshToggle"')
end
it "includes the application version in the footer" do
get "/"
expected = APP_VERSION.to_s.start_with?("v") ? APP_VERSION : "v#{APP_VERSION}"
@@ -1452,8 +1464,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('id="map"')
expect(last_response.body).to include('id="filterInput"')
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).to include('id="refreshBtn"')
expect(last_response.body).to include('id="status"')
expect(last_response.body).not_to include('id="refreshBtn"')
expect(last_response.body).not_to include('id="status"')
expect(last_response.body).to include('id="autorefreshToggle"')
expect(last_response.body).not_to include('id="fitBounds"')
expect(last_response.body).not_to include('<footer class="app-footer">')
end
@@ -1509,8 +1522,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('class="chat-panel chat-panel--full"')
expect(last_response.body).to include('id="filterInput"')
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).to include('id="refreshBtn"')
expect(last_response.body).to include('id="status"')
expect(last_response.body).not_to include('id="refreshBtn"')
expect(last_response.body).not_to include('id="status"')
expect(last_response.body).to include('id="autorefreshToggle"')
expect(last_response.body).not_to include('<footer class="app-footer">')
end
@@ -1533,8 +1547,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('id="nodes"')
expect(last_response.body).to include('id="filterInput"')
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).to include('id="refreshBtn"')
expect(last_response.body).to include('id="status"')
expect(last_response.body).not_to include('id="refreshBtn"')
expect(last_response.body).not_to include('id="status"')
expect(last_response.body).to include('id="autorefreshToggle"')
expect(last_response.body).not_to include('<footer class="app-footer">')
end
end
+16 -4
View File
@@ -225,9 +225,19 @@ 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
expect(last_response.status).to eq(201)
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "positions", hint: nil }])
end
it "publishes nodes on a message ingest (#822 touches the author node)" do
allow(PotatoMesh::App::PubSub).to receive(:publish).and_call_original
post "/api/messages", "[]", auth
expect(last_response.status).to eq(201)
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "messages", hint: nil }])
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("messages", private_mode: false)
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("nodes", private_mode: false)
end
it "publishes on every ingest route" do
@@ -239,19 +249,21 @@ RSpec.describe PotatoMesh::App::PubSub do
end
ingest_routes.each_key do |collection|
# `nodes` is published by both the nodes route and the messages route
# (#822), so assert at-least-once rather than exactly-once.
expect(PotatoMesh::App::PubSub).to have_received(:publish)
.with(collection, private_mode: false)
.with(collection, private_mode: false).at_least(:once)
end
end
it "coalesces bursts of one collection into a single pending event" do
subscriber = PotatoMesh::App::PubSub.subscribe
5.times do
post "/api/messages", "[]", auth
post "/api/positions", "[]", 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: "messages", hint: nil }])
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "positions", hint: nil }])
end
end
end
-2
View File
@@ -211,9 +211,7 @@
<img src="/assets/img/meshtastic.svg" alt="" width="20" height="20" aria-hidden="true" class="protocol-toggle-icon" loading="lazy" decoding="async" />
</button>
<span id="footerActiveNodes" class="meta-active-nodes"></span>
<button id="refreshBtn" type="button" title="Fetch latest data now">Refresh</button>
<button id="autorefreshToggle" type="button" aria-label="Pause auto-refresh" aria-pressed="false" title="Pause or resume automatic data refresh">⏸</button>
<span id="status" class="refresh-timestamp" aria-live="polite"></span>
</div>
<% end %>