From 3cfa0db7e6bf17bfc51013731b7136fb3310dc51 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:14:16 +0200 Subject: [PATCH] web: distinguish meshcore from meshtastic in frontend (#688) * web: distinguish meshcore from meshtastic in frontend * fix mark_packet_seen bug * web: distinguish meshcore from meshtastic in frontend * address review comments * address review comments * address review comments --- data/mesh_ingestor/handlers/__init__.py | 2 + data/mesh_ingestor/providers/meshcore.py | 2 + tests/test_handlers_unit.py | 7 + tests/test_provider_unit.py | 12 + .../js/app/__tests__/main-filter.test.js | 534 ++++++++++++++++++ .../js/app/__tests__/main-protocol.test.js | 27 +- .../js/app/__tests__/node-details.test.js | 26 + .../app/__tests__/node-link-helpers.test.js | 15 +- .../assets/js/app/__tests__/node-page.test.js | 19 +- .../js/app/__tests__/node-rendering.test.js | 30 +- .../js/app/__tests__/protocol-helpers.test.js | 51 +- .../js/app/__tests__/role-helpers.test.js | 78 +++ web/public/assets/js/app/main.js | 343 ++++++++--- web/public/assets/js/app/node-details.js | 1 + web/public/assets/js/app/node-rendering.js | 6 +- web/public/assets/js/app/protocol-helpers.js | 22 +- web/public/assets/js/app/role-helpers.js | 104 +++- web/public/assets/styles/base.css | 52 +- web/views/shared/_nodes_table.erb | 1 + 19 files changed, 1163 insertions(+), 169 deletions(-) create mode 100644 web/public/assets/js/app/__tests__/main-filter.test.js diff --git a/data/mesh_ingestor/handlers/__init__.py b/data/mesh_ingestor/handlers/__init__.py index 820b1d5..9cae0ca 100644 --- a/data/mesh_ingestor/handlers/__init__.py +++ b/data/mesh_ingestor/handlers/__init__.py @@ -34,6 +34,7 @@ from __future__ import annotations from .. import queue as _queue from ._state import ( + _mark_packet_seen, host_node_id, last_packet_monotonic, register_host_node_id, @@ -85,6 +86,7 @@ __all__ = [ "_radio_metadata_fields", "_record_ignored_packet", "base64_payload", + "_mark_packet_seen", "host_node_id", "last_packet_monotonic", "on_receive", diff --git a/data/mesh_ingestor/providers/meshcore.py b/data/mesh_ingestor/providers/meshcore.py index 0df3d2a..86dc515 100644 --- a/data/mesh_ingestor/providers/meshcore.py +++ b/data/mesh_ingestor/providers/meshcore.py @@ -219,6 +219,7 @@ def _contact_to_node_dict(contact: dict) -> dict: role = _meshcore_adv_type_to_role(contact.get("type")) node: dict = { "lastHeard": contact.get("last_advert"), + "protocol": "meshcore", "user": { "longName": name, "shortName": _meshcore_short_name(pub_key), @@ -249,6 +250,7 @@ def _self_info_to_node_dict(self_info: dict) -> dict: role = _meshcore_adv_type_to_role(self_info.get("adv_type")) node: dict = { "lastHeard": int(time.time()), + "protocol": "meshcore", "user": { "longName": name, "shortName": _meshcore_short_name(pub_key), diff --git a/tests/test_handlers_unit.py b/tests/test_handlers_unit.py index 73739b5..2ab6f67 100644 --- a/tests/test_handlers_unit.py +++ b/tests/test_handlers_unit.py @@ -100,6 +100,13 @@ class TestLastPacketMonotonic: assert ts is not None assert isinstance(ts, float) + def test_mark_packet_seen_exported_from_handlers(self): + """handlers._mark_packet_seen must be accessible via the package.""" + assert callable(handlers._mark_packet_seen) + handlers._mark_packet_seen() + ts = handlers.last_packet_monotonic() + assert ts is not None + # --------------------------------------------------------------------------- # _state: _host_telemetry_suppressed diff --git a/tests/test_provider_unit.py b/tests/test_provider_unit.py index 6e32925..a57a875 100644 --- a/tests/test_provider_unit.py +++ b/tests/test_provider_unit.py @@ -668,6 +668,12 @@ def test_contact_to_node_dict_omits_position_at_origin(): assert "position" not in node +def test_contact_to_node_dict_sets_protocol_meshcore(): + """_contact_to_node_dict must set protocol='meshcore' on every node dict.""" + contact = {"public_key": "aa" * 32, "adv_name": "Node"} + assert _contact_to_node_dict(contact)["protocol"] == "meshcore" + + # --------------------------------------------------------------------------- # _self_info_to_node_dict # --------------------------------------------------------------------------- @@ -709,6 +715,12 @@ def test_self_info_to_node_dict_includes_position(): assert node["position"]["longitude"] == pytest.approx(2.35) +def test_self_info_to_node_dict_sets_protocol_meshcore(): + """_self_info_to_node_dict must set protocol='meshcore' on the node dict.""" + self_info = {"name": "MyNode", "public_key": "bb" * 32} + assert _self_info_to_node_dict(self_info)["protocol"] == "meshcore" + + # --------------------------------------------------------------------------- # _MeshcoreInterface contact management # --------------------------------------------------------------------------- diff --git a/web/public/assets/js/app/__tests__/main-filter.test.js b/web/public/assets/js/app/__tests__/main-filter.test.js new file mode 100644 index 0000000..714f647 --- /dev/null +++ b/web/public/assets/js/app/__tests__/main-filter.test.js @@ -0,0 +1,534 @@ +/* + * 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 { createDomEnvironment } from './dom-environment.js'; +import { initializeApp } from '../main.js'; + +const MINIMAL_CONFIG = Object.freeze({ + channel: 'Primary', + frequency: '915MHz', + refreshMs: 0, + refreshIntervalSeconds: 30, + chatEnabled: true, + mapCenter: { lat: 0, lon: 0 }, + mapZoom: null, + maxDistanceKm: 0, + tileFilters: { light: '', dark: '' }, + instancesFeatureEnabled: false, + instanceDomain: null, + snapshotWindowSeconds: 3600, +}); + +/** + * Spin up a minimal app and return test utilities with a cleanup handle. + * + * @returns {{ testUtils: Object, cleanup: Function }} + */ +function setupApp() { + const env = createDomEnvironment({ includeBody: true }); + env.createElement('button', 'themeToggle'); + const { _testUtils } = initializeApp(MINIMAL_CONFIG); + return { testUtils: _testUtils, cleanup: env.cleanup.bind(env) }; +} + +/** + * Run a test body with a fresh app instance, ensuring cleanup regardless of outcome. + * + * @param {function(Object): void} fn Receives the _testUtils object. + */ +function withApp(fn) { + const { testUtils, cleanup } = setupApp(); + try { + fn(testUtils); + } finally { + cleanup(); + } +} + +// --------------------------------------------------------------------------- +// makeRoleFilterKey +// --------------------------------------------------------------------------- + +test('makeRoleFilterKey produces compound key for meshtastic protocol', () => { + withApp((t) => { + assert.equal(t.makeRoleFilterKey('SENSOR', 'meshtastic'), 'meshtastic:SENSOR'); + assert.equal(t.makeRoleFilterKey('ROUTER', 'meshtastic'), 'meshtastic:ROUTER'); + }); +}); + +test('makeRoleFilterKey produces compound key for meshcore protocol', () => { + withApp((t) => { + assert.equal(t.makeRoleFilterKey('SENSOR', 'meshcore'), 'meshcore:SENSOR'); + assert.equal(t.makeRoleFilterKey('REPEATER', 'meshcore'), 'meshcore:REPEATER'); + }); +}); + +test('makeRoleFilterKey defaults null protocol to meshtastic bucket', () => { + withApp((t) => { + assert.equal(t.makeRoleFilterKey('SENSOR', null), 'meshtastic:SENSOR'); + assert.equal(t.makeRoleFilterKey('ROUTER', null), 'meshtastic:ROUTER'); + }); +}); + +test('makeRoleFilterKey defaults absent protocol to meshtastic bucket', () => { + withApp((t) => { + assert.equal(t.makeRoleFilterKey('CLIENT', undefined), 'meshtastic:CLIENT'); + }); +}); + +test('makeRoleFilterKey SENSOR and REPEATER produce distinct keys across protocols', () => { + withApp((t) => { + const meshtasticSensor = t.makeRoleFilterKey('SENSOR', 'meshtastic'); + const meshcoreSensor = t.makeRoleFilterKey('SENSOR', 'meshcore'); + assert.notEqual(meshtasticSensor, meshcoreSensor); + + const meshtasticRepeater = t.makeRoleFilterKey('REPEATER', 'meshtastic'); + const meshcoreRepeater = t.makeRoleFilterKey('REPEATER', 'meshcore'); + assert.notEqual(meshtasticRepeater, meshcoreRepeater); + }); +}); + +// --------------------------------------------------------------------------- +// matchesRoleFilter — no active filters +// --------------------------------------------------------------------------- + +test('matchesRoleFilter returns true when no filters are active', () => { + withApp((t) => { + t.activeRoleFilters.clear(); + assert.equal(t.matchesRoleFilter({ role: 'ROUTER', protocol: 'meshtastic' }), true); + assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshcore' }), true); + }); +}); + +// --------------------------------------------------------------------------- +// matchesRoleFilter — protocol-aware compound key matching +// --------------------------------------------------------------------------- + +test('matchesRoleFilter matches meshtastic SENSOR filter for meshtastic node', () => { + withApp((t) => { + t.activeRoleFilters.clear(); + t.activeRoleFilters.add('meshtastic:SENSOR'); + assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshtastic' }), true); + }); +}); + +test('matchesRoleFilter does not match meshtastic SENSOR filter for meshcore node', () => { + withApp((t) => { + t.activeRoleFilters.clear(); + t.activeRoleFilters.add('meshtastic:SENSOR'); + assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshcore' }), false); + }); +}); + +test('matchesRoleFilter matches meshcore SENSOR filter for meshcore node', () => { + withApp((t) => { + t.activeRoleFilters.clear(); + t.activeRoleFilters.add('meshcore:SENSOR'); + assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshcore' }), true); + }); +}); + +test('matchesRoleFilter does not match meshcore SENSOR filter for meshtastic node', () => { + withApp((t) => { + t.activeRoleFilters.clear(); + t.activeRoleFilters.add('meshcore:SENSOR'); + assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshtastic' }), false); + }); +}); + +test('matchesRoleFilter matches meshtastic REPEATER filter for meshtastic node', () => { + withApp((t) => { + t.activeRoleFilters.clear(); + t.activeRoleFilters.add('meshtastic:REPEATER'); + assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshtastic' }), true); + assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshcore' }), false); + }); +}); + +test('matchesRoleFilter matches meshcore REPEATER filter for meshcore node', () => { + withApp((t) => { + t.activeRoleFilters.clear(); + t.activeRoleFilters.add('meshcore:REPEATER'); + assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshcore' }), true); + assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshtastic' }), false); + }); +}); + +// --------------------------------------------------------------------------- +// matchesRoleFilter — null/absent protocol treated as meshtastic +// --------------------------------------------------------------------------- + +test('matchesRoleFilter treats null protocol as meshtastic for filter matching', () => { + withApp((t) => { + t.activeRoleFilters.clear(); + t.activeRoleFilters.add('meshtastic:SENSOR'); + // null-protocol node should match the meshtastic SENSOR filter + assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: null }), true); + // but not the meshcore one + t.activeRoleFilters.clear(); + t.activeRoleFilters.add('meshcore:SENSOR'); + assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: null }), false); + }); +}); + +test('matchesRoleFilter with multiple active filters returns true when any matches', () => { + withApp((t) => { + t.activeRoleFilters.clear(); + t.activeRoleFilters.add('meshtastic:SENSOR'); + t.activeRoleFilters.add('meshcore:REPEATER'); + assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshtastic' }), true); + assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshcore' }), true); + assert.equal(t.matchesRoleFilter({ role: 'ROUTER', protocol: 'meshtastic' }), false); + }); +}); + +// --------------------------------------------------------------------------- +// matchesProtocolFilter +// --------------------------------------------------------------------------- + +test('matchesProtocolFilter returns true when no protocols are hidden', () => { + withApp((t) => { + t.hiddenProtocols.clear(); + assert.equal(t.matchesProtocolFilter({ protocol: 'meshtastic' }), true); + assert.equal(t.matchesProtocolFilter({ protocol: 'meshcore' }), true); + assert.equal(t.matchesProtocolFilter({ protocol: null }), true); + }); +}); + +test('matchesProtocolFilter hides meshtastic nodes when meshtastic is hidden', () => { + withApp((t) => { + t.hiddenProtocols.clear(); + t.hiddenProtocols.add('meshtastic'); + assert.equal(t.matchesProtocolFilter({ protocol: 'meshtastic' }), false); + assert.equal(t.matchesProtocolFilter({ protocol: 'meshcore' }), true); + }); +}); + +test('matchesProtocolFilter hides meshcore nodes when meshcore is hidden', () => { + withApp((t) => { + t.hiddenProtocols.clear(); + t.hiddenProtocols.add('meshcore'); + assert.equal(t.matchesProtocolFilter({ protocol: 'meshcore' }), false); + assert.equal(t.matchesProtocolFilter({ protocol: 'meshtastic' }), true); + }); +}); + +test('matchesProtocolFilter always shows null-protocol nodes even when meshtastic is hidden', () => { + withApp((t) => { + t.hiddenProtocols.clear(); + t.hiddenProtocols.add('meshtastic'); + // null/absent protocol nodes are NOT hidden — they predate the protocol field + assert.equal(t.matchesProtocolFilter({ protocol: null }), true); + assert.equal(t.matchesProtocolFilter({}), true); + }); +}); + +// --------------------------------------------------------------------------- +// Role filter key independence across protocols +// --------------------------------------------------------------------------- + +test('SENSOR filter keys for meshtastic and meshcore are distinct strings', () => { + withApp((t) => { + const m = t.makeRoleFilterKey('SENSOR', 'meshtastic'); + const mc = t.makeRoleFilterKey('SENSOR', 'meshcore'); + // They must be different keys so they can live independently in the Set + assert.notEqual(m, mc); + // Adding one to the filter set must not affect the other + t.activeRoleFilters.clear(); + t.activeRoleFilters.add(m); + assert.equal(t.activeRoleFilters.has(m), true); + assert.equal(t.activeRoleFilters.has(mc), false); + }); +}); + +test('REPEATER filter keys for meshtastic and meshcore are distinct strings', () => { + withApp((t) => { + const m = t.makeRoleFilterKey('REPEATER', 'meshtastic'); + const mc = t.makeRoleFilterKey('REPEATER', 'meshcore'); + assert.notEqual(m, mc); + t.activeRoleFilters.clear(); + t.activeRoleFilters.add(mc); + assert.equal(t.activeRoleFilters.has(mc), true); + assert.equal(t.activeRoleFilters.has(m), false); + }); +}); + +// --------------------------------------------------------------------------- +// normalizeFilterProtocol +// --------------------------------------------------------------------------- + +test('normalizeFilterProtocol returns meshcore for explicit meshcore', () => { + withApp((t) => { + assert.equal(t.normalizeFilterProtocol('meshcore'), 'meshcore'); + }); +}); + +test('normalizeFilterProtocol returns meshtastic for explicit meshtastic', () => { + withApp((t) => { + assert.equal(t.normalizeFilterProtocol('meshtastic'), 'meshtastic'); + }); +}); + +test('normalizeFilterProtocol returns meshtastic for null', () => { + withApp((t) => { + assert.equal(t.normalizeFilterProtocol(null), 'meshtastic'); + }); +}); + +test('normalizeFilterProtocol returns meshtastic for undefined', () => { + withApp((t) => { + assert.equal(t.normalizeFilterProtocol(undefined), 'meshtastic'); + }); +}); + +test('normalizeFilterProtocol returns meshtastic for unknown protocol', () => { + withApp((t) => { + assert.equal(t.normalizeFilterProtocol('reticulum'), 'meshtastic'); + }); +}); + +// --------------------------------------------------------------------------- +// buildProtocolIconImg / buildMeshtasticIconImg / buildMeshcoreIconImg +// --------------------------------------------------------------------------- + +test('buildProtocolIconImg returns an img element with the correct src and class', () => { + withApp((t) => { + const img = t.buildProtocolIconImg('/assets/img/test.svg', 'protocol-icon--test'); + assert.equal(img.tagName.toLowerCase(), 'img'); + assert.equal(img.getAttribute('src'), '/assets/img/test.svg'); + assert.ok(img.className.includes('protocol-icon')); + assert.ok(img.className.includes('protocol-icon--test')); + assert.equal(img.getAttribute('aria-hidden'), 'true'); + assert.equal(img.getAttribute('alt'), ''); + assert.equal(img.getAttribute('width'), '12'); + assert.equal(img.getAttribute('height'), '12'); + }); +}); + +test('buildMeshtasticIconImg references meshtastic.svg and carries the meshtastic class', () => { + withApp((t) => { + const img = t.buildMeshtasticIconImg(); + assert.ok(img.getAttribute('src').includes('meshtastic.svg')); + assert.ok(img.className.includes('protocol-icon--meshtastic')); + assert.equal(img.getAttribute('aria-hidden'), 'true'); + }); +}); + +test('buildMeshcoreIconImg references meshcore.svg and carries the meshcore class', () => { + withApp((t) => { + const img = t.buildMeshcoreIconImg(); + assert.ok(img.getAttribute('src').includes('meshcore.svg')); + assert.ok(img.className.includes('protocol-icon--meshcore')); + assert.equal(img.getAttribute('aria-hidden'), 'true'); + }); +}); + +test('buildMeshtasticIconImg and buildMeshcoreIconImg return different src values', () => { + withApp((t) => { + const mt = t.buildMeshtasticIconImg(); + const mc = t.buildMeshcoreIconImg(); + assert.notEqual(mt.getAttribute('src'), mc.getAttribute('src')); + }); +}); + +// --------------------------------------------------------------------------- +// legendClickHandler +// --------------------------------------------------------------------------- + +test('legendClickHandler calls preventDefault and stopPropagation before fn', () => { + withApp((t) => { + let fnCalled = false; + let preventDefaultCalled = false; + let stopPropagationCalled = false; + const handler = t.legendClickHandler(() => { fnCalled = true; }); + const fakeEvent = { + preventDefault: () => { preventDefaultCalled = true; }, + stopPropagation: () => { stopPropagationCalled = true; }, + }; + handler(fakeEvent); + assert.equal(preventDefaultCalled, true); + assert.equal(stopPropagationCalled, true); + assert.equal(fnCalled, true); + }); +}); + +test('legendClickHandler passes the event to fn', () => { + withApp((t) => { + let received = null; + const handler = t.legendClickHandler(ev => { received = ev; }); + const fakeEvent = { + preventDefault: () => {}, + stopPropagation: () => {}, + detail: 'test', + }; + handler(fakeEvent); + assert.equal(received, fakeEvent); + }); +}); + +// --------------------------------------------------------------------------- +// buildRoleButtons +// --------------------------------------------------------------------------- + +test('buildRoleButtons appends one child per palette entry', () => { + withApp((t) => { + t.legendRoleButtons.clear(); + const col = document.createElement('div'); + t.buildRoleButtons(col, { SENSOR: '#4A7EB4', REPEATER: '#C8D0DC' }, 'meshcore'); + assert.equal(col.childNodes.length, 2); + }); +}); + +test('buildRoleButtons sets dataset.role and dataset.protocol on each button', () => { + withApp((t) => { + t.legendRoleButtons.clear(); + const col = document.createElement('div'); + t.buildRoleButtons(col, { SENSOR: '#4A7EB4' }, 'meshcore'); + const btn = t.legendRoleButtons.get('meshcore:SENSOR'); + assert.ok(btn, 'button should be in legendRoleButtons'); + assert.equal(btn.dataset.role, 'SENSOR'); + assert.equal(btn.dataset.protocol, 'meshcore'); + }); +}); + +test('buildRoleButtons registers compound keys in legendRoleButtons', () => { + withApp((t) => { + t.legendRoleButtons.clear(); + const col = document.createElement('div'); + t.buildRoleButtons(col, { SENSOR: '#4A7EB4', REPEATER: '#C8D0DC' }, 'meshcore'); + assert.ok(t.legendRoleButtons.has('meshcore:SENSOR')); + assert.ok(t.legendRoleButtons.has('meshcore:REPEATER')); + }); +}); + +test('buildRoleButtons keeps meshtastic and meshcore SENSOR keys distinct', () => { + withApp((t) => { + t.legendRoleButtons.clear(); + const colMc = document.createElement('div'); + const colMt = document.createElement('div'); + t.buildRoleButtons(colMc, { SENSOR: '#4A7EB4' }, 'meshcore'); + t.buildRoleButtons(colMt, { SENSOR: '#B2D880' }, 'meshtastic'); + assert.ok(t.legendRoleButtons.has('meshcore:SENSOR')); + assert.ok(t.legendRoleButtons.has('meshtastic:SENSOR')); + assert.notEqual( + t.legendRoleButtons.get('meshcore:SENSOR'), + t.legendRoleButtons.get('meshtastic:SENSOR'), + ); + }); +}); + +test('buildRoleButtons sets aria-pressed to false initially', () => { + withApp((t) => { + t.legendRoleButtons.clear(); + const col = document.createElement('div'); + t.buildRoleButtons(col, { ROUTER: '#D44E14' }, 'meshtastic'); + const btn = t.legendRoleButtons.get('meshtastic:ROUTER'); + assert.ok(btn, 'button should be in legendRoleButtons'); + assert.equal(btn.getAttribute('aria-pressed'), 'false'); + }); +}); + +test('buildRoleButtons creates swatch child with background color', () => { + withApp((t) => { + t.legendRoleButtons.clear(); + const col = document.createElement('div'); + t.buildRoleButtons(col, { ROUTER: '#D44E14' }, 'meshtastic'); + const btn = t.legendRoleButtons.get('meshtastic:ROUTER'); + // swatch is the first child of the button + const swatch = btn.childNodes[0]; + assert.ok(swatch, 'swatch element should exist'); + assert.ok(swatch.style.background, 'swatch should have background color'); + }); +}); + +test('buildRoleButtons creates label child with role text', () => { + withApp((t) => { + t.legendRoleButtons.clear(); + const col = document.createElement('div'); + t.buildRoleButtons(col, { ROUTER: '#D44E14' }, 'meshtastic'); + const btn = t.legendRoleButtons.get('meshtastic:ROUTER'); + // label is the second child of the button + const label = btn.childNodes[1]; + assert.ok(label, 'label element should exist'); + assert.equal(label.textContent, 'ROUTER'); + }); +}); + +// --------------------------------------------------------------------------- +// updateLegendRoleFiltersUI +// --------------------------------------------------------------------------- + +test('updateLegendRoleFiltersUI sets aria-pressed true on active role buttons', () => { + withApp((t) => { + t.legendRoleButtons.clear(); + const col = document.createElement('div'); + t.buildRoleButtons(col, { SENSOR: '#4A7EB4' }, 'meshcore'); + const btn = t.legendRoleButtons.get('meshcore:SENSOR'); + t.activeRoleFilters.clear(); + t.activeRoleFilters.add('meshcore:SENSOR'); + t.updateLegendRoleFiltersUI(); + assert.equal(btn.getAttribute('aria-pressed'), 'true'); + }); +}); + +test('updateLegendRoleFiltersUI sets aria-pressed false on inactive role buttons', () => { + withApp((t) => { + t.legendRoleButtons.clear(); + const col = document.createElement('div'); + t.buildRoleButtons(col, { SENSOR: '#4A7EB4' }, 'meshcore'); + const btn = t.legendRoleButtons.get('meshcore:SENSOR'); + t.activeRoleFilters.clear(); + t.updateLegendRoleFiltersUI(); + assert.equal(btn.getAttribute('aria-pressed'), 'false'); + }); +}); + +test('updateLegendRoleFiltersUI updates protocol button text to Show when hidden', () => { + withApp((t) => { + t.legendProtocolButtons.clear(); + const fakeBtn = document.createElement('button'); + fakeBtn.setAttribute('aria-pressed', 'false'); + t.legendProtocolButtons.set('meshtastic', fakeBtn); + t.hiddenProtocols.clear(); + t.hiddenProtocols.add('meshtastic'); + t.updateLegendRoleFiltersUI(); + assert.equal(fakeBtn.getAttribute('aria-pressed'), 'true'); + assert.ok(fakeBtn.textContent.startsWith('Show')); + }); +}); + +test('updateLegendRoleFiltersUI updates protocol button text to Hide when visible', () => { + withApp((t) => { + t.legendProtocolButtons.clear(); + const fakeBtn = document.createElement('button'); + fakeBtn.setAttribute('aria-pressed', 'true'); + t.legendProtocolButtons.set('meshcore', fakeBtn); + t.hiddenProtocols.clear(); + t.updateLegendRoleFiltersUI(); + assert.equal(fakeBtn.getAttribute('aria-pressed'), 'false'); + assert.ok(fakeBtn.textContent.startsWith('Hide')); + }); +}); + +test('updateLegendRoleFiltersUI is safe when legendContainer is null', () => { + withApp((t) => { + // legendContainer starts null in tests (no map); should not throw + assert.doesNotThrow(() => t.updateLegendRoleFiltersUI()); + }); +}); diff --git a/web/public/assets/js/app/__tests__/main-protocol.test.js b/web/public/assets/js/app/__tests__/main-protocol.test.js index 1fc0ce5..6abd1bf 100644 --- a/web/public/assets/js/app/__tests__/main-protocol.test.js +++ b/web/public/assets/js/app/__tests__/main-protocol.test.js @@ -140,17 +140,26 @@ test('normalizeOverlaySource omits protocol when value is not a string', () => { // --- buildMapPopupHtml --- -test('buildMapPopupHtml includes meshtastic icon for null protocol', () => { +test('buildMapPopupHtml shows no icon for null protocol', () => { withApp((t) => { const html = t.buildMapPopupHtml({ long_name: 'Alice', node_id: '!abc123', protocol: null }, 0); - assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon for null protocol'); + assert.ok(!html.includes('meshtastic.svg'), 'popup should not show meshtastic icon when protocol is null'); + assert.ok(!html.includes('meshcore.svg'), 'popup should not show meshcore icon when protocol is null'); }); }); -test('buildMapPopupHtml includes meshtastic icon for absent protocol', () => { +test('buildMapPopupHtml shows no icon when protocol is absent', () => { withApp((t) => { const html = t.buildMapPopupHtml({ long_name: 'Bob', node_id: '!abc456' }, 0); - assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon when protocol absent'); + assert.ok(!html.includes('meshtastic.svg'), 'popup should not show any icon when protocol is absent'); + assert.ok(!html.includes('meshcore.svg'), 'popup should not show any icon when protocol is absent'); + }); +}); + +test('buildMapPopupHtml shows meshtastic icon for explicit meshtastic protocol', () => { + withApp((t) => { + const html = t.buildMapPopupHtml({ long_name: 'Alice', node_id: '!abc123', protocol: 'meshtastic' }, 0); + assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon for explicit meshtastic protocol'); }); }); @@ -178,7 +187,7 @@ test('createAnnouncementEntry prefixes meshtastic icon when protocol is meshtast }); }); -test('createAnnouncementEntry prefixes meshtastic icon when protocol is absent', () => { +test('createAnnouncementEntry shows no icon when protocol is absent', () => { withApp((t) => { const div = t.createAnnouncementEntry({ timestampSeconds: 1000, @@ -189,7 +198,8 @@ test('createAnnouncementEntry prefixes meshtastic icon when protocol is absent', nodeData: null, messageHtml: 'detected', }); - assert.ok(innerHtml(div).includes('meshtastic.svg'), 'announcement without protocol should show meshtastic icon'); + assert.ok(!innerHtml(div).includes('meshtastic.svg'), 'no meshtastic icon when protocol is absent'); + assert.ok(!innerHtml(div).includes('meshcore.svg'), 'no meshcore icon when protocol is absent'); }); }); @@ -237,14 +247,15 @@ test('createMessageChatEntry prefixes meshtastic icon when node protocol is mesh }); }); -test('createMessageChatEntry prefixes meshtastic icon when node protocol is absent', () => { +test('createMessageChatEntry shows no icon when node protocol is absent', () => { withApp((t) => { const div = t.createMessageChatEntry({ text: 'hi', rx_time: 2000, node: { short_name: 'BOB', role: 'ROUTER' }, }); - assert.ok(innerHtml(div).includes('meshtastic.svg'), 'chat entry without protocol should show meshtastic icon'); + assert.ok(!innerHtml(div).includes('meshtastic.svg'), 'no meshtastic icon when protocol is absent'); + assert.ok(!innerHtml(div).includes('meshcore.svg'), 'no meshcore icon when protocol is absent'); }); }); diff --git a/web/public/assets/js/app/__tests__/node-details.test.js b/web/public/assets/js/app/__tests__/node-details.test.js index 9181f57..e419e37 100644 --- a/web/public/assets/js/app/__tests__/node-details.test.js +++ b/web/public/assets/js/app/__tests__/node-details.test.js @@ -336,6 +336,7 @@ test('merge helpers combine node, telemetry, and position data', () => { assert.equal(node.nodeId, '!node'); assert.equal(node.nodeNum, 55); assert.equal(node.shortName, 'NODE'); + assert.equal(node.protocol, undefined); // no protocol in this fixture assert.equal(node.battery, 50); assert.equal(node.voltage, 3.8); assert.equal(node.lastHeard, 1_200); @@ -350,6 +351,31 @@ test('merge helpers combine node, telemetry, and position data', () => { assert.ok(node.position); }); +test('mergeNodeFields propagates the protocol field', () => { + const node = {}; + mergeNodeFields(node, { node_id: '!abc', protocol: 'meshcore' }); + assert.equal(node.protocol, 'meshcore'); +}); + +test('mergeNodeFields does not overwrite an existing protocol with absent value', () => { + const node = { protocol: 'meshcore' }; + mergeNodeFields(node, { node_id: '!abc' }); + assert.equal(node.protocol, 'meshcore'); +}); + +test('refreshNodeInformation surfaces protocol from the node API record', async () => { + const responses = new Map([ + ['/api/nodes/!proto?limit=7', createResponse(200, [{ node_id: '!proto', short_name: 'PT', protocol: 'meshcore' }])], + ['/api/telemetry/!proto?limit=1000', createResponse(404, {})], + ['/api/positions/!proto?limit=7', createResponse(404, {})], + ['/api/neighbors/!proto?limit=1000', createResponse(404, {})], + ]); + const fetchImpl = async url => responses.get(url) ?? createResponse(404, {}); + const node = await refreshNodeInformation('!proto', { fetchImpl }); + assert.equal(node.protocol, 'meshcore'); + assert.equal(node.rawSources.node.protocol, 'meshcore'); +}); + test('normalizeReference extracts identifiers and tolerates malformed fallback payloads', () => { const originalWarn = console.warn; const warnings = []; diff --git a/web/public/assets/js/app/__tests__/node-link-helpers.test.js b/web/public/assets/js/app/__tests__/node-link-helpers.test.js index 71ad0bc..6294c3e 100644 --- a/web/public/assets/js/app/__tests__/node-link-helpers.test.js +++ b/web/public/assets/js/app/__tests__/node-link-helpers.test.js @@ -97,9 +97,10 @@ test('renderNodeLongNameLink returns empty string for null/empty longName', () = assert.equal(renderNodeLongNameLink(' ', '!abc123'), ''); }); -test('renderNodeLongNameLink prepends Meshtastic icon for null protocol', () => { +test('renderNodeLongNameLink shows no icon for null protocol', () => { const html = renderNodeLongNameLink('Alice', '!abc123', { protocol: null }); - assert.ok(html.includes('meshtastic.svg'), 'should include meshtastic icon'); + assert.ok(!html.includes('meshtastic.svg'), 'no meshtastic icon for null protocol'); + assert.ok(!html.includes('meshcore.svg'), 'no meshcore icon for null protocol'); assert.ok(html.includes('Alice'), 'should include the name'); }); @@ -108,9 +109,10 @@ test('renderNodeLongNameLink prepends Meshtastic icon for "meshtastic" protocol' assert.ok(html.includes('meshtastic.svg'), 'should include meshtastic icon'); }); -test('renderNodeLongNameLink prepends Meshtastic icon for undefined protocol (default)', () => { +test('renderNodeLongNameLink shows no icon for absent protocol (default)', () => { const html = renderNodeLongNameLink('Alice', '!abc123'); - assert.ok(html.includes('meshtastic.svg'), 'default protocol should show meshtastic icon'); + assert.ok(!html.includes('meshtastic.svg'), 'no meshtastic icon when protocol is absent'); + assert.ok(!html.includes('meshcore.svg'), 'no meshcore icon when protocol is absent'); }); test('renderNodeLongNameLink does not prepend icon for "meshcore" protocol', () => { @@ -133,11 +135,12 @@ test('renderNodeLongNameLink renders anchor with href when identifier is present assert.ok(html.includes('data-node-id="!abc123"'), 'should include node id attribute'); }); -test('renderNodeLongNameLink renders plain text (with icon) when no identifier', () => { +test('renderNodeLongNameLink renders plain text (no icon) when no identifier and null protocol', () => { const html = renderNodeLongNameLink('Alice', null, { protocol: null }); assert.ok(!html.startsWith(' { diff --git a/web/public/assets/js/app/__tests__/node-page.test.js b/web/public/assets/js/app/__tests__/node-page.test.js index 4341f6f..1b1b4ca 100644 --- a/web/public/assets/js/app/__tests__/node-page.test.js +++ b/web/public/assets/js/app/__tests__/node-page.test.js @@ -360,7 +360,7 @@ test('renderSingleNodeTable renders a condensed table for the node', () => { 10_000, ); assert.equal(html.includes('.*Example Node<\/a>/s); assert.equal(html.includes('66.0%'), true); assert.equal(html.includes('1.230%'), true); @@ -604,7 +604,7 @@ test('renderNodeDetailHtml composes the table, neighbors, and messages', () => { assert.equal(html.includes('Heard by'), true); assert.equal(html.includes('We hear'), true); assert.equal(html.includes('Messages'), true); - assert.ok(html.includes('meshtastic.svg'), 'default protocol should show meshtastic icon in heading and table'); + assert.ok(!html.includes('meshtastic.svg'), 'absent protocol should show no meshtastic icon in heading and table'); assert.match(html, /.*Example Node<\/a>/s); assert.equal(html.includes('PEER'), true); assert.equal(html.includes('ALLY'), true); @@ -665,7 +665,7 @@ test('renderSingleNodeTable shows meshtastic icon for meshtastic protocol in lon assert.ok(html.includes('meshtastic.svg'), 'meshtastic protocol should show icon in long name link'); }); -test('renderSingleNodeTable shows meshtastic icon when protocol is absent in long name link', () => { +test('renderSingleNodeTable shows no protocol icon when protocol is absent in long name link', () => { const node = { shortName: 'A', longName: 'Alice', @@ -674,7 +674,8 @@ test('renderSingleNodeTable shows meshtastic icon when protocol is absent in lon rawSources: { node: { node_id: '!aa', role: 'CLIENT' } }, }; const html = renderSingleNodeTable(node, (short, role) => `${short}`, 0); - assert.ok(html.includes('meshtastic.svg'), 'absent protocol should show meshtastic icon in long name link'); + assert.ok(!html.includes('meshtastic.svg'), 'absent protocol should show no meshtastic icon in long name link'); + assert.ok(!html.includes('meshcore.svg'), 'absent protocol should show no meshcore icon in long name link'); }); test('renderSingleNodeTable omits meshtastic icon for meshcore protocol in long name link', () => { @@ -700,12 +701,13 @@ test('renderNodeDetailHtml shows meshtastic icon in heading for meshtastic proto assert.ok(html.includes('meshtastic.svg'), 'meshtastic protocol should show icon in heading'); }); -test('renderNodeDetailHtml shows meshtastic icon in heading when protocol is absent', () => { +test('renderNodeDetailHtml shows no protocol icon in heading when protocol is absent', () => { const html = renderNodeDetailHtml( { shortName: 'A', longName: 'Alice', nodeId: '!aa', role: 'CLIENT' }, { renderShortHtml: short => `${short}` }, ); - assert.ok(html.includes('meshtastic.svg'), 'absent protocol should show meshtastic icon in heading'); + assert.ok(!html.includes('meshtastic.svg'), 'absent protocol should show no meshtastic icon in heading'); + assert.ok(!html.includes('meshcore.svg'), 'absent protocol should show no meshcore icon in heading'); }); test('renderNodeDetailHtml omits meshtastic icon in heading for meshcore protocol', () => { @@ -736,7 +738,7 @@ test('renderMessages prefixes meshtastic icon for meshtastic node protocol', () assert.ok(html.includes('meshtastic.svg'), 'meshtastic node chat entry should show icon'); }); -test('renderMessages prefixes meshtastic icon when node protocol is absent', () => { +test('renderMessages shows no protocol icon when node protocol is absent', () => { const nodeContext = { shortName: 'SRC', longName: 'Source', @@ -750,7 +752,8 @@ test('renderMessages prefixes meshtastic icon when node protocol is absent', () (short, role) => `${short}`, nodeContext, ); - assert.ok(html.includes('meshtastic.svg'), 'absent node protocol chat entry should show meshtastic icon'); + assert.ok(!html.includes('meshtastic.svg'), 'absent node protocol chat entry should show no meshtastic icon'); + assert.ok(!html.includes('meshcore.svg'), 'absent node protocol chat entry should show no meshcore icon'); }); test('renderMessages omits meshtastic icon for meshcore node protocol', () => { diff --git a/web/public/assets/js/app/__tests__/node-rendering.test.js b/web/public/assets/js/app/__tests__/node-rendering.test.js index 97df3a5..5925d6d 100644 --- a/web/public/assets/js/app/__tests__/node-rendering.test.js +++ b/web/public/assets/js/app/__tests__/node-rendering.test.js @@ -122,14 +122,21 @@ test('renderNodeLongNameLink renders anchor when identifier is present', () => { assert.ok(html.includes('Alice'), 'long name should appear'); }); -test('renderNodeLongNameLink renders meshtastic icon for null protocol', () => { +test('renderNodeLongNameLink shows no icon for null protocol', () => { const html = renderNodeLongNameLink('Alice', '!aabbccdd', { protocol: null }); - assert.ok(html.includes('meshtastic.svg'), 'meshtastic icon should be shown for null protocol'); + assert.ok(!html.includes('meshtastic.svg'), 'no icon when protocol is null'); + assert.ok(!html.includes('meshcore.svg'), 'no icon when protocol is null'); }); -test('renderNodeLongNameLink renders meshtastic icon when protocol is absent', () => { +test('renderNodeLongNameLink shows no icon when protocol is absent', () => { const html = renderNodeLongNameLink('Alice', '!aabbccdd'); - assert.ok(html.includes('meshtastic.svg')); + assert.ok(!html.includes('meshtastic.svg'), 'no icon when protocol is absent'); + assert.ok(!html.includes('meshcore.svg'), 'no icon when protocol is absent'); +}); + +test('renderNodeLongNameLink renders meshtastic icon for explicit meshtastic protocol', () => { + const html = renderNodeLongNameLink('Alice', '!aabbccdd', { protocol: 'meshtastic' }); + assert.ok(html.includes('meshtastic.svg'), 'meshtastic icon shown for explicit meshtastic protocol'); }); test('renderNodeLongNameLink uses meshcore icon for meshcore protocol', () => { @@ -138,6 +145,21 @@ test('renderNodeLongNameLink uses meshcore icon for meshcore protocol', () => { assert.ok(html.includes('meshcore.svg'), 'meshcore icon should be shown'); }); +test('renderNodeLongNameLink renders meshcore icon for meshcore protocol', () => { + const html = renderNodeLongNameLink('Eve', '!aabbccdd', { protocol: 'meshcore' }); + assert.ok(html.includes('meshcore.svg'), 'meshcore icon should be shown for meshcore protocol'); +}); + +test('renderNodeLongNameLink omits meshcore icon for meshtastic protocol', () => { + const html = renderNodeLongNameLink('Alice', '!aabbccdd', { protocol: 'meshtastic' }); + assert.ok(!html.includes('meshcore.svg'), 'no meshcore icon for meshtastic protocol'); +}); + +test('renderNodeLongNameLink omits meshcore icon for null protocol', () => { + const html = renderNodeLongNameLink('Alice', '!aabbccdd', { protocol: null }); + assert.ok(!html.includes('meshcore.svg'), 'no meshcore icon for null protocol'); +}); + test('renderNodeLongNameLink renders plain text when identifier is null', () => { const html = renderNodeLongNameLink('Alice', null); assert.ok(!html.includes(' { - assert.equal(isMeshtasticProtocol(null), true); -}); - -test('isMeshtasticProtocol — undefined is Meshtastic (default)', () => { - assert.equal(isMeshtasticProtocol(undefined), true); -}); - -test('isMeshtasticProtocol — empty string is Meshtastic', () => { - assert.equal(isMeshtasticProtocol(''), true); -}); - -test('isMeshtasticProtocol — whitespace-only string is Meshtastic', () => { - assert.equal(isMeshtasticProtocol(' '), true); -}); +// --------------------------------------------------------------------------- +// isMeshtasticProtocol — only matches the explicit string "meshtastic" +// --------------------------------------------------------------------------- test('isMeshtasticProtocol — "meshtastic" is Meshtastic', () => { assert.equal(isMeshtasticProtocol('meshtastic'), true); }); +test('isMeshtasticProtocol — null is not Meshtastic (no default)', () => { + assert.equal(isMeshtasticProtocol(null), false); +}); + +test('isMeshtasticProtocol — undefined is not Meshtastic (no default)', () => { + assert.equal(isMeshtasticProtocol(undefined), false); +}); + +test('isMeshtasticProtocol — empty string is not Meshtastic', () => { + assert.equal(isMeshtasticProtocol(''), false); +}); + +test('isMeshtasticProtocol — whitespace-only string is not Meshtastic', () => { + assert.equal(isMeshtasticProtocol(' '), false); +}); + test('isMeshtasticProtocol — "meshcore" is not Meshtastic', () => { assert.equal(isMeshtasticProtocol('meshcore'), false); }); @@ -107,26 +111,23 @@ test('MESHCORE_ICON_SRC is referenced by meshcoreIconHtml', () => { // protocolIconPrefixHtml // --------------------------------------------------------------------------- -test('protocolIconPrefixHtml — null yields meshtastic icon prefix', () => { - const result = protocolIconPrefixHtml(null); - assert.ok(result.includes('meshtastic.svg'), 'null should produce the meshtastic icon'); - assert.ok(result.endsWith(' '), 'prefix must end with a trailing space'); +test('protocolIconPrefixHtml — null yields empty string (no default)', () => { + assert.equal(protocolIconPrefixHtml(null), ''); }); -test('protocolIconPrefixHtml — undefined yields meshtastic icon prefix', () => { - const result = protocolIconPrefixHtml(undefined); - assert.ok(result.includes('meshtastic.svg'), 'undefined should produce the meshtastic icon'); +test('protocolIconPrefixHtml — undefined yields empty string (no default)', () => { + assert.equal(protocolIconPrefixHtml(undefined), ''); }); -test('protocolIconPrefixHtml — empty string yields meshtastic icon prefix', () => { - const result = protocolIconPrefixHtml(''); - assert.ok(result.includes('meshtastic.svg'), 'empty string should produce the meshtastic icon'); +test('protocolIconPrefixHtml — empty string yields empty string', () => { + assert.equal(protocolIconPrefixHtml(''), ''); }); test('protocolIconPrefixHtml — "meshtastic" yields meshtastic icon prefix', () => { const result = protocolIconPrefixHtml('meshtastic'); assert.ok(result.includes('meshtastic.svg'), '"meshtastic" should produce the meshtastic icon'); assert.ok(!result.includes('meshcore.svg'), '"meshtastic" must not produce the meshcore icon'); + assert.ok(result.endsWith(' '), 'prefix must end with a trailing space'); }); test('protocolIconPrefixHtml — "meshcore" yields meshcore icon prefix', () => { diff --git a/web/public/assets/js/app/__tests__/role-helpers.test.js b/web/public/assets/js/app/__tests__/role-helpers.test.js index 0edc124..43ce273 100644 --- a/web/public/assets/js/app/__tests__/role-helpers.test.js +++ b/web/public/assets/js/app/__tests__/role-helpers.test.js @@ -22,7 +22,11 @@ import { getRoleKey, getRoleRenderPriority, getRoleColors, + getRoleTextColor, meshcoreRoleColors, + meshcoreRoleTextColors, + meshcoreRoleRenderOrder, + meshtasticRoleRenderOrder, roleColors, normalizeRole, translateRoleId, @@ -52,10 +56,53 @@ test('role key and color lookups prefer known values with uppercase fallback', ( }); test('render priority uses canonical role keys and defaults to zero for unknowns', () => { + // translateRoleId(2) → 'ROUTER', so both should resolve to the same priority assert.equal(getRoleRenderPriority('ROUTER'), getRoleRenderPriority(2)); assert.equal(getRoleRenderPriority('custom-role'), 0); }); +test('render priority is protocol-aware for shared roles', () => { + // SENSOR: meshtastic=2, meshcore=3 + assert.equal(getRoleRenderPriority('SENSOR', 'meshtastic'), 2); + assert.equal(getRoleRenderPriority('SENSOR', 'meshcore'), 3); + assert.ok(getRoleRenderPriority('SENSOR', 'meshcore') > getRoleRenderPriority('SENSOR', 'meshtastic')); + // REPEATER: meshtastic=11, meshcore=12 + assert.equal(getRoleRenderPriority('REPEATER', 'meshtastic'), 11); + assert.equal(getRoleRenderPriority('REPEATER', 'meshcore'), 12); + assert.ok(getRoleRenderPriority('REPEATER', 'meshcore') > getRoleRenderPriority('REPEATER', 'meshtastic')); +}); + +test('render priority meshcore-exclusive roles have defined priorities', () => { + assert.equal(getRoleRenderPriority('COMPANION', 'meshcore'), 7); + assert.equal(getRoleRenderPriority('ROOM_SERVER', 'meshcore'), 9); +}); + +test('render priority respects the full bottom-to-top order', () => { + const order = [ + ['CLIENT_HIDDEN', null], + ['SENSOR', 'meshtastic'], + ['SENSOR', 'meshcore'], + ['TRACKER', null], + ['CLIENT_MUTE', null], + ['CLIENT', null], + ['COMPANION', 'meshcore'], + ['CLIENT_BASE', null], + ['ROOM_SERVER', 'meshcore'], + ['ROUTER_LATE', null], + ['REPEATER', 'meshtastic'], + ['REPEATER', 'meshcore'], + ['ROUTER', null], + ['LOST_AND_FOUND', null], + ]; + for (let i = 1; i < order.length; i++) { + const [roleA, protoA] = order[i - 1]; + const [roleB, protoB] = order[i]; + const pA = getRoleRenderPriority(roleA, protoA); + const pB = getRoleRenderPriority(roleB, protoB); + assert.ok(pA < pB, `Expected ${roleA}/${protoA} (${pA}) < ${roleB}/${protoB} (${pB})`); + } +}); + test('getRoleColors returns Meshtastic palette for null/undefined/meshtastic', () => { assert.equal(getRoleColors(null), roleColors); assert.equal(getRoleColors(undefined), roleColors); @@ -70,3 +117,34 @@ test('getRoleColors returns MeshCore palette for meshcore protocol', () => { test('getRoleColors returns Meshtastic palette for unknown protocols', () => { assert.equal(getRoleColors('reticulum'), roleColors); }); + +test('getRoleColor uses meshcore palette when protocol is meshcore', () => { + assert.equal(getRoleColor('COMPANION', 'meshcore'), meshcoreRoleColors.COMPANION); + assert.equal(getRoleColor('REPEATER', 'meshcore'), meshcoreRoleColors.REPEATER); + assert.equal(getRoleColor('ROOM_SERVER', 'meshcore'), meshcoreRoleColors.ROOM_SERVER); + assert.equal(getRoleColor('SENSOR', 'meshcore'), meshcoreRoleColors.SENSOR); +}); + +test('getRoleColor uses meshtastic palette when protocol is null', () => { + assert.equal(getRoleColor('ROUTER', null), roleColors.ROUTER); + assert.equal(getRoleColor('CLIENT', null), roleColors.CLIENT); +}); + +test('getRoleColor falls back to CLIENT color for unknown meshcore role', () => { + assert.equal(getRoleColor('UNKNOWN_ROLE', 'meshcore'), roleColors.CLIENT); +}); + +test('getRoleTextColor returns light grey for meshcore COMPANION', () => { + assert.equal(getRoleTextColor('COMPANION', 'meshcore'), meshcoreRoleTextColors.COMPANION); +}); + +test('getRoleTextColor returns null for meshcore roles without override', () => { + assert.equal(getRoleTextColor('REPEATER', 'meshcore'), null); + assert.equal(getRoleTextColor('ROOM_SERVER', 'meshcore'), null); + assert.equal(getRoleTextColor('SENSOR', 'meshcore'), null); +}); + +test('getRoleTextColor returns null for meshtastic roles', () => { + assert.equal(getRoleTextColor('CLIENT', 'meshtastic'), null); + assert.equal(getRoleTextColor('ROUTER', null), null); +}); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 3a734fc..7008277 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -95,9 +95,10 @@ import { getRoleColor, getRoleKey, getRoleRenderPriority, + getRoleTextColor, + meshcoreRoleColors, normalizeRole, roleColors, - roleRenderOrder, } from './role-helpers.js'; import { isMeshtasticProtocol, @@ -835,8 +836,63 @@ export function initializeApp(config) { syncInfoOverlayHost(); + /** @type {Set} Active compound role-filter keys, each ``":"``. */ const activeRoleFilters = new Set(); + /** @type {Map} Compound key → legend button element. */ const legendRoleButtons = new Map(); + /** @type {Set} Protocols hidden by the user via legend toggles. */ + const hiddenProtocols = new Set(); + const legendProtocolButtons = new Map(); + + /** + * Wrap a legend button click handler so it always calls + * ``preventDefault`` and ``stopPropagation`` before running the body. + * + * Centralising this prevents the two-line boilerplate from repeating in + * every legend button handler, reducing token-level duplication. + * + * @param {function(Event): void} fn Handler body. + * @returns {function(Event): void} Full click listener. + */ + function legendClickHandler(fn) { + return (event) => { + event.preventDefault(); + event.stopPropagation(); + fn(event); + }; + } + + /** + * Canonical protocol token for use in compound filter keys. + * + * Collapses null/absent/unknown protocol values to ``'meshtastic'`` so that + * pre-protocol legacy records land in the Meshtastic filter bucket. + * + * @param {string|null|undefined} protocol Raw protocol value. + * @returns {'meshtastic'|'meshcore'} Normalised protocol token. + */ + function normalizeFilterProtocol(protocol) { + return isMeshcoreProtocol(protocol) ? 'meshcore' : 'meshtastic'; + } + + /** + * Build a compound filter key that encodes both protocol and role. + * + * Using compound keys avoids collisions between role names that appear in + * both Meshtastic and MeshCore (e.g. ``SENSOR``, ``REPEATER``). The filter + * set stores these keys so that clicking the MeshCore SENSOR button only + * includes MeshCore SENSOR nodes, not Meshtastic ones. + * + * @param {*} role Raw role value from the API. + * @param {string|null|undefined} protocol Protocol string from the API. + * @returns {string} Compound key in the form ``":"``. + */ + function makeRoleFilterKey(role, protocol) { + return `${normalizeFilterProtocol(protocol)}:${getRoleKey(role)}`; + } + + /** @type {Readonly>} Display names for protocol tokens. */ + const PROTOCOL_DISPLAY_NAMES = Object.freeze({ meshtastic: 'Meshtastic', meshcore: 'MeshCore' }); /** * Lazily create the floating map status element used for progress messages. @@ -1366,31 +1422,44 @@ export function initializeApp(config) { * @returns {void} */ /** - * Build a Meshtastic protocol icon ```` element via DOM APIs. + * Build a protocol icon ```` element via DOM APIs. * - * Used wherever an icon node must be appended rather than injected via - * ``innerHTML``. Mirrors the attribute set used by {@link meshtasticIconHtml} - * so the rendered output is identical. + * Shared implementation used by {@link buildMeshtasticIconImg} and + * {@link buildMeshcoreIconImg}. Mirrors the attribute set produced by + * the HTML-string helpers in ``protocol-helpers.js`` so the rendered + * output is identical regardless of insertion method. * + * @param {string} src Absolute path to the SVG asset. + * @param {string} variantClass BEM modifier class, e.g. ``protocol-icon--meshtastic``. * @returns {HTMLImageElement} Icon element ready to append. */ - function buildMeshtasticIconImg() { + function buildProtocolIconImg(src, variantClass) { const img = document.createElement('img'); - img.setAttribute('src', MESHTASTIC_ICON_SRC); + img.setAttribute('src', src); img.setAttribute('alt', ''); img.setAttribute('width', '12'); img.setAttribute('height', '12'); img.setAttribute('aria-hidden', 'true'); img.setAttribute('loading', 'lazy'); img.setAttribute('decoding', 'async'); - img.className = 'protocol-icon protocol-icon--meshtastic'; + img.className = `protocol-icon ${variantClass}`; return img; } + /** @returns {HTMLImageElement} Meshtastic protocol icon element. */ + function buildMeshtasticIconImg() { + return buildProtocolIconImg(MESHTASTIC_ICON_SRC, 'protocol-icon--meshtastic'); + } + + /** @returns {HTMLImageElement} MeshCore protocol icon element. */ + function buildMeshcoreIconImg() { + return buildProtocolIconImg(MESHCORE_ICON_SRC, 'protocol-icon--meshcore'); + } + function updateNeighborLinesToggleState() { if (!neighborLinesToggleButton) return; const label = neighborLinesVisible ? 'Hide neighbor lines' : 'Show neighbor lines'; - neighborLinesToggleButton.replaceChildren(buildMeshtasticIconImg(), document.createTextNode(` ${label}`)); + neighborLinesToggleButton.textContent = label; neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'true' : 'false'); neighborLinesToggleButton.setAttribute('aria-label', label); } @@ -1422,7 +1491,7 @@ export function initializeApp(config) { function updateTraceLinesToggleState() { if (!traceLinesToggleButton) return; const label = traceLinesVisible ? 'Hide trace lines' : 'Show trace lines'; - traceLinesToggleButton.replaceChildren(buildMeshtasticIconImg(), document.createTextNode(` ${label}`)); + traceLinesToggleButton.textContent = label; traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false'); traceLinesToggleButton.setAttribute('aria-label', label); } @@ -1453,13 +1522,21 @@ export function initializeApp(config) { */ function updateLegendRoleFiltersUI() { const hasFilters = activeRoleFilters.size > 0; - legendRoleButtons.forEach((button, role) => { + // legendRoleButtons is keyed by compound key ("protocol:roleKey") + legendRoleButtons.forEach((button, compoundKey) => { if (!button) return; - const isActive = activeRoleFilters.has(role); + const isActive = activeRoleFilters.has(compoundKey); button.setAttribute('aria-pressed', isActive ? 'true' : 'false'); }); + legendProtocolButtons.forEach((button, protocol) => { + if (!button) return; + const isHidden = hiddenProtocols.has(protocol); + const displayName = PROTOCOL_DISPLAY_NAMES[protocol] ?? protocol; + button.setAttribute('aria-pressed', isHidden ? 'true' : 'false'); + button.textContent = isHidden ? `Show ${displayName}` : `Hide ${displayName}`; + }); if (legendContainer) { - if (hasFilters) { + if (hasFilters || hiddenProtocols.size > 0) { legendContainer.setAttribute('data-has-active-filters', 'true'); } else { legendContainer.removeAttribute('data-has-active-filters'); @@ -1469,22 +1546,69 @@ export function initializeApp(config) { } /** - * Toggle the visibility filter for a given role. + * Toggle the visibility filter for a role+protocol combination. * - * @param {string} role Role identifier. + * @param {string} compoundKey Compound key in the form ``":"``. * @returns {void} */ - function toggleRoleFilter(role) { - if (!role) return; - if (activeRoleFilters.has(role)) { - activeRoleFilters.delete(role); + function toggleRoleFilter(compoundKey) { + if (!compoundKey) return; + if (activeRoleFilters.has(compoundKey)) { + activeRoleFilters.delete(compoundKey); } else { - activeRoleFilters.add(role); + activeRoleFilters.add(compoundKey); } updateLegendRoleFiltersUI(); applyFilter(); } + /** + * Build role filter buttons for a given palette and append them to a column. + * + * Each button is keyed by a compound ``":"`` string so + * that roles sharing a name across protocols (e.g. ``SENSOR``, ``REPEATER``) + * produce independent buttons without colliding in {@link legendRoleButtons}. + * + * @param {HTMLElement} colEl Column container element. + * @param {Record} palette Role→colour map to render. + * @param {'meshtastic'|'meshcore'} protocol Protocol token for this column. + * @returns {void} + */ + function buildRoleButtons(colEl, palette, protocol) { + for (const [role, color] of Object.entries(palette)) { + if (!CHAT_ENABLED && role === 'CLIENT_HIDDEN') continue; + const compoundKey = makeRoleFilterKey(role, protocol); + const item = document.createElement('button'); + item.className = 'legend-item'; + colEl.appendChild(item); + item.type = 'button'; + item.setAttribute('aria-pressed', 'false'); + item.dataset.role = role; + item.dataset.protocol = protocol; + const swatch = document.createElement('span'); + swatch.className = 'legend-swatch'; + item.appendChild(swatch); + swatch.style.background = color; + swatch.setAttribute('aria-hidden', 'true'); + const label = document.createElement('span'); + label.className = 'legend-label'; + item.appendChild(label); + label.textContent = role; + item.addEventListener('click', legendClickHandler(event => { + const exclusive = event.metaKey || event.ctrlKey; + if (exclusive) { + activeRoleFilters.clear(); + activeRoleFilters.add(compoundKey); + updateLegendRoleFiltersUI(); + applyFilter(); + } else { + toggleRoleFilter(compoundKey); + } + })); + legendRoleButtons.set(compoundKey, item); + } + } + if (map && hasLeaflet) { const legend = L.control({ position: 'bottomright' }); /** @@ -1503,66 +1627,81 @@ export function initializeApp(config) { const title = L.DomUtil.create('span', 'legend-title', header); title.textContent = 'Legend'; - const itemsContainer = L.DomUtil.create('div', 'legend-items', div); - legendRoleButtons.clear(); - for (const [role, color] of Object.entries(roleColors)) { - if (!CHAT_ENABLED && role === 'CLIENT_HIDDEN') continue; - const item = L.DomUtil.create('button', 'legend-item', itemsContainer); - item.type = 'button'; - item.setAttribute('aria-pressed', 'false'); - item.dataset.role = role; - item.appendChild(buildMeshtasticIconImg()); - const swatch = L.DomUtil.create('span', 'legend-swatch', item); - swatch.style.background = color; - swatch.setAttribute('aria-hidden', 'true'); - const label = L.DomUtil.create('span', 'legend-label', item); - label.textContent = role; - item.addEventListener('click', event => { - event.preventDefault(); - event.stopPropagation(); - const exclusive = event.metaKey || event.ctrlKey; - if (exclusive) { - activeRoleFilters.clear(); - activeRoleFilters.add(role); - updateLegendRoleFiltersUI(); - applyFilter(); - } else { - toggleRoleFilter(role); - } - }); - legendRoleButtons.set(role, item); - } - updateLegendRoleFiltersUI(); + const itemsContainer = L.DomUtil.create('div', 'legend-items legend-items--columns', div); - const toggle = L.DomUtil.create('div', 'legend-toggle', div); - neighborLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-neighbors', toggle); + // --- MeshCore column (left) --- + const meshcoreCol = L.DomUtil.create('div', 'legend-column', itemsContainer); + const meshcoreColHeader = L.DomUtil.create('div', 'legend-column-header', meshcoreCol); + meshcoreColHeader.appendChild(buildMeshcoreIconImg()); + const meshcoreColTitle = document.createElement('span'); + meshcoreColTitle.textContent = 'MeshCore'; + meshcoreColHeader.appendChild(meshcoreColTitle); + + // --- Meshtastic column (right) --- + const meshtasticCol = L.DomUtil.create('div', 'legend-column', itemsContainer); + const meshtasticColHeader = L.DomUtil.create('div', 'legend-column-header', meshtasticCol); + meshtasticColHeader.appendChild(buildMeshtasticIconImg()); + const meshtasticColTitle = document.createElement('span'); + meshtasticColTitle.textContent = 'Meshtastic'; + meshtasticColHeader.appendChild(meshtasticColTitle); + + legendRoleButtons.clear(); + buildRoleButtons(meshcoreCol, meshcoreRoleColors, 'meshcore'); + buildRoleButtons(meshtasticCol, roleColors, 'meshtastic'); + + // --- Protocol hide toggles — one per column footer --- + legendProtocolButtons.clear(); + const protocolColDefs = [ + { protocol: 'meshcore', col: meshcoreCol }, + { protocol: 'meshtastic', col: meshtasticCol }, + ]; + for (const { protocol, col } of protocolColDefs) { + const displayName = PROTOCOL_DISPLAY_NAMES[protocol] ?? protocol; + const btn = L.DomUtil.create('button', 'legend-item legend-protocol-toggle', col); + btn.type = 'button'; + btn.setAttribute('aria-pressed', 'false'); + btn.textContent = `Hide ${displayName}`; + btn.addEventListener('click', legendClickHandler(() => { + if (hiddenProtocols.has(protocol)) { + hiddenProtocols.delete(protocol); + } else { + hiddenProtocols.add(protocol); + } + updateLegendRoleFiltersUI(); + applyFilter(); + })); + legendProtocolButtons.set(protocol, btn); + } + + // --- Line toggles in the Meshtastic column --- + neighborLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-neighbors', meshtasticCol); neighborLinesToggleButton.type = 'button'; - neighborLinesToggleButton.addEventListener('click', event => { - event.preventDefault(); - event.stopPropagation(); + neighborLinesToggleButton.addEventListener('click', legendClickHandler(() => { setNeighborLinesVisibility(!neighborLinesVisible); - }); + })); updateNeighborLinesToggleState(); - traceLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-traces', toggle); + traceLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-traces', meshtasticCol); traceLinesToggleButton.type = 'button'; - traceLinesToggleButton.addEventListener('click', event => { - event.preventDefault(); - event.stopPropagation(); + traceLinesToggleButton.addEventListener('click', legendClickHandler(() => { setTraceLinesVisibility(!traceLinesVisible); - }); + })); updateTraceLinesToggleState(); + updateLegendRoleFiltersUI(); + + // --- Clear filters — full-width below the two columns --- + const toggle = L.DomUtil.create('div', 'legend-toggle', div); + const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', toggle); resetButton.type = 'button'; resetButton.textContent = 'Clear filters'; - resetButton.addEventListener('click', event => { - event.preventDefault(); - event.stopPropagation(); + resetButton.addEventListener('click', legendClickHandler(() => { activeRoleFilters.clear(); + hiddenProtocols.clear(); updateLegendRoleFiltersUI(); applyFilter(); - }); + })); L.DomEvent.disableClickPropagation(div); L.DomEvent.disableScrollPropagation(div); @@ -1586,11 +1725,9 @@ export function initializeApp(config) { button.setAttribute('aria-pressed', 'true'); button.setAttribute('aria-label', 'Hide map legend'); button.setAttribute('aria-controls', 'mapLegend'); - button.addEventListener('click', event => { - event.preventDefault(); - event.stopPropagation(); + button.addEventListener('click', legendClickHandler(() => { setLegendVisibility(!legendVisible); - }); + })); legendToggleButton = button; updateLegendToggleState(); L.DomEvent.disableClickPropagation(container); @@ -1848,8 +1985,11 @@ export function initializeApp(config) { return `?   `; } const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, ' '); - const color = getRoleColor(roleValue); - return `${padded}`; + const protocol = nodeData?.protocol ?? null; + const color = getRoleColor(roleValue, protocol); + const textColor = getRoleTextColor(roleValue, protocol); + const styleAttr = textColor ? `background:${color};color:${textColor}` : `background:${color}`; + return `${padded}`; } const potatoMeshNamespace = globalThis.PotatoMesh || (globalThis.PotatoMesh = {}); @@ -2266,7 +2406,7 @@ export function initializeApp(config) { } } const shortParts = []; - const shortHtml = renderShortHtml(overlayInfo.shortName, overlayInfo.role, overlayInfo.longName); + const shortHtml = renderShortHtml(overlayInfo.shortName, overlayInfo.role, overlayInfo.longName, overlayInfo); if (shortHtml) { shortParts.push(shortHtml); } @@ -3852,8 +3992,10 @@ export function initializeApp(config) { const loraFrequencyText = formatLoraFrequencyMHz(modemMetadata.loraFreq); const loraFrequencyDisplay = loraFrequencyText ? escapeHtml(loraFrequencyText) : ''; const modemPresetDisplay = modemMetadata.modemPreset ? escapeHtml(modemMetadata.modemPreset) : ''; - const longNameHtml = renderNodeLongNameLink(n.long_name, n.node_id, { protocol: n.protocol }); + const longNameHtml = renderNodeLongNameLink(n.long_name, n.node_id); + const protocolIconCell = protocolIconPrefixHtml(n.protocol); tr.innerHTML = ` + ${protocolIconCell} ${n.node_id || ""} ${renderShortHtml(n.short_name, n.role, n.long_name, n)} ${longNameHtml} @@ -3933,7 +4075,7 @@ export function initializeApp(config) { ? buildTraceSegments(allTraces, nodes, { limitDistance: LIMIT_DISTANCE, maxDistanceKm: MAX_DISTANCE_KM, - colorForNode: node => getRoleColor(node.role) + colorForNode: node => getRoleColor(node.role, node.protocol) }) : []; @@ -3973,7 +4115,7 @@ export function initializeApp(config) { if (LIMIT_DISTANCE && sourceNode.distance_km != null && sourceNode.distance_km > MAX_DISTANCE_KM) continue; if (LIMIT_DISTANCE && targetNode.distance_km != null && targetNode.distance_km > MAX_DISTANCE_KM) continue; - const priority = getRoleRenderPriority(sourceNode.role); + const priority = getRoleRenderPriority(sourceNode.role, sourceNode.protocol); const rxTimeRaw = entry.rx_time; let rxTime = 0; if (typeof rxTimeRaw === 'number' && Number.isFinite(rxTimeRaw)) { @@ -3991,7 +4133,7 @@ export function initializeApp(config) { neighborSegments.push({ latlngs: [[srcLat, srcLon], [tgtLat, tgtLon]], - color: getRoleColor(sourceNode.role), + color: getRoleColor(sourceNode.role, sourceNode.protocol), priority, rxTime, sourceId, @@ -4123,8 +4265,8 @@ export function initializeApp(config) { const nodesByRenderOrder = nodes .map((node, index) => ({ node, index })) .sort((a, b) => { - const orderA = getRoleRenderPriority(a.node && a.node.role); - const orderB = getRoleRenderPriority(b.node && b.node.role); + const orderA = getRoleRenderPriority(a.node && a.node.role, a.node && a.node.protocol); + const orderB = getRoleRenderPriority(b.node && b.node.role, b.node && b.node.protocol); if (orderA !== orderB) return orderA - orderB; return a.index - b.index; }) @@ -4137,7 +4279,7 @@ export function initializeApp(config) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; if (LIMIT_DISTANCE && n.distance_km != null && n.distance_km > MAX_DISTANCE_KM) continue; - const color = getRoleColor(n.role); + const color = getRoleColor(n.role, n.protocol); const marker = L.circleMarker([lat, lon], { radius: 9, color: '#000', @@ -4222,13 +4364,36 @@ export function initializeApp(config) { /** * Test whether a node matches the active role filters. * + * Filters use compound ``":"`` keys so that shared role + * names (e.g. ``SENSOR``, ``REPEATER``) can be toggled independently per + * protocol. Nodes whose protocol is null/absent are treated as Meshtastic + * (via {@link normalizeFilterProtocol}) to keep legacy records visible when + * the Meshtastic SENSOR filter is active. + * * @param {Object} node Node payload. * @returns {boolean} True when the node should be visible. */ function matchesRoleFilter(node) { if (!activeRoleFilters.size) return true; - const roleKey = getRoleKey(node && node.role); - return activeRoleFilters.has(roleKey); + const compoundKey = makeRoleFilterKey(node && node.role, node && node.protocol); + return activeRoleFilters.has(compoundKey); + } + + /** + * Check whether a node passes the active protocol visibility filters. + * + * Nodes with a null/absent protocol are always shown — hiding + * ``'meshtastic'`` hides only nodes that explicitly carry that protocol + * value. Pre-protocol legacy records remain visible regardless. + * + * @param {Object} node Node payload. + * @returns {boolean} True when the node should be visible. + */ + function matchesProtocolFilter(node) { + if (!hiddenProtocols.size) return true; + const protocol = (node && node.protocol) || null; + if (protocol && hiddenProtocols.has(protocol)) return false; + return true; } /** @@ -4256,7 +4421,7 @@ export function initializeApp(config) { // Text and role filters apply only to the node table and map; the chat log // always receives the full node collection so reply-thread lookups succeed // even for nodes that are currently hidden by the active filter. - const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n)); + const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n) && matchesProtocolFilter(n)); const sortedNodes = sortNodes(filteredNodes); const nowSec = Date.now()/1000; renderTable(sortedNodes, nowSec); @@ -4475,6 +4640,20 @@ export function initializeApp(config) { createAnnouncementEntry, createMessageChatEntry, buildDisplayContext, + makeRoleFilterKey, + normalizeFilterProtocol, + matchesRoleFilter, + matchesProtocolFilter, + buildProtocolIconImg, + buildMeshtasticIconImg, + buildMeshcoreIconImg, + buildRoleButtons, + updateLegendRoleFiltersUI, + legendClickHandler, + activeRoleFilters, + hiddenProtocols, + legendRoleButtons, + legendProtocolButtons, }, }; } diff --git a/web/public/assets/js/app/node-details.js b/web/public/assets/js/app/node-details.js index a4fc7d0..894e909 100644 --- a/web/public/assets/js/app/node-details.js +++ b/web/public/assets/js/app/node-details.js @@ -178,6 +178,7 @@ function mergeNodeFields(target, record) { assignString(target, 'shortName', extractString(record, ['shortName', 'short_name'])); assignString(target, 'longName', extractString(record, ['longName', 'long_name'])); assignString(target, 'role', extractString(record, ['role'])); + assignString(target, 'protocol', extractString(record, ['protocol'])); assignString(target, 'hwModel', extractString(record, ['hwModel', 'hw_model'])); mergeModemMetadata(target, record); assignNumber(target, 'snr', extractNumber(record, ['snr'])); diff --git a/web/public/assets/js/app/node-rendering.js b/web/public/assets/js/app/node-rendering.js index bbb66a8..2894d04 100644 --- a/web/public/assets/js/app/node-rendering.js +++ b/web/public/assets/js/app/node-rendering.js @@ -79,9 +79,9 @@ export function canonicalNodeIdentifier(identifier) { /** * Render a linked long name pointing to the node detail view. * - * When ``protocol`` is Meshtastic (including null/empty per - * {@link module:protocol-helpers~isMeshtasticProtocol}) or ``"meshcore"``, the - * matching protocol icon is prepended. An anchor element is only emitted when + * When ``protocol`` is a known value (``"meshtastic"`` or ``"meshcore"``), + * the matching protocol icon is prepended. Absent or unknown protocol strings + * produce no icon prefix. An anchor element is only emitted when * ``identifier`` resolves to a non-null detail path. * * @param {string|null} longName Display name. diff --git a/web/public/assets/js/app/protocol-helpers.js b/web/public/assets/js/app/protocol-helpers.js index 355ab6e..7b4c134 100644 --- a/web/public/assets/js/app/protocol-helpers.js +++ b/web/public/assets/js/app/protocol-helpers.js @@ -21,11 +21,10 @@ export const MESHTASTIC_ICON_SRC = '/assets/img/meshtastic.svg'; export const MESHCORE_ICON_SRC = '/assets/img/meshcore.svg'; /** - * Return true when the protocol value represents Meshtastic or is absent. + * Return true when the protocol value is explicitly ``"meshtastic"``. * - * A null/undefined/empty string is treated as Meshtastic because the backend - * defaults all records to ``"meshtastic"`` and pre-existing records that - * predate the protocol column carry an implicit Meshtastic origin. + * Absent, null, or empty values return ``false`` — no default is applied. + * An icon is only shown when the protocol is positively known. * * Comparison is case-sensitive: only the lowercase value ``"meshtastic"`` * matches — mixed-case strings such as ``"Meshtastic"`` return ``false``. @@ -33,12 +32,11 @@ export const MESHCORE_ICON_SRC = '/assets/img/meshcore.svg'; * intentional. * * @param {string|null|undefined} protocol Protocol string from the API. - * @returns {boolean} Whether the protocol is (or defaults to) Meshtastic. + * @returns {boolean} Whether the protocol is explicitly Meshtastic. */ export function isMeshtasticProtocol(protocol) { - if (protocol == null) return true; - const trimmed = String(protocol).trim(); - return trimmed.length === 0 || trimmed === 'meshtastic'; + if (protocol == null) return false; + return String(protocol).trim() === 'meshtastic'; } /** @@ -83,10 +81,10 @@ export function meshcoreIconHtml() { /** * Build an HTML prefix (protocol icon plus a trailing space) for inline UI. * - * Meshtastic — including null, undefined, empty, or whitespace-only values per - * {@link isMeshtasticProtocol} — uses the Meshtastic icon. The literal - * ``"meshcore"`` uses the MeshCore icon. Any other protocol string yields an - * empty prefix (same as the pre-MeshCore behaviour for unknown stacks). + * Returns the matching icon only when the protocol is positively known: + * ``"meshtastic"`` → Meshtastic icon, ``"meshcore"`` → MeshCore icon. + * Absent, null, or unrecognised protocol strings yield an empty string — + * no default icon is assumed. * * @param {string|null|undefined} protocol Protocol string from the API. * @returns {string} HTML fragment safe to concatenate before visible text. diff --git a/web/public/assets/js/app/role-helpers.js b/web/public/assets/js/app/role-helpers.js index 5e4614b..af0032b 100644 --- a/web/public/assets/js/app/role-helpers.js +++ b/web/public/assets/js/app/role-helpers.js @@ -14,6 +14,8 @@ * limitations under the License. */ +import { isMeshcoreProtocol } from './protocol-helpers.js'; + /** * Mapping of numeric Meshtastic role identifiers to their canonical names. * @@ -81,6 +83,33 @@ export const meshcoreRoleColors = Object.freeze({ COMPANION: '#1A5498', }); +/** + * MeshCore role text colour overrides — only populated for roles whose + * background is dark enough that the default (near-black) text becomes + * illegible. Roles absent from this map inherit the page default. + * + * @type {Readonly>} + */ +export const meshcoreRoleTextColors = Object.freeze({ + COMPANION: '#e0e0e0', +}); + +/** + * Return the foreground text colour for a role badge, or ``null`` when the + * page default is acceptable. + * + * @param {*} role Raw role value from the API. + * @param {string|null|undefined} [protocol] Protocol string from the API. + * @returns {string|null} CSS colour string, or ``null`` to inherit. + */ +export function getRoleTextColor(role, protocol = null) { + if (isMeshcoreProtocol(protocol)) { + const key = getRoleKey(role); + return meshcoreRoleTextColors[key] ?? null; + } + return null; +} + /** * Return the role colour palette appropriate for the given protocol. * @@ -92,25 +121,51 @@ export const meshcoreRoleColors = Object.freeze({ * @returns {Readonly>} Role colour map. */ export function getRoleColors(protocol) { - if (protocol != null && String(protocol).trim() === 'meshcore') { - return meshcoreRoleColors; - } - return roleColors; + return isMeshcoreProtocol(protocol) ? meshcoreRoleColors : roleColors; } -export const roleRenderOrder = Object.freeze({ +/** + * Meshtastic-specific render priority order for map marker stacking. + * Higher numbers render above lower ones (LOST_AND_FOUND on top). + * + * @type {Readonly>} + */ +export const meshtasticRoleRenderOrder = Object.freeze({ CLIENT_HIDDEN: 1, SENSOR: 2, - TRACKER: 3, - CLIENT_MUTE: 4, - CLIENT: 5, - CLIENT_BASE: 6, - REPEATER: 7, - ROUTER_LATE: 8, - ROUTER: 9, - LOST_AND_FOUND: 10 + TRACKER: 4, + CLIENT_MUTE: 5, + CLIENT: 6, + CLIENT_BASE: 8, + ROUTER_LATE: 10, + REPEATER: 11, + ROUTER: 13, + LOST_AND_FOUND: 14, }); +/** + * MeshCore-specific render priority overrides. Only roles whose stacking + * order differs from the Meshtastic palette need to appear here — any role + * absent from this map falls through to {@link meshtasticRoleRenderOrder}. + * + * @type {Readonly>} + */ +export const meshcoreRoleRenderOrder = Object.freeze({ + SENSOR: 3, + COMPANION: 7, + ROOM_SERVER: 9, + REPEATER: 12, +}); + +/** + * Backward-compatible alias kept for any code that still imports + * ``roleRenderOrder`` by name. + * + * @deprecated Use {@link meshtasticRoleRenderOrder} directly. + * @type {Readonly>} + */ +export const roleRenderOrder = meshtasticRoleRenderOrder; + /** * Translate numeric identifiers or numeric strings into canonical role names. * @@ -156,22 +211,37 @@ export function getRoleKey(role) { /** * Determine the colour assigned to a role for legend badges. * + * Pass the node's ``protocol`` field to select the correct palette: MeshCore + * roles are looked up in {@link meshcoreRoleColors}; everything else falls + * back to the Meshtastic {@link roleColors} palette. + * * @param {*} role Raw role value. + * @param {string|null|undefined} [protocol] Protocol string from the API. * @returns {string} CSS colour string. */ -export function getRoleColor(role) { +export function getRoleColor(role, protocol = null) { + const colors = getRoleColors(protocol); const key = getRoleKey(role); - return roleColors[key] || roleColors.CLIENT || '#3388ff'; + return colors[key] || roleColors.CLIENT || '#3388ff'; } /** * Determine the render priority that decides marker stacking order. * + * MeshCore nodes use {@link meshcoreRoleRenderOrder} for roles that differ + * from Meshtastic; everything else falls back to + * {@link meshtasticRoleRenderOrder}. + * * @param {*} role Raw role value. + * @param {string|null|undefined} [protocol] Protocol string from the API. * @returns {number} Higher numbers render above lower ones. */ -export function getRoleRenderPriority(role) { +export function getRoleRenderPriority(role, protocol = null) { const key = getRoleKey(role); - const priority = roleRenderOrder[key]; + if (isMeshcoreProtocol(protocol)) { + const mc = meshcoreRoleRenderOrder[key]; + if (typeof mc === 'number') return mc; + } + const priority = meshtasticRoleRenderOrder[key]; return typeof priority === 'number' ? priority : 0; } diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index 7f0d03b..d0122fa 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -1709,6 +1709,29 @@ input[type="radio"] { gap: 2px; } +.legend-items--columns { + flex-direction: row; + align-items: flex-start; + gap: 8px; +} + +.legend-column { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.legend-column-header { + display: flex; + align-items: center; + gap: 4px; + font-weight: 600; + font-size: 11px; + padding: 2px 6px 4px; + color: var(--fg); +} + .legend-item { display: flex; align-items: center; @@ -1735,8 +1758,16 @@ input[type="radio"] { } .legend-item[aria-pressed="true"] { - border-color: rgba(0, 0, 0, 0.2); - background: rgba(0, 0, 0, 0.08); + border-color: #3a7bd5; + background: rgba(58, 123, 213, 0.18); + color: #1a4a9e; + font-weight: 600; +} + +.legend-protocol-toggle { + margin-top: 4px; + border-top: 1px solid rgba(0, 0, 0, 0.1); + padding-top: 5px; } .legend-swatch { @@ -1784,6 +1815,13 @@ input[type="radio"] { font-size: 12px; } +.nodes-col--protocol { + width: 20px; + min-width: 20px; + text-align: center; + padding-inline: 4px; +} + .app-footer { position: fixed; inset-block-end: 0; @@ -2150,8 +2188,14 @@ body.dark .legend-item:hover { } body.dark .legend-item[aria-pressed="true"] { - border-color: rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.16); + border-color: #5b9bd5; + background: rgba(91, 155, 213, 0.28); + color: #a8d4ff; + font-weight: 600; +} + +body.dark .legend-protocol-toggle { + border-top-color: rgba(255, 255, 255, 0.15); } body.dark .leaflet-popup-content-wrapper, diff --git a/web/views/shared/_nodes_table.erb b/web/views/shared/_nodes_table.erb index bcacaed..9ac2a8e 100644 --- a/web/views/shared/_nodes_table.erb +++ b/web/views/shared/_nodes_table.erb @@ -17,6 +17,7 @@ +