mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-10 10:52:55 +02:00
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:
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -178,8 +178,15 @@
|
||||
<button type="button" id="filterClear" class="filter-clear" aria-label="Clear filter" hidden>×</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 %>
|
||||
|
||||
Reference in New Issue
Block a user