web: restore refresh and protocol buttons (#742)

* web: restore refresh and protocol buttons

* web: restore refresh and protocol buttons

* web: restore refresh and protocol buttons

* web: address review comments
This commit is contained in:
l5y
2026-04-14 16:54:57 +02:00
committed by GitHub
parent 851b2180dd
commit c08b3f2c2d
7 changed files with 407 additions and 182 deletions
@@ -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());
});
});
@@ -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);
});
});
@@ -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],
];
+151 -45
View File
@@ -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<typeof setTimeout>|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<string>} Active compound role-filter keys, each ``"<protocol>:<roleKey>"``. */
/** @type {Set<string>} Hidden role compound keys — roles in this set are excluded from display. */
const activeRoleFilters = new Set();
/** @type {Map<string, HTMLElement>} Compound key → legend button element. */
const legendRoleButtons = new Map();
@@ -827,8 +833,7 @@ export function initializeApp(config) {
return `${normalizeFilterProtocol(protocol)}:${getRoleKey(role)}`;
}
/** @type {Readonly<Record<string,string>>} 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 ``<img>`` 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;
+26 -26
View File
@@ -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<Record<string, string>>}
*/
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<Record<string, string>>}
*/
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<Record<string, number>>}
*/
export const meshcoreRoleRenderOrder = Object.freeze({
SENSOR: 3,
COMPANION: 7,
ROOM_SERVER: 9,
REPEATER: 12,
REPEATER: 3,
ROOM_SERVER: 7,
SENSOR: 9,
COMPANION: 12,
});
/**
+31 -10
View File
@@ -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;
+8 -1
View File
@@ -178,8 +178,15 @@
<button type="button" id="filterClear" class="filter-clear" aria-label="Clear filter" hidden>&times;</button>
</div>
<% end %>
<button id="protocolToggleMeshcore" type="button" class="icon-button protocol-toggle-btn" aria-label="Hide MeshCore nodes" aria-pressed="false" title="Toggle MeshCore node visibility" hidden>
<img src="/assets/img/meshcore.svg" alt="" width="20" height="20" aria-hidden="true" class="protocol-toggle-icon" loading="lazy" decoding="async" />
</button>
<button id="protocolToggleMeshtastic" type="button" class="icon-button protocol-toggle-btn" aria-label="Hide Meshtastic nodes" aria-pressed="false" title="Toggle Meshtastic node visibility" hidden>
<img src="/assets/img/meshtastic.svg" alt="" width="20" height="20" aria-hidden="true" class="protocol-toggle-icon" loading="lazy" decoding="async" />
</button>
<span id="footerActiveNodes" class="meta-active-nodes"></span>
<button id="refreshBtn" type="button">Refresh</button>
<button id="refreshBtn" type="button" title="Fetch latest data now">Refresh</button>
<button id="autorefreshToggle" type="button" aria-label="Pause auto-refresh" aria-pressed="false" title="Pause or resume automatic data refresh">⏸</button>
<span id="status" class="refresh-timestamp" aria-live="polite"></span>
</div>
<% end %>