diff --git a/web/public/assets/js/app/__tests__/main-autorefresh.test.js b/web/public/assets/js/app/__tests__/main-autorefresh.test.js new file mode 100644 index 0000000..f258169 --- /dev/null +++ b/web/public/assets/js/app/__tests__/main-autorefresh.test.js @@ -0,0 +1,40 @@ +/* + * 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 { withApp } from './main-app-test-helpers.js'; + +// --------------------------------------------------------------------------- +// isAutorefreshPaused +// --------------------------------------------------------------------------- + +test('isAutorefreshPaused returns false by default', () => { + withApp((t) => { + assert.equal(t.isAutorefreshPaused(), false); + }); +}); + +// --------------------------------------------------------------------------- +// restartAutoRefresh is safe when called without a timer +// --------------------------------------------------------------------------- + +test('restartAutoRefresh does not throw when invoked with refreshMs 0', () => { + withApp((t) => { + assert.doesNotThrow(() => t.restartAutoRefresh()); + }); +}); diff --git a/web/public/assets/js/app/__tests__/main-filter.test.js b/web/public/assets/js/app/__tests__/main-filter.test.js index 0114000..0ea164d 100644 --- a/web/public/assets/js/app/__tests__/main-filter.test.js +++ b/web/public/assets/js/app/__tests__/main-filter.test.js @@ -66,7 +66,7 @@ test('makeRoleFilterKey SENSOR and REPEATER produce distinct keys across protoco // matchesRoleFilter — no active filters // --------------------------------------------------------------------------- -test('matchesRoleFilter returns true when no filters are active', () => { +test('matchesRoleFilter returns true when no roles are hidden', () => { withApp((t) => { t.activeRoleFilters.clear(); assert.equal(t.matchesRoleFilter({ role: 'ROUTER', protocol: 'meshtastic' }), true); @@ -75,56 +75,56 @@ test('matchesRoleFilter returns true when no filters are active', () => { }); // --------------------------------------------------------------------------- -// matchesRoleFilter — protocol-aware compound key matching +// matchesRoleFilter — exclusion-set semantics (roles in set are hidden) // --------------------------------------------------------------------------- -test('matchesRoleFilter matches meshtastic SENSOR filter for meshtastic node', () => { +test('matchesRoleFilter hides meshtastic SENSOR when in exclusion set', () => { 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', () => { +test('matchesRoleFilter does not hide meshcore SENSOR when meshtastic SENSOR is hidden', () => { 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); + t.activeRoleFilters.add('meshtastic:SENSOR'); + assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshcore' }), true); }); }); -test('matchesRoleFilter matches meshcore REPEATER filter for meshcore node', () => { +test('matchesRoleFilter hides meshcore SENSOR when in exclusion set', () => { + withApp((t) => { + t.activeRoleFilters.clear(); + t.activeRoleFilters.add('meshcore:SENSOR'); + assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshcore' }), false); + }); +}); + +test('matchesRoleFilter does not hide meshtastic SENSOR when meshcore SENSOR is hidden', () => { + withApp((t) => { + t.activeRoleFilters.clear(); + t.activeRoleFilters.add('meshcore:SENSOR'); + assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshtastic' }), true); + }); +}); + +test('matchesRoleFilter hides meshtastic REPEATER but not meshcore REPEATER', () => { + withApp((t) => { + t.activeRoleFilters.clear(); + t.activeRoleFilters.add('meshtastic:REPEATER'); + assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshtastic' }), false); + assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshcore' }), true); + }); +}); + +test('matchesRoleFilter hides meshcore REPEATER but not meshtastic REPEATER', () => { 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); + assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshcore' }), false); + assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshtastic' }), true); }); }); @@ -132,27 +132,27 @@ test('matchesRoleFilter matches meshcore REPEATER filter for meshcore node', () // matchesRoleFilter — null/absent protocol treated as meshtastic // --------------------------------------------------------------------------- -test('matchesRoleFilter treats null protocol as meshtastic for filter matching', () => { +test('matchesRoleFilter treats null protocol as meshtastic for exclusion', () => { 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 + // null-protocol node should be hidden by the meshtastic SENSOR exclusion + assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: null }), false); + // but meshcore SENSOR exclusion should not affect null-protocol nodes t.activeRoleFilters.clear(); t.activeRoleFilters.add('meshcore:SENSOR'); - assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: null }), false); + assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: null }), true); }); }); -test('matchesRoleFilter with multiple active filters returns true when any matches', () => { +test('matchesRoleFilter with multiple hidden roles hides only those roles', () => { 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); + assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshtastic' }), false); + assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshcore' }), false); + assert.equal(t.matchesRoleFilter({ role: 'ROUTER', protocol: 'meshtastic' }), true); }); }); @@ -348,7 +348,7 @@ 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'); + t.buildRoleButtons(col, { SENSOR: '#40749E', REPEATER: '#B8C4D4' }, 'meshcore'); assert.equal(col.childNodes.length, 2); }); }); @@ -357,7 +357,7 @@ 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'); + t.buildRoleButtons(col, { SENSOR: '#40749E' }, 'meshcore'); const btn = t.legendRoleButtons.get('meshcore:SENSOR'); assert.ok(btn, 'button should be in legendRoleButtons'); assert.equal(btn.dataset.role, 'SENSOR'); @@ -369,7 +369,7 @@ 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'); + t.buildRoleButtons(col, { SENSOR: '#40749E', REPEATER: '#B8C4D4' }, 'meshcore'); assert.ok(t.legendRoleButtons.has('meshcore:SENSOR')); assert.ok(t.legendRoleButtons.has('meshcore:REPEATER')); }); @@ -380,8 +380,8 @@ test('buildRoleButtons keeps meshtastic and meshcore SENSOR keys distinct', () = 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'); + t.buildRoleButtons(colMc, { SENSOR: '#40749E' }, 'meshcore'); + t.buildRoleButtons(colMt, { SENSOR: '#A8D5BA' }, 'meshtastic'); assert.ok(t.legendRoleButtons.has('meshcore:SENSOR')); assert.ok(t.legendRoleButtons.has('meshtastic:SENSOR')); assert.notEqual( @@ -391,14 +391,14 @@ test('buildRoleButtons keeps meshtastic and meshcore SENSOR keys distinct', () = }); }); -test('buildRoleButtons sets aria-pressed to false initially', () => { +test('buildRoleButtons sets aria-pressed to true initially (all visible)', () => { withApp((t) => { t.legendRoleButtons.clear(); const col = document.createElement('div'); - t.buildRoleButtons(col, { ROUTER: '#D44E14' }, 'meshtastic'); + t.buildRoleButtons(col, { ROUTER: '#ff0019' }, 'meshtastic'); const btn = t.legendRoleButtons.get('meshtastic:ROUTER'); assert.ok(btn, 'button should be in legendRoleButtons'); - assert.equal(btn.getAttribute('aria-pressed'), 'false'); + assert.equal(btn.getAttribute('aria-pressed'), 'true'); }); }); @@ -406,7 +406,7 @@ test('buildRoleButtons creates swatch child with background color', () => { withApp((t) => { t.legendRoleButtons.clear(); const col = document.createElement('div'); - t.buildRoleButtons(col, { ROUTER: '#D44E14' }, 'meshtastic'); + t.buildRoleButtons(col, { ROUTER: '#ff0019' }, 'meshtastic'); const btn = t.legendRoleButtons.get('meshtastic:ROUTER'); // swatch is the first child of the button const swatch = btn.childNodes[0]; @@ -419,7 +419,7 @@ test('buildRoleButtons creates label child with role text', () => { withApp((t) => { t.legendRoleButtons.clear(); const col = document.createElement('div'); - t.buildRoleButtons(col, { ROUTER: '#D44E14' }, 'meshtastic'); + t.buildRoleButtons(col, { ROUTER: '#ff0019' }, 'meshtastic'); const btn = t.legendRoleButtons.get('meshtastic:ROUTER'); // label is the second child of the button const label = btn.childNodes[1]; @@ -432,55 +432,28 @@ test('buildRoleButtons creates label child with role text', () => { // updateLegendRoleFiltersUI // --------------------------------------------------------------------------- -test('updateLegendRoleFiltersUI sets aria-pressed true on active role buttons', () => { +test('updateLegendRoleFiltersUI sets aria-pressed false on hidden role buttons', () => { withApp((t) => { t.legendRoleButtons.clear(); const col = document.createElement('div'); - t.buildRoleButtons(col, { SENSOR: '#4A7EB4' }, 'meshcore'); + t.buildRoleButtons(col, { SENSOR: '#40749E' }, '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', () => { +test('updateLegendRoleFiltersUI sets aria-pressed true on visible role buttons', () => { 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.legendRoleButtons.clear(); + const col = document.createElement('div'); + t.buildRoleButtons(col, { SENSOR: '#40749E' }, 'meshcore'); + const btn = t.legendRoleButtons.get('meshcore:SENSOR'); + t.activeRoleFilters.clear(); 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')); + assert.equal(btn.getAttribute('aria-pressed'), 'true'); }); }); @@ -490,3 +463,81 @@ test('updateLegendRoleFiltersUI is safe when legendContainer is null', () => { assert.doesNotThrow(() => t.updateLegendRoleFiltersUI()); }); }); + +// --------------------------------------------------------------------------- +// adjustStatsForHiddenProtocols +// --------------------------------------------------------------------------- + +test('adjustStatsForHiddenProtocols returns original stats when nothing is hidden', () => { + withApp((t) => { + t.hiddenProtocols.clear(); + const stats = { hour: 10, day: 50, week: 100, month: 200, meshcore: { hour: 2, day: 10, week: 20, month: 40 }, meshtastic: { hour: 8, day: 40, week: 80, month: 160 } }; + const result = t.adjustStatsForHiddenProtocols(stats); + assert.equal(result, stats); + }); +}); + +test('adjustStatsForHiddenProtocols subtracts meshcore counts when meshcore hidden', () => { + withApp((t) => { + t.hiddenProtocols.clear(); + t.hiddenProtocols.add('meshcore'); + const stats = { hour: 10, day: 50, week: 100, month: 200, meshcore: { hour: 2, day: 10, week: 20, month: 40 }, meshtastic: { hour: 8, day: 40, week: 80, month: 160 } }; + const result = t.adjustStatsForHiddenProtocols(stats); + assert.equal(result.week, 80); + assert.equal(result.day, 40); + assert.equal(result.month, 160); + assert.equal(result.hour, 8); + }); +}); + +test('adjustStatsForHiddenProtocols subtracts meshtastic counts when meshtastic hidden', () => { + withApp((t) => { + t.hiddenProtocols.clear(); + t.hiddenProtocols.add('meshtastic'); + const stats = { hour: 10, day: 50, week: 100, month: 200, meshcore: { hour: 2, day: 10, week: 20, month: 40 }, meshtastic: { hour: 8, day: 40, week: 80, month: 160 } }; + const result = t.adjustStatsForHiddenProtocols(stats); + assert.equal(result.week, 20); + assert.equal(result.day, 10); + }); +}); + +test('adjustStatsForHiddenProtocols subtracts both when both hidden', () => { + withApp((t) => { + t.hiddenProtocols.clear(); + t.hiddenProtocols.add('meshcore'); + t.hiddenProtocols.add('meshtastic'); + const stats = { hour: 10, day: 50, week: 100, month: 200, meshcore: { hour: 2, day: 10, week: 20, month: 40 }, meshtastic: { hour: 8, day: 40, week: 80, month: 160 } }; + const result = t.adjustStatsForHiddenProtocols(stats); + assert.equal(result.week, 0); + assert.equal(result.day, 0); + }); +}); + +test('adjustStatsForHiddenProtocols floors at zero', () => { + withApp((t) => { + t.hiddenProtocols.clear(); + t.hiddenProtocols.add('meshcore'); + const stats = { hour: 1, day: 5, week: 10, month: 20, meshcore: { hour: 50, day: 50, week: 50, month: 50 } }; + const result = t.adjustStatsForHiddenProtocols(stats); + assert.equal(result.week, 0); + assert.equal(result.day, 0); + }); +}); + +test('adjustStatsForHiddenProtocols handles null stats gracefully', () => { + withApp((t) => { + t.hiddenProtocols.add('meshcore'); + assert.equal(t.adjustStatsForHiddenProtocols(null), null); + assert.equal(t.adjustStatsForHiddenProtocols(undefined), undefined); + }); +}); + +test('adjustStatsForHiddenProtocols handles missing protocol bucket', () => { + withApp((t) => { + t.hiddenProtocols.clear(); + t.hiddenProtocols.add('meshcore'); + const stats = { hour: 10, day: 50, week: 100, month: 200 }; + const result = t.adjustStatsForHiddenProtocols(stats); + assert.equal(result.week, 100); + }); +}); 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 43ce273..373a3e0 100644 --- a/web/public/assets/js/app/__tests__/role-helpers.test.js +++ b/web/public/assets/js/app/__tests__/role-helpers.test.js @@ -62,35 +62,35 @@ test('render priority uses canonical role keys and defaults to zero for unknowns }); test('render priority is protocol-aware for shared roles', () => { - // SENSOR: meshtastic=2, meshcore=3 + // SENSOR: meshtastic=2, meshcore=9 assert.equal(getRoleRenderPriority('SENSOR', 'meshtastic'), 2); - assert.equal(getRoleRenderPriority('SENSOR', 'meshcore'), 3); + assert.equal(getRoleRenderPriority('SENSOR', 'meshcore'), 9); assert.ok(getRoleRenderPriority('SENSOR', 'meshcore') > getRoleRenderPriority('SENSOR', 'meshtastic')); - // REPEATER: meshtastic=11, meshcore=12 + // REPEATER: meshtastic=11, meshcore=3 assert.equal(getRoleRenderPriority('REPEATER', 'meshtastic'), 11); - assert.equal(getRoleRenderPriority('REPEATER', 'meshcore'), 12); - assert.ok(getRoleRenderPriority('REPEATER', 'meshcore') > getRoleRenderPriority('REPEATER', 'meshtastic')); + assert.equal(getRoleRenderPriority('REPEATER', 'meshcore'), 3); + assert.ok(getRoleRenderPriority('REPEATER', 'meshtastic') > getRoleRenderPriority('REPEATER', 'meshcore')); }); test('render priority meshcore-exclusive roles have defined priorities', () => { - assert.equal(getRoleRenderPriority('COMPANION', 'meshcore'), 7); - assert.equal(getRoleRenderPriority('ROOM_SERVER', 'meshcore'), 9); + assert.equal(getRoleRenderPriority('COMPANION', 'meshcore'), 12); + assert.equal(getRoleRenderPriority('ROOM_SERVER', 'meshcore'), 7); }); test('render priority respects the full bottom-to-top order', () => { const order = [ ['CLIENT_HIDDEN', null], ['SENSOR', 'meshtastic'], - ['SENSOR', 'meshcore'], + ['REPEATER', 'meshcore'], ['TRACKER', null], ['CLIENT_MUTE', null], ['CLIENT', null], - ['COMPANION', 'meshcore'], - ['CLIENT_BASE', null], ['ROOM_SERVER', 'meshcore'], + ['CLIENT_BASE', null], + ['SENSOR', 'meshcore'], ['ROUTER_LATE', null], ['REPEATER', 'meshtastic'], - ['REPEATER', 'meshcore'], + ['COMPANION', 'meshcore'], ['ROUTER', null], ['LOST_AND_FOUND', null], ]; diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 6fb9d32..4c14af6 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -133,6 +133,9 @@ 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'); const filterInput = document.getElementById('filterInput'); const filterClearButton = document.getElementById('filterClear'); const shortInfoTemplate = document.getElementById('shortInfoOverlayTemplate'); @@ -248,6 +251,7 @@ export function initializeApp(config) { /** @type {ReturnType|null} */ let refreshTimer = null; + let autorefreshPaused = false; let activeStatsRequestId = 0; /** @@ -453,7 +457,9 @@ export function initializeApp(config) { } // Only arm the timer when a positive interval is configured; a zero or // negative value means auto-refresh is intentionally disabled. - if (REFRESH_MS > 0) { + // When the user has explicitly paused auto-refresh, skip arming the timer + // entirely so no background API requests are made. + if (REFRESH_MS > 0 && !autorefreshPaused) { refreshTimer = setInterval(refresh, REFRESH_MS); } } @@ -772,7 +778,7 @@ export function initializeApp(config) { }); } - /** @type {Set} Active compound role-filter keys, each ``":"``. */ + /** @type {Set} Hidden role compound keys — roles in this set are excluded from display. */ const activeRoleFilters = new Set(); /** @type {Map} Compound key → legend button element. */ const legendRoleButtons = new Map(); @@ -827,8 +833,7 @@ export function initializeApp(config) { 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. @@ -1465,18 +1470,14 @@ export function initializeApp(config) { */ function updateLegendRoleFiltersUI() { const hasFilters = activeRoleFilters.size > 0; - // legendRoleButtons is keyed by compound key ("protocol:roleKey") + // activeRoleFilters is a *hidden-roles* set: roles present in the set are + // hidden. Buttons show aria-pressed="true" when the role is *visible* + // (i.e. NOT in the hidden set) so that the default all-visible state + // highlights every button. legendRoleButtons.forEach((button, compoundKey) => { if (!button) return; - 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}`; + const isHidden = activeRoleFilters.has(compoundKey); + button.setAttribute('aria-pressed', isHidden ? 'false' : 'true'); }); if (legendContainer) { if (hasFilters || hiddenProtocols.size > 0) { @@ -1485,9 +1486,37 @@ export function initializeApp(config) { legendContainer.removeAttribute('data-has-active-filters'); } } + updateMetaProtocolToggleUI(); updateLegendToggleState(); } + /** + * Sync the meta-row protocol toggle buttons with the current + * {@link hiddenProtocols} state. + * + * When a protocol is hidden the button's ```` receives a greyscale + * filter and ``aria-pressed`` is set to ``"true"``. + * + * @returns {void} + */ + function updateMetaProtocolToggleUI() { + /** @type {Array<{btn: HTMLElement|null, protocol: string, name: string}>} */ + const toggles = [ + { btn: protocolToggleMeshcore, protocol: 'meshcore', name: 'MeshCore' }, + { btn: protocolToggleMeshtastic, protocol: 'meshtastic', name: 'Meshtastic' }, + ]; + toggles.forEach(({ btn, protocol, name }) => { + if (!btn) return; + const isHidden = hiddenProtocols.has(protocol); + btn.setAttribute('aria-pressed', isHidden ? 'true' : 'false'); + btn.setAttribute('aria-label', isHidden ? `Show ${name} nodes` : `Hide ${name} nodes`); + const img = btn.querySelector('.protocol-toggle-icon'); + if (img) { + img.style.filter = isHidden ? 'grayscale(1) opacity(0.4)' : ''; + } + }); + } + /** * Toggle the visibility filter for a role+protocol combination. * @@ -1525,7 +1554,7 @@ export function initializeApp(config) { item.className = 'legend-item'; colEl.appendChild(item); item.type = 'button'; - item.setAttribute('aria-pressed', 'false'); + item.setAttribute('aria-pressed', 'true'); item.dataset.role = role; item.dataset.protocol = protocol; const swatch = document.createElement('span'); @@ -1540,6 +1569,7 @@ export function initializeApp(config) { item.addEventListener('click', legendClickHandler(event => { const exclusive = event.metaKey || event.ctrlKey; if (exclusive) { + // Ctrl/Cmd+Click: hide only this role (all others become visible). activeRoleFilters.clear(); activeRoleFilters.add(compoundKey); updateLegendRoleFiltersUI(); @@ -1616,28 +1646,7 @@ export function initializeApp(config) { buildRoleButtons(meshcoreCol, meshcoreRoleColors, 'meshcore'); buildRoleButtons(meshtasticCol, roleColors, 'meshtastic'); - // --- MeshCore column footer: protocol hide toggle --- - legendProtocolButtons.clear(); - const buildProtocolToggle = (protocol, col) => { - 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); - }; - buildProtocolToggle('meshcore', meshcoreCol); - - // --- Meshtastic column: line toggles then protocol hide toggle at bottom --- + // --- Meshtastic column: line toggles at bottom --- neighborLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-neighbors', meshtasticCol); neighborLinesToggleButton.type = 'button'; neighborLinesToggleButton.addEventListener('click', legendClickHandler(() => { @@ -1652,9 +1661,6 @@ export function initializeApp(config) { })); updateTraceLinesToggleState(); - // Hide Meshtastic toggle at the very bottom of the Meshtastic column. - buildProtocolToggle('meshtastic', meshtasticCol); - updateLegendRoleFiltersUI(); // --- Clear filters — full-width below the two columns --- @@ -3253,8 +3259,24 @@ export function initializeApp(config) { }); const enrichedLogEntries = attachNodeContextToLogEntries(logEntries); + // When a protocol is hidden, exclude its entries from the chat display. + // Entries without a resolved node are kept; entries with a node but a + // null/missing protocol are treated as meshtastic (the default protocol). + const protocolVisibleEntries = hiddenProtocols.size > 0 + ? enrichedLogEntries.filter(e => { + if (!e || !e.node) return true; + const proto = normalizeFilterProtocol(e.node.protocol); + return !hiddenProtocols.has(proto); + }) + : enrichedLogEntries; + const protocolVisibleChannels = hiddenProtocols.size > 0 + ? channels.filter(ch => { + const proto = ch.protocol ? normalizeFilterProtocol(ch.protocol) : null; + return !proto || !hiddenProtocols.has(proto); + }) + : channels; const { logEntries: filteredLogEntries, channels: filteredChannels } = filterChatModel( - { logEntries: enrichedLogEntries, channels }, + { logEntries: protocolVisibleEntries, channels: protocolVisibleChannels }, filterQuery ); @@ -4323,7 +4345,7 @@ export function initializeApp(config) { function matchesRoleFilter(node) { if (!activeRoleFilters.size) return true; const compoundKey = makeRoleFilterKey(node && node.role, node && node.protocol); - return activeRoleFilters.has(compoundKey); + return !activeRoleFilters.has(compoundKey); } /** @@ -4354,6 +4376,31 @@ export function initializeApp(config) { filterClearButton.hidden = !hasValue; } + /** + * Return a copy of the stats object with totals reduced by the counts of + * any protocols the user has explicitly hidden. + * + * Per-protocol sub-objects are left untouched so legend column counts and + * visibility decisions still use the raw server values. + * + * @param {Object|null} stats Normalised stats from ``/api/stats``. + * @returns {Object|null} Adjusted stats (new object) or the original if nothing is hidden. + */ + function adjustStatsForHiddenProtocols(stats) { + if (!hiddenProtocols.size || !stats) return stats; + const adjusted = { ...stats }; + for (const protocol of hiddenProtocols) { + const bucket = stats[protocol]; + if (!bucket || typeof bucket !== 'object') continue; + for (const key of ['hour', 'day', 'week', 'month']) { + if (typeof adjusted[key] === 'number' && typeof bucket[key] === 'number') { + adjusted[key] = Math.max(0, adjusted[key] - bucket[key]); + } + } + } + return adjusted; + } + /** * Apply text and role filters to the node list and re-render outputs. * @@ -4376,15 +4423,16 @@ export function initializeApp(config) { // Show an immediate local estimate for the title so it doesn't flicker // to (0) while waiting for the async /api/stats response. const localStats = computeLocalActiveNodeStats(allNodes, nowSec); - updateTitleCount(localStats); + updateTitleCount(adjustStatsForHiddenProtocols(localStats)); // Title, legend, footer, and visibility are then corrected by /api/stats // which provides the authoritative, uncapped counts. const statsRequestId = ++activeStatsRequestId; void fetchActiveNodeStats({ nodes: allNodes, nowSeconds: nowSec }).then(stats => { if (statsRequestId !== activeStatsRequestId) return; - updateTitleCount(stats); + const visibleStats = adjustStatsForHiddenProtocols(stats); + updateTitleCount(visibleStats); updateLegendProtocolCounts(stats); - updateFooterStats(stats); + updateFooterStats(visibleStats); applyProtocolVisibility(stats); }); updateSortIndicators(); @@ -4532,6 +4580,54 @@ export function initializeApp(config) { refreshBtn.addEventListener('click', refresh); } + // --- Auto-refresh play/pause toggle --- + if (autorefreshToggle) { + autorefreshToggle.addEventListener('click', () => { + autorefreshPaused = !autorefreshPaused; + if (autorefreshPaused) { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + 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'); + autorefreshToggle.setAttribute('aria-pressed', 'false'); + refresh(); + restartAutoRefresh(); + } + }); + } + + // --- Meta-row protocol toggle buttons --- + /** + * Wire a meta-row protocol toggle button to the shared + * {@link hiddenProtocols} set. + * + * @param {HTMLElement|null} btn Button element. + * @param {string} protocol Protocol token (``'meshcore'`` or ``'meshtastic'``). + * @returns {void} + */ + function setupMetaProtocolToggle(btn, protocol) { + if (!btn) return; + btn.addEventListener('click', () => { + if (hiddenProtocols.has(protocol)) { + hiddenProtocols.delete(protocol); + } else { + hiddenProtocols.add(protocol); + } + updateMetaProtocolToggleUI(); + updateLegendRoleFiltersUI(); + applyFilter(); + }); + } + setupMetaProtocolToggle(protocolToggleMeshcore, 'meshcore'); + setupMetaProtocolToggle(protocolToggleMeshtastic, 'meshtastic'); + /** * Update the page/tab title with the total active-node count for the past 7 days. * @@ -4589,6 +4685,12 @@ export function initializeApp(config) { if (meshcoreColEl) meshcoreColEl.style.display = meshcoreWeek === 0 ? 'none' : ''; if (meshtasticColEl) meshtasticColEl.style.display = meshtasticWeek === 0 ? 'none' : ''; + // Show protocol toggle buttons only when both protocols have weekly + // activity — filtering is pointless when only one protocol is present. + const bothActive = meshcoreWeek > 0 && meshtasticWeek > 0; + if (protocolToggleMeshcore) protocolToggleMeshcore.hidden = !bothActive; + if (protocolToggleMeshtastic) protocolToggleMeshtastic.hidden = !bothActive; + // Charts is meshtastic-only; hide the nav link when no meshtastic activity. document.querySelectorAll('a[href="/charts"]').forEach(el => { el.style.display = meshtasticWeek === 0 ? 'none' : ''; @@ -4628,6 +4730,10 @@ export function initializeApp(config) { updateFooterStats, applyProtocolVisibility, restartAutoRefresh, + updateMetaProtocolToggleUI, + adjustStatsForHiddenProtocols, + /** Whether auto-refresh is currently paused. */ + isAutorefreshPaused: () => autorefreshPaused, /** Inject mock count span elements for legend protocol count tests. */ _setProtocolCountElements(mc, mt) { meshcoreCountEl = mc; diff --git a/web/public/assets/js/app/role-helpers.js b/web/public/assets/js/app/role-helpers.js index af0032b..737b8b9 100644 --- a/web/public/assets/js/app/role-helpers.js +++ b/web/public/assets/js/app/role-helpers.js @@ -42,28 +42,28 @@ export const roleIdToName = Object.freeze({ }); /** - * Meshtastic role colour palette — warm yellow-green to burnt-orange gradient - * that provides higher contrast than the previous blue-tinted palette, making - * role distinctions more legible on both light and dark map tiles. + * Meshtastic role colour palette — broad-spectrum blue-green-yellow-red gradient + * with a distinctive lavender accent for {@link LOST_AND_FOUND}. Adopted from + * the meshenvy fork to keep visual consistency across instances. * - * Updated from the original blue-cool palette (see PR #657) to improve - * readability alongside the MeshCore grey-blue palette. + * The cool-blue low end is differentiated from the MeshCore steel-grey palette + * by saturation (51 %+ here vs 18 % for MeshCore) and an 8-degree hue offset. * * Firmware 2.7.10 / Android 2.7.0 roles (see issue #177). * * @type {Readonly>} */ export const roleColors = Object.freeze({ - CLIENT_HIDDEN: '#A8D8B0', - SENSOR: '#B2D880', - TRACKER: '#C8D866', - CLIENT_MUTE: '#DFCF52', - CLIENT: '#ECC044', - CLIENT_BASE: '#F0A834', - REPEATER: '#F08824', - ROUTER_LATE: '#E86C1C', - ROUTER: '#D44E14', - LOST_AND_FOUND: '#C0300C', + CLIENT_HIDDEN: '#A9CBE8', + SENSOR: '#A8D5BA', + TRACKER: '#99e67f', + CLIENT_MUTE: '#bcef75', + CLIENT: '#f3ef74', + CLIENT_BASE: '#fdbf79', + REPEATER: '#fa997b', + ROUTER_LATE: '#ff5061', + ROUTER: '#ff0019', + LOST_AND_FOUND: '#C3A8E8', }); /** @@ -77,10 +77,10 @@ export const roleColors = Object.freeze({ * @type {Readonly>} */ export const meshcoreRoleColors = Object.freeze({ - REPEATER: '#C8D0DC', - ROOM_SERVER: '#8AAAC6', - SENSOR: '#4A7EB4', - COMPANION: '#1A5498', + REPEATER: '#B8C4D4', + ROOM_SERVER: '#7A9EBC', + SENSOR: '#40749E', + COMPANION: '#164A88', }); /** @@ -144,17 +144,17 @@ export const meshtasticRoleRenderOrder = Object.freeze({ }); /** - * 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}. + * MeshCore-specific render priority overrides. Bottom-up stacking order: + * REPEATER → ROOM_SERVER → SENSOR → COMPANION (top), so companion nodes + * are always visible above infrastructure roles. * * @type {Readonly>} */ export const meshcoreRoleRenderOrder = Object.freeze({ - SENSOR: 3, - COMPANION: 7, - ROOM_SERVER: 9, - REPEATER: 12, + REPEATER: 3, + ROOM_SERVER: 7, + SENSOR: 9, + COMPANION: 12, }); /** diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index 686cc7a..7f6b1b8 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -1595,6 +1595,24 @@ button:not(.chat-tab):not(.sort-button) { line-height: 1; } +.protocol-toggle-btn { + width: 32px; + height: 32px; + border: 1px solid #ccc; + background: #fff; + border-radius: 6px; + cursor: pointer; +} + +.protocol-toggle-btn[aria-pressed="true"] { + background: #f0f0f0; +} + +.protocol-toggle-icon { + display: block; + transition: filter 0.15s ease; +} + button:not(.chat-tab):not(.sort-button):hover { background: #f6f6f6; } @@ -1782,12 +1800,6 @@ input[type="radio"] { font-weight: 600; } -.legend-protocol-toggle { - margin-top: 4px; - border-top: 1px solid rgba(0, 0, 0, 0.1); - padding-top: 5px; -} - .legend-swatch { display: inline-block; width: 12px; @@ -2083,6 +2095,19 @@ body.dark button:not(.chat-tab):not(.sort-button):hover { background: #444; } +body.dark .protocol-toggle-btn { + background: #333; + border-color: #444; +} + +body.dark .protocol-toggle-btn:hover { + background: #444; +} + +body.dark .protocol-toggle-btn[aria-pressed="true"] { + background: #2a2a2a; +} + body.dark .sort-button { background: none; border: none; @@ -2129,10 +2154,6 @@ body.dark .legend-item[aria-pressed="true"] { font-weight: 600; } -body.dark .legend-protocol-toggle { - border-top-color: rgba(255, 255, 255, 0.15); -} - body.dark .leaflet-popup-content-wrapper, body.dark .leaflet-popup-tip { background: #333; diff --git a/web/views/layouts/app.erb b/web/views/layouts/app.erb index 253c062..9108760 100644 --- a/web/views/layouts/app.erb +++ b/web/views/layouts/app.erb @@ -178,8 +178,15 @@ <% end %> + + - + + <% end %>