web: prepare frontend for multi protocol (#657)

* web: prepare frontend for multi protocol

* web: address review comments

* fix: address review feedback on multi-protocol frontend prep

- Replace iconHtml/innerHTML in renderChatTabs with iconSrc + DOM APIs;
  the img element is now built attribute-by-attribute so no innerHTML trust
  boundary exists even if iconSrc were to receive external input
- Add MESHTASTIC_ICON_SRC / MESHCORE_ICON_SRC constants to protocol-helpers;
  meshtasticIconHtml() and meshcoreIconHtml() reference these so the asset
  path has a single source of truth
- Use meshtasticIconHtml() in the map legend via a temp span to eliminate
  the 7-setAttribute duplication
- Add getRoleColors(protocol) to role-helpers, making meshcoreRoleColors
  reachable through a tested code path rather than a dead export
- Rename __test__ export in main.js to __testUtils for consistency
- Add JSDoc cross-reference on normalizeNodeNameValue vs stringOrNull


* web: address review comments

* web: address review comments

* web: address review comments
This commit is contained in:
l5y
2026-03-30 08:21:39 +02:00
committed by GitHub
parent a5d0008555
commit 874e81ab8b
16 changed files with 1077 additions and 112 deletions
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="150" height="150">
<path d="M0 0 C49.5 0 99 0 150 0 C150 49.5 150 99 150 150 C100.5 150 51 150 0 150 C0 100.5 0 51 0 0 Z " fill="#010101" transform="translate(0,0)"/>
<path d="M0 0 C1.53527344 -0.01836914 1.53527344 -0.01836914 3.1015625 -0.03710938 C4.07867187 -0.03904297 5.05578125 -0.04097656 6.0625 -0.04296875 C7.41021484 -0.04913208 7.41021484 -0.04913208 8.78515625 -0.05541992 C11 0.1875 11 0.1875 13 2.1875 C13.82833738 4.60048282 14.56308409 6.98402953 15.25 9.4375 C15.46180908 10.17532715 15.67361816 10.9131543 15.8918457 11.67333984 C18.46626793 20.70198673 20.91045751 29.76786964 23.35351562 38.83276367 C23.93407205 40.94736589 24.54518448 43.04989666 25.16796875 45.15234375 C25.94846312 47.99949928 26.17763687 50.25649158 26 53.1875 C29.53719962 52.00843346 29.92947089 51.20820225 31.9074707 48.16186523 C32.46847977 47.30684921 33.02948883 46.45183319 33.60749817 45.57090759 C34.20753159 44.63871902 34.807565 43.70653046 35.42578125 42.74609375 C36.05099167 41.78985611 36.67620209 40.83361847 37.32035828 39.84840393 C39.32548415 36.77890469 41.31945998 33.70235756 43.3125 30.625 C45.29667428 27.57388909 47.28318817 24.5243371 49.27250671 21.47657776 C50.51031178 19.57885081 51.74487721 17.67900549 52.97566223 15.77671814 C54.63024288 13.23282832 56.30863092 10.70704307 58 8.1875 C58.51591705 7.39784851 59.03183411 6.60819702 59.56338501 5.7946167 C63.24847691 0.44336876 63.24847691 0.44336876 66.76074219 -0.75244141 C67.68016602 -0.72327637 68.59958984 -0.69411133 69.546875 -0.6640625 C71.06087891 -0.63892578 71.06087891 -0.63892578 72.60546875 -0.61328125 C73.64316406 -0.55527344 74.68085937 -0.49726562 75.75 -0.4375 C77.32974609 -0.40849609 77.32974609 -0.40849609 78.94140625 -0.37890625 C86.66153067 -0.15096933 86.66153067 -0.15096933 89 2.1875 C88.78535972 4.58877596 88.44690562 6.8263515 88 9.1875 C87.73732629 10.743369 87.47593231 12.2994544 87.21557617 13.85571289 C86.90917246 15.62405721 86.60157143 17.39219436 86.29296875 19.16015625 C86.12630203 20.11909286 85.95963531 21.07802948 85.78791809 22.06602478 C85.43456299 24.09757235 85.08068534 26.12902908 84.72631836 28.16040039 C84.19830027 31.18807095 83.67225319 34.21607876 83.14648438 37.24414062 C82.45475573 41.22681206 81.75398458 45.20776121 81.04833984 49.18798828 C80.32688931 53.26991242 79.64519057 57.3561796 78.98681641 61.44873047 C78.65403741 63.44369592 78.3211948 65.43865075 77.98828125 67.43359375 C77.84722458 68.35680923 77.70616791 69.28002472 77.56083679 70.23121643 C76.91250415 74.03349339 76.41881054 76.60462503 74.14404297 79.77050781 C71.74234511 81.35778342 70.55833969 81.67049074 67.70703125 81.61328125 C66.91232422 81.60490234 66.11761719 81.59652344 65.29882812 81.58789062 C64.47833984 81.55888672 63.65785156 81.52988281 62.8125 81.5 C61.98041016 81.49033203 61.14832031 81.48066406 60.29101562 81.47070312 C54.16843594 81.35593594 54.16843594 81.35593594 53 80.1875 C52.52795585 73.46087084 53.58284962 67.3308395 54.93261719 60.77319336 C55.60813469 57.46821193 56 54.58252922 56 51.1875 C55.54149658 51.88311035 55.08299316 52.5787207 54.6105957 53.29541016 C52.88950164 55.90278622 51.16517913 58.50798983 49.4387207 61.11181641 C48.33233335 62.78255005 47.22960887 64.45570746 46.12695312 66.12890625 C44.72286979 68.24464826 43.30312124 70.35009394 41.86645508 72.44384766 C40.78976022 74.02656797 39.741896 75.6316021 38.7800293 77.28662109 C37.71875 78.9375 37.71875 78.9375 35 81.1875 C31.03043345 81.88673061 27.20146456 81.75375793 23.1875 81.5625 C21.57584961 81.52189453 21.57584961 81.52189453 19.93164062 81.48046875 C17.28570432 81.40991045 14.6438876 81.31138756 12 81.1875 C6.74936442 71.19139204 3.55442915 60.34690926 2 49.1875 C1.34 49.1875 0.68 49.1875 0 49.1875 C-0.03738281 49.9403125 -0.07476562 50.693125 -0.11328125 51.46875 C-0.51682086 57.98868401 -1.23629152 64.38456784 -2.375 70.8125 C-2.48884033 71.49892578 -2.60268066 72.18535156 -2.7199707 72.89257812 C-3.41310966 76.60288301 -3.97093895 78.87525936 -7 81.1875 C-9.06884766 81.54394531 -9.06884766 81.54394531 -11.4140625 81.515625 C-12.25710937 81.51046875 -13.10015625 81.5053125 -13.96875 81.5 C-14.8453125 81.479375 -15.721875 81.45875 -16.625 81.4375 C-17.50929688 81.43234375 -18.39359375 81.4271875 -19.3046875 81.421875 C-25.84588509 81.34161491 -25.84588509 81.34161491 -27 80.1875 C-27.31453657 73.34879702 -26.46185737 67.1360847 -25.13671875 60.44140625 C-24.94406937 59.43142075 -24.75141998 58.42143524 -24.55293274 57.38084412 C-23.93887716 54.1690146 -23.31372179 50.95947739 -22.6875 47.75 C-21.87557047 43.54360648 -21.06853993 39.33628201 -20.26171875 35.12890625 C-20.06488495 34.11059723 -19.86805115 33.09228821 -19.66525269 32.04312134 C-18.3031104 24.94644791 -17.06375396 17.83729619 -16.00382996 10.68885803 C-15.46327937 7.2164468 -14.95513495 4.15342185 -13 1.1875 C-9.11308052 -0.75595974 -4.27485419 0.00269197 0 0 Z " fill="#FBFBFB" transform="translate(44,33.8125)"/>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="512" height="512" viewBox="0 0 512 512" xml:space="preserve">
<desc>Created with Fabric.js 4.6.0</desc>
<defs>
</defs>
<g transform="matrix(1 0 0 1 256 256)" id="xYQ9Gk9Jwpgj_HMOXB3F_" >
<path style="stroke: rgb(213,130,139); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(103,234,148); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-256, -256)" d="M 0 0 L 512 0 L 512 512 L 0 512 z" stroke-linecap="round" />
</g>
<g transform="matrix(1.79 0 0 1.79 313.74 258.36)" id="1xBsk2n9FZp60Rz1O-ceJ" >
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44,45,60); fill-rule: evenodd; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-250.97, -362.41)" d="M 250.908 330.267 L 193.126 415.005 L 180.938 406.694 L 244.802 313.037 C 246.174 311.024 248.453 309.819 250.889 309.816 C 253.326 309.814 255.606 311.015 256.982 313.026 L 320.994 406.536 L 308.821 414.869 L 250.908 330.267 Z" stroke-linecap="round" />
</g>
<g transform="matrix(1.81 0 0 1.81 145 256.15)" id="KxN7E9YpbyPgz0S4z4Cl6" >
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44,45,60); fill-rule: evenodd; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-115.14, -528.06)" d="M 87.642 581.398 L 154.757 482.977 L 142.638 474.713 L 75.523 573.134 L 87.642 581.398 Z" stroke-linecap="round" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -56,7 +56,10 @@ class MockFragment {
class MockElement {
constructor(tagName) {
this.tagName = tagName.toUpperCase();
// children mirrors HTMLElement.children: element nodes only.
this.children = [];
// childNodes mirrors HTMLElement.childNodes: all nodes including text.
this.childNodes = [];
this.attributes = new Map();
this.dataset = {};
this.classList = new MockClassList();
@@ -67,18 +70,26 @@ class MockElement {
}
appendChild(node) {
this.children.push(node);
this.childNodes.push(node);
if (node instanceof MockElement) {
this.children.push(node);
}
return node;
}
replaceChildren(...nodes) {
this.children = [];
this.childNodes = [];
for (const node of nodes) {
if (!node) continue;
if (node.isFragment && Array.isArray(node.children)) {
this.children.push(...node.children);
this.childNodes.push(...node.children);
} else {
this.children.push(node);
this.childNodes.push(node);
if (node instanceof MockElement) {
this.children.push(node);
}
}
}
}
@@ -113,6 +124,13 @@ class MockElement {
}
}
class MockTextNode {
constructor(text) {
this.textContent = String(text);
this.nodeType = 3;
}
}
function createMockDocument() {
return {
createElement(tag) {
@@ -120,6 +138,9 @@ function createMockDocument() {
},
createDocumentFragment() {
return new MockFragment();
},
createTextNode(text) {
return new MockTextNode(text);
}
};
}
@@ -192,3 +213,42 @@ test('renderChatTabs clears container when no tabs exist', () => {
assert.equal(container.children.length, 0);
assert.equal(container.dataset.activeTab, '');
});
test('renderChatTabs renders icon img child when tab.iconSrc is provided', () => {
const document = createMockDocument();
const container = new MockElement('div');
const tabs = [
{ id: 'channel-0', label: 'LongFast', iconSrc: '/assets/img/meshtastic.svg' }
];
renderChatTabs({ document, container, tabs });
const [tabList] = container.children;
const button = tabList.children[0];
// Button has one element child (the icon <img>) and one text node — two childNodes total.
assert.equal(button.children.length, 1, 'should have exactly one element child (icon img)');
assert.equal(button.childNodes.length, 2, 'should have two child nodes (icon img + text node)');
const iconImg = button.children[0];
assert.equal(iconImg.tagName, 'IMG', 'first element child should be an img');
assert.equal(iconImg.getAttribute('src'), '/assets/img/meshtastic.svg', 'img src should match iconSrc');
assert.equal(iconImg.getAttribute('aria-hidden'), 'true', 'img should be hidden from AT');
const textNode = button.childNodes[1];
assert.equal(textNode.nodeType, 3, 'second child node should be a text node');
assert.equal(textNode.textContent, 'LongFast');
});
test('renderChatTabs uses textContent when no iconSrc is provided', () => {
const document = createMockDocument();
const container = new MockElement('div');
const tabs = [{ id: 'log', label: 'Log' }];
renderChatTabs({ document, container, tabs });
const [tabList] = container.children;
const button = tabList.children[0];
assert.equal(button.textContent, 'Log');
// No icon child elements
assert.equal(button.children.length, 0);
});
@@ -211,6 +211,32 @@ class MockElement {
this.childNodes = [String(value)];
}
/**
* Register an event handler for the given event type.
*
* @param {string} event Event type.
* @param {Function} handler Callback to invoke.
* @returns {void}
*/
addEventListener(event, handler) {
if (!this._listeners) this._listeners = new Map();
if (!this._listeners.has(event)) this._listeners.set(event, []);
this._listeners.get(event).push(handler);
}
/**
* Remove a previously registered event handler.
*
* @param {string} event Event type.
* @param {Function} handler Callback to remove.
* @returns {void}
*/
removeEventListener(event, handler) {
if (!this._listeners || !this._listeners.has(event)) return;
const remaining = this._listeners.get(event).filter(h => h !== handler);
this._listeners.set(event, remaining);
}
/**
* Very small querySelectorAll implementation that supports ``.class`` lookups
* used in unit tests.
@@ -303,6 +329,9 @@ export function createDomEnvironment(options = {}) {
createElement(tagName) {
return new MockElement(tagName, registry);
},
createTextNode(text) {
return String(text);
},
createDocumentFragment() {
const fragment = new MockElement('fragment', null);
fragment.childNodes = [];
@@ -0,0 +1,230 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDomEnvironment } from './dom-environment.js';
import { initializeApp } from '../main.js';
const MINIMAL_CONFIG = Object.freeze({
channel: 'Primary',
frequency: '915MHz',
refreshMs: 0,
refreshIntervalSeconds: 30,
chatEnabled: true,
mapCenter: { lat: 0, lon: 0 },
mapZoom: null,
maxDistanceKm: 0,
tileFilters: { light: '', dark: '' },
instancesFeatureEnabled: false,
instanceDomain: null,
snapshotWindowSeconds: 3600,
});
/**
* Spin up a minimal DOM environment, call initializeApp with a stub config,
* and return the inner test utilities alongside an env.cleanup() handle.
*
* @returns {{ testUtils: Object, cleanup: Function }}
*/
function setupApp() {
const env = createDomEnvironment({ includeBody: true });
// themeToggle is accessed without a null guard in initializeApp.
env.createElement('button', 'themeToggle');
const { _testUtils } = initializeApp(MINIMAL_CONFIG);
return { testUtils: _testUtils, cleanup: env.cleanup.bind(env) };
}
// --- normalizeOverlaySource ---
test('normalizeOverlaySource propagates string protocol field', () => {
const { testUtils, cleanup } = setupApp();
try {
const result = testUtils.normalizeOverlaySource({ protocol: 'meshcore' });
assert.equal(result.protocol, 'meshcore');
} finally {
cleanup();
}
});
test('normalizeOverlaySource propagates "meshtastic" protocol', () => {
const { testUtils, cleanup } = setupApp();
try {
const result = testUtils.normalizeOverlaySource({ protocol: 'meshtastic' });
assert.equal(result.protocol, 'meshtastic');
} finally {
cleanup();
}
});
test('normalizeOverlaySource omits protocol when absent', () => {
const { testUtils, cleanup } = setupApp();
try {
const result = testUtils.normalizeOverlaySource({ longName: 'Alice' });
assert.ok(!('protocol' in result), 'protocol should not be set when source has none');
} finally {
cleanup();
}
});
test('normalizeOverlaySource omits protocol when value is not a string', () => {
const { testUtils, cleanup } = setupApp();
try {
const result = testUtils.normalizeOverlaySource({ protocol: 42 });
assert.ok(!('protocol' in result), 'protocol should not be set for non-string values');
} finally {
cleanup();
}
});
// --- buildMapPopupHtml ---
test('buildMapPopupHtml includes meshtastic icon for null protocol', () => {
const { testUtils, cleanup } = setupApp();
try {
const html = testUtils.buildMapPopupHtml({ long_name: 'Alice', node_id: '!abc123', protocol: null }, 0);
assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon for null protocol');
} finally {
cleanup();
}
});
test('buildMapPopupHtml includes meshtastic icon for absent protocol', () => {
const { testUtils, cleanup } = setupApp();
try {
const html = testUtils.buildMapPopupHtml({ long_name: 'Bob', node_id: '!abc456' }, 0);
assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon when protocol absent');
} finally {
cleanup();
}
});
test('buildMapPopupHtml omits meshtastic icon for meshcore protocol', () => {
const { testUtils, cleanup } = setupApp();
try {
const html = testUtils.buildMapPopupHtml({ long_name: 'Eve', node_id: '!abc789', protocol: 'meshcore' }, 0);
assert.ok(!html.includes('meshtastic.svg'), 'popup should not show meshtastic icon for meshcore nodes');
} finally {
cleanup();
}
});
// --- createAnnouncementEntry ---
test('createAnnouncementEntry prefixes meshtastic icon when protocol is meshtastic', () => {
const { testUtils, cleanup } = setupApp();
try {
const div = testUtils.createAnnouncementEntry({
timestampSeconds: 1000,
shortName: 'ALI',
longName: 'Alice',
role: 'CLIENT',
metadataSource: { protocol: 'meshtastic' },
nodeData: null,
messageHtml: 'joined the mesh',
});
const html = typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? '';
assert.ok(String(html).includes('meshtastic.svg'), 'announcement should include meshtastic icon');
} finally {
cleanup();
}
});
test('createAnnouncementEntry prefixes meshtastic icon when protocol is absent', () => {
const { testUtils, cleanup } = setupApp();
try {
const div = testUtils.createAnnouncementEntry({
timestampSeconds: 1000,
shortName: 'BOB',
longName: 'Bob',
role: 'ROUTER',
metadataSource: {},
nodeData: null,
messageHtml: 'detected',
});
const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? '');
assert.ok(html.includes('meshtastic.svg'), 'announcement without protocol should show meshtastic icon');
} finally {
cleanup();
}
});
test('createAnnouncementEntry omits meshtastic icon for meshcore protocol', () => {
const { testUtils, cleanup } = setupApp();
try {
const div = testUtils.createAnnouncementEntry({
timestampSeconds: 1000,
shortName: 'MC1',
longName: 'MeshCore Node',
role: 'REPEATER',
metadataSource: { protocol: 'meshcore' },
nodeData: null,
messageHtml: 'seen',
});
const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? '');
assert.ok(!html.includes('meshtastic.svg'), 'announcement for meshcore should not include meshtastic icon');
} finally {
cleanup();
}
});
// --- createMessageChatEntry ---
test('createMessageChatEntry prefixes meshtastic icon when node protocol is meshtastic', () => {
const { testUtils, cleanup } = setupApp();
try {
const div = testUtils.createMessageChatEntry({
text: 'hello mesh',
rx_time: 1000,
node: { short_name: 'ALI', role: 'CLIENT', protocol: 'meshtastic' },
});
const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? '');
assert.ok(html.includes('meshtastic.svg'), 'chat entry should include meshtastic icon');
} finally {
cleanup();
}
});
test('createMessageChatEntry prefixes meshtastic icon when node protocol is absent', () => {
const { testUtils, cleanup } = setupApp();
try {
const div = testUtils.createMessageChatEntry({
text: 'hi',
rx_time: 2000,
node: { short_name: 'BOB', role: 'ROUTER' },
});
const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? '');
assert.ok(html.includes('meshtastic.svg'), 'chat entry without protocol should show meshtastic icon');
} finally {
cleanup();
}
});
test('createMessageChatEntry omits meshtastic icon for meshcore node', () => {
const { testUtils, cleanup } = setupApp();
try {
const div = testUtils.createMessageChatEntry({
text: 'test',
rx_time: 3000,
node: { short_name: 'MC1', role: 'REPEATER', protocol: 'meshcore' },
});
const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? '');
assert.ok(!html.includes('meshtastic.svg'), 'chat entry for meshcore node should not show meshtastic icon');
} finally {
cleanup();
}
});
@@ -0,0 +1,159 @@
/*
* 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 {
escapeHtml,
normalizeNodeNameValue,
buildNodeDetailHref,
canonicalNodeIdentifier,
renderNodeLongNameLink,
} from '../main.js';
// --- escapeHtml ---
test('escapeHtml escapes & < > " and single-quote', () => {
assert.equal(escapeHtml('a & b'), 'a &amp; b');
assert.equal(escapeHtml('<script>'), '&lt;script&gt;');
assert.equal(escapeHtml('"quoted"'), '&quot;quoted&quot;');
assert.equal(escapeHtml("it's"), 'it&#39;s');
});
test('escapeHtml coerces non-strings', () => {
assert.equal(escapeHtml(42), '42');
assert.equal(escapeHtml(null), 'null');
});
// --- normalizeNodeNameValue ---
test('normalizeNodeNameValue returns empty string for null/undefined', () => {
assert.equal(normalizeNodeNameValue(null), '');
assert.equal(normalizeNodeNameValue(undefined), '');
});
test('normalizeNodeNameValue trims whitespace', () => {
assert.equal(normalizeNodeNameValue(' Alice '), 'Alice');
});
test('normalizeNodeNameValue returns empty string for whitespace-only', () => {
assert.equal(normalizeNodeNameValue(' '), '');
});
// --- buildNodeDetailHref ---
test('buildNodeDetailHref returns null for null/undefined', () => {
assert.equal(buildNodeDetailHref(null), null);
assert.equal(buildNodeDetailHref(undefined), null);
});
test('buildNodeDetailHref returns null for empty/whitespace string', () => {
assert.equal(buildNodeDetailHref(''), null);
assert.equal(buildNodeDetailHref(' '), null);
});
test('buildNodeDetailHref strips leading ! before encoding', () => {
assert.equal(buildNodeDetailHref('!abc123'), '/nodes/!abc123');
});
test('buildNodeDetailHref adds ! prefix when absent', () => {
assert.equal(buildNodeDetailHref('abc123'), '/nodes/!abc123');
});
// --- canonicalNodeIdentifier ---
test('canonicalNodeIdentifier returns null for null/undefined', () => {
assert.equal(canonicalNodeIdentifier(null), null);
assert.equal(canonicalNodeIdentifier(undefined), null);
});
test('canonicalNodeIdentifier preserves existing ! prefix', () => {
assert.equal(canonicalNodeIdentifier('!abc123'), '!abc123');
});
test('canonicalNodeIdentifier adds ! prefix when absent', () => {
assert.equal(canonicalNodeIdentifier('abc123'), '!abc123');
});
// --- renderNodeLongNameLink ---
test('renderNodeLongNameLink returns empty string for null/empty longName', () => {
assert.equal(renderNodeLongNameLink(null, '!abc123'), '');
assert.equal(renderNodeLongNameLink('', '!abc123'), '');
assert.equal(renderNodeLongNameLink(' ', '!abc123'), '');
});
test('renderNodeLongNameLink prepends Meshtastic icon for null protocol', () => {
const html = renderNodeLongNameLink('Alice', '!abc123', { protocol: null });
assert.ok(html.includes('meshtastic.svg'), 'should include meshtastic icon');
assert.ok(html.includes('Alice'), 'should include the name');
});
test('renderNodeLongNameLink prepends Meshtastic icon for "meshtastic" protocol', () => {
const html = renderNodeLongNameLink('Alice', '!abc123', { protocol: 'meshtastic' });
assert.ok(html.includes('meshtastic.svg'), 'should include meshtastic icon');
});
test('renderNodeLongNameLink prepends Meshtastic icon for undefined protocol (default)', () => {
const html = renderNodeLongNameLink('Alice', '!abc123');
assert.ok(html.includes('meshtastic.svg'), 'default protocol should show meshtastic icon');
});
test('renderNodeLongNameLink does not prepend icon for "meshcore" protocol', () => {
const html = renderNodeLongNameLink('Alice', '!abc123', { protocol: 'meshcore' });
assert.ok(!html.includes('meshtastic.svg'), 'should not include meshtastic icon for meshcore');
assert.ok(html.includes('Alice'), 'should still include the name');
});
test('renderNodeLongNameLink does not prepend icon for unknown protocol', () => {
const html = renderNodeLongNameLink('Alice', '!abc123', { protocol: 'reticulum' });
assert.ok(!html.includes('meshtastic.svg'), 'should not include meshtastic icon for unknown protocol');
});
test('renderNodeLongNameLink renders anchor with href when identifier is present', () => {
const html = renderNodeLongNameLink('Alice', '!abc123', { protocol: null });
assert.ok(html.startsWith('<a '), 'should be an anchor element');
assert.ok(html.includes('href="/nodes/!abc123"'), 'should include correct href');
assert.ok(html.includes('class="node-long-link"'), 'should include CSS class');
assert.ok(html.includes('data-node-detail-link="true"'), 'should include detail link attribute');
assert.ok(html.includes('data-node-id="!abc123"'), 'should include node id attribute');
});
test('renderNodeLongNameLink renders plain text (with icon) when no identifier', () => {
const html = renderNodeLongNameLink('Alice', null, { protocol: null });
assert.ok(!html.startsWith('<a '), 'should not be an anchor');
assert.ok(html.includes('Alice'), 'should include the name');
assert.ok(html.includes('meshtastic.svg'), 'should still show meshtastic icon');
});
test('renderNodeLongNameLink escapes HTML in long name', () => {
const html = renderNodeLongNameLink('<evil>', '!abc123', { protocol: 'meshcore' });
assert.ok(html.includes('&lt;evil&gt;'), 'should escape < and >');
assert.ok(!html.includes('<evil>'), 'should not include raw HTML');
});
test('renderNodeLongNameLink uses custom className', () => {
const html = renderNodeLongNameLink('Alice', '!abc123', { className: 'custom-class', protocol: null });
assert.ok(html.includes('class="custom-class"'), 'should use the provided class');
});
test('renderNodeLongNameLink omits class attribute on anchor when className is empty', () => {
const html = renderNodeLongNameLink('Alice', '!abc123', { className: '', protocol: null });
// Extract just the opening <a> tag to avoid matching the icon's own class attribute.
const aTag = html.match(/^<a([^>]*)>/)?.[1] ?? '';
assert.ok(!aTag.includes('class='), 'anchor should have no class attribute when className is empty');
});
@@ -360,7 +360,8 @@ test('renderSingleNodeTable renders a condensed table for the node', () => {
10_000,
);
assert.equal(html.includes('<table'), true);
assert.match(html, /<a class="node-long-link" href="\/nodes\/!abcd" data-node-detail-link="true" data-node-id="!abcd">Example Node<\/a>/);
assert.ok(html.includes('meshtastic.svg'), 'default protocol should show meshtastic icon in long name link');
assert.match(html, /<a class="node-long-link" href="\/nodes\/!abcd" data-node-detail-link="true" data-node-id="!abcd">.*Example Node<\/a>/s);
assert.equal(html.includes('66.0%'), true);
assert.equal(html.includes('1.230%'), true);
assert.equal(html.includes('52.52000'), true);
@@ -603,7 +604,8 @@ test('renderNodeDetailHtml composes the table, neighbors, and messages', () => {
assert.equal(html.includes('Heard by'), true);
assert.equal(html.includes('We hear'), true);
assert.equal(html.includes('Messages'), true);
assert.match(html, /<a class="node-long-link" href="\/nodes\/!abcd" data-node-detail-link="true" data-node-id="!abcd">Example Node<\/a>/);
assert.ok(html.includes('meshtastic.svg'), 'default protocol should show meshtastic icon in heading and table');
assert.match(html, /<a class="node-long-link" href="\/nodes\/!abcd" data-node-detail-link="true" data-node-id="!abcd">.*Example Node<\/a>/s);
assert.equal(html.includes('PEER'), true);
assert.equal(html.includes('ALLY'), true);
assert.equal(html.includes('Traceroutes'), true);
@@ -648,6 +650,127 @@ test('renderNodeDetailHtml embeds telemetry charts when snapshots are present',
assert.equal(html.includes('Air quality'), true);
});
// --- Protocol icon in renderSingleNodeTable ---
test('renderSingleNodeTable shows meshtastic icon for meshtastic protocol in long name link', () => {
const node = {
shortName: 'A',
longName: 'Alice',
nodeId: '!aa',
role: 'CLIENT',
protocol: 'meshtastic',
rawSources: { node: { node_id: '!aa', role: 'CLIENT' } },
};
const html = renderSingleNodeTable(node, (short, role) => `<span data-role="${role}">${short}</span>`, 0);
assert.ok(html.includes('meshtastic.svg'), 'meshtastic protocol should show icon in long name link');
});
test('renderSingleNodeTable shows meshtastic icon when protocol is absent in long name link', () => {
const node = {
shortName: 'A',
longName: 'Alice',
nodeId: '!aa',
role: 'CLIENT',
rawSources: { node: { node_id: '!aa', role: 'CLIENT' } },
};
const html = renderSingleNodeTable(node, (short, role) => `<span data-role="${role}">${short}</span>`, 0);
assert.ok(html.includes('meshtastic.svg'), 'absent protocol should show meshtastic icon in long name link');
});
test('renderSingleNodeTable omits meshtastic icon for meshcore protocol in long name link', () => {
const node = {
shortName: 'M',
longName: 'MeshCore Node',
nodeId: '!mc',
role: 'REPEATER',
protocol: 'meshcore',
rawSources: { node: { node_id: '!mc', role: 'REPEATER' } },
};
const html = renderSingleNodeTable(node, (short, role) => `<span data-role="${role}">${short}</span>`, 0);
assert.ok(!html.includes('meshtastic.svg'), 'meshcore protocol should not show meshtastic icon in long name link');
});
// --- Protocol icon in renderNodeDetailHtml heading ---
test('renderNodeDetailHtml shows meshtastic icon in heading for meshtastic protocol', () => {
const html = renderNodeDetailHtml(
{ shortName: 'A', longName: 'Alice', nodeId: '!aa', role: 'CLIENT', protocol: 'meshtastic' },
{ renderShortHtml: short => `<span>${short}</span>` },
);
assert.ok(html.includes('meshtastic.svg'), 'meshtastic protocol should show icon in heading');
});
test('renderNodeDetailHtml shows meshtastic icon in heading when protocol is absent', () => {
const html = renderNodeDetailHtml(
{ shortName: 'A', longName: 'Alice', nodeId: '!aa', role: 'CLIENT' },
{ renderShortHtml: short => `<span>${short}</span>` },
);
assert.ok(html.includes('meshtastic.svg'), 'absent protocol should show meshtastic icon in heading');
});
test('renderNodeDetailHtml omits meshtastic icon in heading for meshcore protocol', () => {
const html = renderNodeDetailHtml(
{ shortName: 'M', longName: 'MeshCore Node', nodeId: '!mc', role: 'REPEATER', protocol: 'meshcore' },
{ renderShortHtml: short => `<span>${short}</span>` },
);
assert.ok(!html.includes('meshtastic.svg'), 'meshcore protocol should not show icon in heading');
});
// --- Protocol icon in renderMessages chat ---
test('renderMessages prefixes meshtastic icon for meshtastic node protocol', () => {
const nodeContext = {
shortName: 'SRC',
longName: 'Source',
role: 'CLIENT',
nodeId: '!src',
nodeNum: 1,
rawSources: { node: { node_id: '!src', role: 'CLIENT', short_name: 'SRC' } },
protocol: 'meshtastic',
};
const html = renderMessages(
[{ text: 'hello', rx_time: 1_700_000_000, node: { short_name: 'SRC', role: 'CLIENT', protocol: 'meshtastic' } }],
(short, role) => `<span data-role="${role}">${short}</span>`,
nodeContext,
);
assert.ok(html.includes('meshtastic.svg'), 'meshtastic node chat entry should show icon');
});
test('renderMessages prefixes meshtastic icon when node protocol is absent', () => {
const nodeContext = {
shortName: 'SRC',
longName: 'Source',
role: 'CLIENT',
nodeId: '!src',
nodeNum: 1,
rawSources: { node: { node_id: '!src', role: 'CLIENT', short_name: 'SRC' } },
};
const html = renderMessages(
[{ text: 'hello', rx_time: 1_700_000_000, node: { short_name: 'SRC', role: 'CLIENT' } }],
(short, role) => `<span data-role="${role}">${short}</span>`,
nodeContext,
);
assert.ok(html.includes('meshtastic.svg'), 'absent node protocol chat entry should show meshtastic icon');
});
test('renderMessages omits meshtastic icon for meshcore node protocol', () => {
const nodeContext = {
shortName: 'MC',
longName: 'MeshCore',
role: 'REPEATER',
nodeId: '!mc',
nodeNum: 2,
rawSources: { node: { node_id: '!mc', role: 'REPEATER', short_name: 'MC' } },
protocol: 'meshcore',
};
const html = renderMessages(
[{ text: 'test', rx_time: 1_700_000_000, node: { short_name: 'MC', role: 'REPEATER', protocol: 'meshcore' } }],
(short, role) => `<span data-role="${role}">${short}</span>`,
nodeContext,
);
assert.ok(!html.includes('meshtastic.svg'), 'meshcore node chat entry should not show meshtastic icon');
});
test('fetchNodeDetailHtml renders the node layout for overlays', async () => {
const reference = { nodeId: '!alpha' };
const calledUrls = [];
@@ -0,0 +1,103 @@
/*
* 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 {
isMeshtasticProtocol,
meshtasticIconHtml,
isMeshcoreProtocol,
meshcoreIconHtml,
MESHTASTIC_ICON_SRC,
MESHCORE_ICON_SRC,
} from '../protocol-helpers.js';
test('isMeshtasticProtocol — null is Meshtastic (default)', () => {
assert.equal(isMeshtasticProtocol(null), true);
});
test('isMeshtasticProtocol — undefined is Meshtastic (default)', () => {
assert.equal(isMeshtasticProtocol(undefined), true);
});
test('isMeshtasticProtocol — empty string is Meshtastic', () => {
assert.equal(isMeshtasticProtocol(''), true);
});
test('isMeshtasticProtocol — whitespace-only string is Meshtastic', () => {
assert.equal(isMeshtasticProtocol(' '), true);
});
test('isMeshtasticProtocol — "meshtastic" is Meshtastic', () => {
assert.equal(isMeshtasticProtocol('meshtastic'), true);
});
test('isMeshtasticProtocol — "meshcore" is not Meshtastic', () => {
assert.equal(isMeshtasticProtocol('meshcore'), false);
});
test('isMeshtasticProtocol — "reticulum" is not Meshtastic', () => {
assert.equal(isMeshtasticProtocol('reticulum'), false);
});
test('isMeshtasticProtocol — case-sensitive: "Meshtastic" is not matched', () => {
assert.equal(isMeshtasticProtocol('Meshtastic'), false);
});
test('meshtasticIconHtml — returns img element HTML', () => {
const html = meshtasticIconHtml();
assert.ok(html.startsWith('<img '), 'should start with <img');
assert.ok(html.includes('meshtastic.svg'), 'should reference meshtastic.svg');
assert.ok(html.includes('protocol-icon--meshtastic'), 'should carry CSS class');
assert.ok(html.includes('aria-hidden="true"'), 'should be hidden from AT');
});
test('isMeshcoreProtocol — "meshcore" is MeshCore', () => {
assert.equal(isMeshcoreProtocol('meshcore'), true);
});
test('isMeshcoreProtocol — null is not MeshCore', () => {
assert.equal(isMeshcoreProtocol(null), false);
});
test('isMeshcoreProtocol — undefined is not MeshCore', () => {
assert.equal(isMeshcoreProtocol(undefined), false);
});
test('isMeshcoreProtocol — "meshtastic" is not MeshCore', () => {
assert.equal(isMeshcoreProtocol('meshtastic'), false);
});
test('isMeshcoreProtocol — empty string is not MeshCore', () => {
assert.equal(isMeshcoreProtocol(''), false);
});
test('meshcoreIconHtml — returns img element HTML', () => {
const html = meshcoreIconHtml();
assert.ok(html.startsWith('<img '), 'should start with <img');
assert.ok(html.includes('meshcore.svg'), 'should reference meshcore.svg');
assert.ok(html.includes('protocol-icon--meshcore'), 'should carry CSS class');
assert.ok(html.includes('aria-hidden="true"'), 'should be hidden from AT');
});
test('MESHTASTIC_ICON_SRC is referenced by meshtasticIconHtml', () => {
assert.ok(meshtasticIconHtml().includes(MESHTASTIC_ICON_SRC), 'icon HTML must embed the src constant');
});
test('MESHCORE_ICON_SRC is referenced by meshcoreIconHtml', () => {
assert.ok(meshcoreIconHtml().includes(MESHCORE_ICON_SRC), 'icon HTML must embed the src constant');
});
@@ -21,6 +21,9 @@ import {
getRoleColor,
getRoleKey,
getRoleRenderPriority,
getRoleColors,
meshcoreRoleColors,
roleColors,
normalizeRole,
translateRoleId,
} from '../role-helpers.js';
@@ -52,3 +55,18 @@ test('render priority uses canonical role keys and defaults to zero for unknowns
assert.equal(getRoleRenderPriority('ROUTER'), getRoleRenderPriority(2));
assert.equal(getRoleRenderPriority('custom-role'), 0);
});
test('getRoleColors returns Meshtastic palette for null/undefined/meshtastic', () => {
assert.equal(getRoleColors(null), roleColors);
assert.equal(getRoleColors(undefined), roleColors);
assert.equal(getRoleColors('meshtastic'), roleColors);
assert.equal(getRoleColors(''), roleColors);
});
test('getRoleColors returns MeshCore palette for meshcore protocol', () => {
assert.equal(getRoleColors('meshcore'), meshcoreRoleColors);
});
test('getRoleColors returns Meshtastic palette for unknown protocols', () => {
assert.equal(getRoleColors('reticulum'), roleColors);
});
+5 -1
View File
@@ -259,7 +259,11 @@ export function buildChatTabModel({
label: labelInfo.label,
entries: [],
labelPriority: labelInfo.priority,
isPrimaryFallback: bucketKey === '0'
isPrimaryFallback: bucketKey === '0',
// Protocol is captured from the first message that creates the bucket.
// In practice all messages in a bucket share the same ingestor/protocol,
// so subsequent messages don't update this field.
protocol: message.protocol ?? null
};
channelBuckets.set(bucketKey, bucket);
} else {
+22 -2
View File
@@ -17,10 +17,16 @@
/**
* Render an accessible tab interface within ``container``.
*
* When a tab carries an ``iconSrc`` URL the icon is rendered as an
* {@code <img>} element built entirely via DOM APIs no ``innerHTML`` is
* involved so the value is safe even if it originates from user-controlled
* data (img src does not execute script). The ``label`` field is always
* inserted as a text node.
*
* @param {{
* document: Document,
* container: HTMLElement,
* tabs: Array<{ id: string, label: string, content: Node|null }>,
* tabs: Array<{ id: string, label: string, iconSrc?: string|null, content: Node|null }>,
* previousActiveTabId?: string|null,
* defaultActiveTabId?: string|null
* }} options Rendering parameters.
@@ -80,7 +86,21 @@ export function renderChatTabs({
button.setAttribute('role', 'tab');
button.setAttribute('id', `chat-tab-${uniqueId}`);
button.dataset.tabId = uniqueId;
button.textContent = tab.label || '';
if (tab.iconSrc) {
const icon = document.createElement('img');
icon.setAttribute('src', tab.iconSrc);
icon.setAttribute('alt', '');
icon.setAttribute('width', '12');
icon.setAttribute('height', '12');
icon.setAttribute('aria-hidden', 'true');
icon.setAttribute('loading', 'lazy');
icon.setAttribute('decoding', 'async');
icon.className = 'protocol-icon';
button.appendChild(icon);
button.appendChild(document.createTextNode(tab.label || ''));
} else {
button.textContent = tab.label || '';
}
button.setAttribute('aria-selected', 'false');
button.setAttribute('tabindex', '-1');
+145 -87
View File
@@ -68,6 +68,7 @@ import {
roleColors,
roleRenderOrder,
} from './role-helpers.js';
import { isMeshtasticProtocol, meshtasticIconHtml, MESHTASTIC_ICON_SRC } from './protocol-helpers.js';
/**
* Compute active-node counts from a local node array.
@@ -207,6 +208,93 @@ export function formatActiveNodeStatsText({ channel, frequency, stats }) {
return `${channel} (${frequency}) — active nodes: ${parts.join(', ')}${suffix}.`;
}
/**
* Escape a string for safe HTML insertion.
*
* @param {string} str Raw string.
* @returns {string} Escaped HTML string.
*/
export function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Normalise node name fields by trimming whitespace.
*
* Unlike {@link stringOrNull} in node-page.js (which returns ``null`` for blank
* values), this function returns an empty string the expected shape for
* display-only name fields where a falsy string is preferable to ``null``.
*
* @param {*} value Raw name value.
* @returns {string} Sanitised name string, or empty string when blank/absent.
*/
export function normalizeNodeNameValue(value) {
if (value == null) return '';
const str = String(value).trim();
return str.length ? str : '';
}
/**
* Compute the node detail path for a given identifier.
*
* @param {string|null} identifier Node identifier.
* @returns {string|null} Detail path.
*/
export function buildNodeDetailHref(identifier) {
if (identifier == null) return null;
const trimmed = String(identifier).trim();
if (!trimmed) return null;
const body = trimmed.startsWith('!') ? trimmed.slice(1) : trimmed;
if (!body) return null;
const encoded = encodeURIComponent(body);
return `/nodes/!${encoded}`;
}
/**
* Ensure ``identifier`` includes the canonical ``!`` prefix.
*
* @param {*} identifier Candidate identifier.
* @returns {string|null} Canonical identifier or ``null``.
*/
export function canonicalNodeIdentifier(identifier) {
if (identifier == null) return null;
const trimmed = String(identifier).trim();
if (!trimmed) return null;
return trimmed.startsWith('!') ? trimmed : `!${trimmed}`;
}
/**
* Render a linked long name pointing to the node detail view.
*
* When {@code protocol} is ``"meshtastic"`` or absent the Meshtastic logo is
* prepended to the displayed name to indicate the node's mesh backend.
*
* @param {string|null} longName Display name.
* @param {string|null} identifier Node identifier.
* @param {{ className?: string, protocol?: string|null }} [options] Rendering options.
* @returns {string} Escaped HTML snippet.
*/
export function renderNodeLongNameLink(longName, identifier, { className = 'node-long-link', protocol = null } = {}) {
const text = normalizeNodeNameValue(longName);
if (!text) return '';
const iconPrefix = isMeshtasticProtocol(protocol) ? `${meshtasticIconHtml()} ` : '';
const href = buildNodeDetailHref(identifier);
if (!href) {
return `${iconPrefix}${escapeHtml(text)}`;
}
const classAttr = className ? ` class="${escapeHtml(className)}"` : '';
const canonicalId = canonicalNodeIdentifier(identifier);
const dataAttrs = canonicalId
? ` data-node-detail-link="true" data-node-id="${escapeHtml(canonicalId)}"`
: ' data-node-detail-link="true"';
return `<a${classAttr} href="${href}"${dataAttrs}>${iconPrefix}${escapeHtml(text)}</a>`;
}
/**
* Entry point for the interactive dashboard. Wires up event listeners,
* initializes the map, and triggers the first data refresh cycle.
@@ -222,7 +310,8 @@ export function formatActiveNodeStatsText({ channel, frequency, stats }) {
* maxDistanceKm: number,
* tileFilters: { light: string, dark: string }
* }} config Normalized application configuration.
* @returns {void}
* @returns {{ _testUtils: Object }} Object whose ``_testUtils`` property exposes
* inner closures for unit tests. Production callers may ignore this.
*/
export function initializeApp(config) {
const statusEl = document.getElementById('status');
@@ -1459,10 +1548,32 @@ export function initializeApp(config) {
*
* @returns {void}
*/
/**
* Build a Meshtastic protocol icon ``<img>`` element via DOM APIs.
*
* Used wherever an icon node must be appended rather than injected via
* ``innerHTML``. Mirrors the attribute set used by {@link meshtasticIconHtml}
* so the rendered output is identical.
*
* @returns {HTMLImageElement} Icon element ready to append.
*/
function buildMeshtasticIconImg() {
const img = document.createElement('img');
img.setAttribute('src', MESHTASTIC_ICON_SRC);
img.setAttribute('alt', '');
img.setAttribute('width', '12');
img.setAttribute('height', '12');
img.setAttribute('aria-hidden', 'true');
img.setAttribute('loading', 'lazy');
img.setAttribute('decoding', 'async');
img.className = 'protocol-icon protocol-icon--meshtastic';
return img;
}
function updateNeighborLinesToggleState() {
if (!neighborLinesToggleButton) return;
const label = neighborLinesVisible ? 'Hide neighbor lines' : 'Show neighbor lines';
neighborLinesToggleButton.textContent = label;
neighborLinesToggleButton.replaceChildren(buildMeshtasticIconImg(), document.createTextNode(` ${label}`));
neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'true' : 'false');
neighborLinesToggleButton.setAttribute('aria-label', label);
}
@@ -1494,7 +1605,7 @@ export function initializeApp(config) {
function updateTraceLinesToggleState() {
if (!traceLinesToggleButton) return;
const label = traceLinesVisible ? 'Hide trace lines' : 'Show trace lines';
traceLinesToggleButton.textContent = label;
traceLinesToggleButton.replaceChildren(buildMeshtasticIconImg(), document.createTextNode(` ${label}`));
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false');
traceLinesToggleButton.setAttribute('aria-label', label);
}
@@ -1583,6 +1694,7 @@ export function initializeApp(config) {
item.type = 'button';
item.setAttribute('aria-pressed', 'false');
item.dataset.role = role;
item.appendChild(buildMeshtasticIconImg());
const swatch = L.DomUtil.create('span', 'legend-swatch', item);
swatch.style.background = color;
swatch.setAttribute('aria-hidden', 'true');
@@ -1879,21 +1991,6 @@ export function initializeApp(config) {
});
// --- Helpers ---
/**
* Escape a string for safe HTML insertion.
*
* @param {string} str Raw string.
* @returns {string} Escaped HTML string.
*/
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Render a short name badge with role-based styling.
*
@@ -2096,7 +2193,7 @@ export function initializeApp(config) {
*/
function buildMapPopupHtml(node, nowSec) {
const lines = [];
const longNameLink = renderNodeLongNameLink(node?.long_name, node?.node_id);
const longNameLink = renderNodeLongNameLink(node?.long_name, node?.node_id, { protocol: node?.protocol });
if (longNameLink) {
lines.push(`<b>${longNameLink}</b>`);
}
@@ -2273,6 +2370,11 @@ export function initializeApp(config) {
}
}
const protocolRaw = source.protocol;
if (protocolRaw != null && typeof protocolRaw === 'string') {
normalized.protocol = protocolRaw;
}
return normalized;
}
@@ -2310,7 +2412,7 @@ export function initializeApp(config) {
const heading = normalized.longName || normalized.shortName || normalized.nodeId || '';
let headingHtml = '';
if (normalized.longName) {
const link = renderNodeLongNameLink(normalized.longName, normalized.nodeId);
const link = renderNodeLongNameLink(normalized.longName, normalized.nodeId, { protocol: normalized.protocol });
if (link) {
headingHtml = `<strong>${link}</strong><br/>`;
}
@@ -2675,8 +2777,10 @@ export function initializeApp(config) {
const presetTag = formatChatPresetTag({ presetCode: metadata.presetCode });
const longNameDisplay = longName != null ? String(longName) : '';
const shortHtml = renderShortHtml(shortName, role, longNameDisplay, nodeData || metadataSource || {});
const announcementProtocol = (nodeData || metadataSource || {}).protocol;
const announcementIconPrefix = isMeshtasticProtocol(announcementProtocol) ? `${meshtasticIconHtml()} ` : '';
div.className = 'chat-entry-node';
div.innerHTML = `${prefix}${presetTag} ${shortHtml} ${messageHtml}`;
div.innerHTML = `${prefix}${presetTag} ${announcementIconPrefix}${shortHtml} ${messageHtml}`;
return div;
}
@@ -3065,6 +3169,7 @@ export function initializeApp(config) {
const tsDate = tsSeconds != null ? new Date(tsSeconds * 1000) : null;
const ts = tsDate ? formatTime(tsDate) : '--:--:--';
const short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node);
const nodeProtocolPrefix = isMeshtasticProtocol(m.node?.protocol) ? `${meshtasticIconHtml()} ` : '';
const replyPrefix = resolveReplyPrefix({
message: m,
messagesById,
@@ -3101,7 +3206,7 @@ export function initializeApp(config) {
});
const presetTag = formatChatPresetTag({ presetCode: metadata.presetCode });
div.className = 'chat-entry-msg';
div.innerHTML = `${prefix}${presetTag} ${short} ${text}`;
div.innerHTML = `${prefix}${presetTag} ${nodeProtocolPrefix}${short} ${text}`;
return div;
}
@@ -3234,6 +3339,7 @@ export function initializeApp(config) {
const channelTabs = filteredChannels.map(channel => ({
id: channel.id || `channel-${channel.index}`,
label: channel.label,
iconSrc: isMeshtasticProtocol(channel.protocol) ? MESHTASTIC_ICON_SRC : null,
content: buildChatFragment({
entries: channel.entries.map(e => ({ ts: e.ts, item: e.message })),
renderEntry: entry => createMessageChatEntry(entry.item),
@@ -3382,70 +3488,6 @@ export function initializeApp(config) {
return `${n.toFixed(1)} dB`;
}
/**
* Normalise node name fields by trimming whitespace.
*
* @param {*} value Raw name value.
* @returns {string} Sanitised name string.
*/
function normalizeNodeNameValue(value) {
if (value == null) return '';
const str = String(value).trim();
return str.length ? str : '';
}
/**
* Compute the node detail path for a given identifier.
*
* @param {string|null} identifier Node identifier.
* @returns {string|null} Detail path.
*/
function buildNodeDetailHref(identifier) {
if (identifier == null) return null;
const trimmed = String(identifier).trim();
if (!trimmed) return null;
const body = trimmed.startsWith('!') ? trimmed.slice(1) : trimmed;
if (!body) return null;
const encoded = encodeURIComponent(body);
return `/nodes/!${encoded}`;
}
/**
* Ensure ``identifier`` includes the canonical ``!`` prefix.
*
* @param {*} identifier Candidate identifier.
* @returns {string|null} Canonical identifier or ``null``.
*/
function canonicalNodeIdentifier(identifier) {
if (identifier == null) return null;
const trimmed = String(identifier).trim();
if (!trimmed) return null;
return trimmed.startsWith('!') ? trimmed : `!${trimmed}`;
}
/**
* Render a linked long name pointing to the node detail view.
*
* @param {string|null} longName Display name.
* @param {string|null} identifier Node identifier.
* @param {string} [className='node-long-link'] Optional class attribute.
* @returns {string} Escaped HTML snippet.
*/
function renderNodeLongNameLink(longName, identifier, className = 'node-long-link') {
const text = normalizeNodeNameValue(longName);
if (!text) return '';
const href = buildNodeDetailHref(identifier);
if (!href) {
return escapeHtml(text);
}
const classAttr = className ? ` class="${escapeHtml(className)}"` : '';
const canonicalIdentifier = canonicalNodeIdentifier(identifier);
const dataAttrs = canonicalIdentifier
? ` data-node-detail-link="true" data-node-id="${escapeHtml(canonicalIdentifier)}"`
: ' data-node-detail-link="true"';
return `<a${classAttr} href="${href}"${dataAttrs}>${escapeHtml(text)}</a>`;
}
/**
* Determine whether a long name link should trigger the overlay behaviour.
*
@@ -3968,7 +4010,7 @@ export function initializeApp(config) {
const loraFrequencyText = formatLoraFrequencyMHz(modemMetadata.loraFreq);
const loraFrequencyDisplay = loraFrequencyText ? escapeHtml(loraFrequencyText) : '';
const modemPresetDisplay = modemMetadata.modemPreset ? escapeHtml(modemMetadata.modemPreset) : '';
const longNameHtml = renderNodeLongNameLink(n.long_name, n.node_id);
const longNameHtml = renderNodeLongNameLink(n.long_name, n.node_id, { protocol: n.protocol });
tr.innerHTML = `
<td class="mono nodes-col nodes-col--node-id">${n.node_id || ""}</td>
<td class="nodes-col nodes-col--short-name">${renderShortHtml(n.short_name, n.role, n.long_name, n)}</td>
@@ -4491,6 +4533,7 @@ export function initializeApp(config) {
refresh();
restartAutoRefresh();
if (refreshBtn) {
refreshBtn.addEventListener('click', refresh);
}
@@ -4546,4 +4589,19 @@ export function initializeApp(config) {
});
});
}
/**
* Inner closures exposed for unit tests. Production callers should ignore
* this return value.
*
* @returns {{ _testUtils: { buildMapPopupHtml: Function, normalizeOverlaySource: Function, createAnnouncementEntry: Function, createMessageChatEntry: Function } }}
*/
return {
_testUtils: {
buildMapPopupHtml,
normalizeOverlaySource,
createAnnouncementEntry,
createMessageChatEntry,
},
};
}
+14 -7
View File
@@ -15,6 +15,7 @@
*/
import { refreshNodeInformation } from './node-details.js';
import { isMeshtasticProtocol, meshtasticIconHtml } from './protocol-helpers.js';
import {
extractChatMessageMetadata,
formatChatChannelTag,
@@ -367,22 +368,23 @@ function canonicalNodeIdentifier(identifier) {
*
* @param {string|null} longName Long name text.
* @param {string|null} identifier Node identifier.
* @param {{ className?: string }} [options] Rendering options.
* @param {{ className?: string, protocol?: string|null }} [options] Rendering options.
* @returns {string} Escaped HTML string.
*/
function renderNodeLongNameLink(longName, identifier, { className = 'node-long-link' } = {}) {
function renderNodeLongNameLink(longName, identifier, { className = 'node-long-link', protocol = null } = {}) {
const text = stringOrNull(longName);
if (!text) return '';
const iconPrefix = isMeshtasticProtocol(protocol) ? `${meshtasticIconHtml()} ` : '';
const href = buildNodeDetailHref(identifier);
if (!href) {
return escapeHtml(text);
return `${iconPrefix}${escapeHtml(text)}`;
}
const classAttr = className ? ` class="${escapeHtml(className)}"` : '';
const canonicalIdentifier = canonicalNodeIdentifier(identifier);
const dataAttrs = canonicalIdentifier
? ` data-node-detail-link="true" data-node-id="${escapeHtml(canonicalIdentifier)}"`
: ' data-node-detail-link="true"';
return `<a${classAttr} href="${href}"${dataAttrs}>${escapeHtml(text)}</a>`;
return `<a${classAttr} href="${href}"${dataAttrs}>${iconPrefix}${escapeHtml(text)}</a>`;
}
/**
@@ -2032,7 +2034,8 @@ function renderSingleNodeTable(node, renderShortHtml, referenceSeconds = Date.no
const nodeId = stringOrNull(node.nodeId ?? node.node_id) ?? '';
const shortName = stringOrNull(node.shortName ?? node.short_name) ?? null;
const longName = stringOrNull(node.longName ?? node.long_name);
const longNameLink = renderNodeLongNameLink(longName, nodeId);
const protocol = stringOrNull(node.protocol) ?? null;
const longNameLink = renderNodeLongNameLink(longName, nodeId, { protocol });
const role = stringOrNull(node.role) ?? 'CLIENT';
const numericId = numberOrNull(node.nodeNum ?? node.node_num ?? node.num);
const badgeSource = node.rawSources?.node && typeof node.rawSources.node === 'object'
@@ -2163,6 +2166,8 @@ function renderMessages(messages, renderShortHtml, node) {
const channelTag = formatChatChannelTag({ channelName: metadata.channelName });
const messageNode = message.node && typeof message.node === 'object' ? message.node : null;
const messageProtocol = stringOrNull(messageNode?.protocol ?? fallbackNode?.protocol) ?? null;
const protocolIconHtml = isMeshtasticProtocol(messageProtocol) ? `${meshtasticIconHtml()} ` : '';
const badgeHtml = renderRoleAwareBadge(renderShortHtml, {
shortName: messageNode?.short_name ?? messageNode?.shortName ?? fallbackNode?.shortName ?? fallbackNode?.short_name,
longName: messageNode?.long_name ?? messageNode?.longName ?? fallbackNode?.longName ?? fallbackNode?.long_name,
@@ -2186,7 +2191,7 @@ function renderMessages(messages, renderShortHtml, node) {
source: messageNode ?? fallbackNode?.rawSources?.node ?? fallbackNode,
});
return `<li>${prefix}${presetTag}${channelTag} ${badgeHtml} ${escapeHtml(text)}</li>`;
return `<li>${prefix}${presetTag}${channelTag} ${protocolIconHtml}${badgeHtml} ${escapeHtml(text)}</li>`;
})
.filter(item => item != null);
if (items.length === 0) return '';
@@ -2396,6 +2401,7 @@ function renderNodeDetailHtml(node, {
});
const longName = stringOrNull(node.longName ?? node.long_name);
const identifier = stringOrNull(node.nodeId ?? node.node_id);
const nodeProtocol = stringOrNull(node.protocol) ?? null;
const tableHtml = renderSingleNodeTable(node, renderShortHtml);
const chartsHtml = renderTelemetryCharts(node, { nowMs: chartNowMs });
const neighborsHtml = renderNeighborGroups(node, neighbors, renderShortHtml, { roleIndex });
@@ -2414,7 +2420,8 @@ function renderNodeDetailHtml(node, {
}
const identifierHtml = identifier ? `<span class="node-detail__identifier">[${escapeHtml(identifier)}]</span>` : '';
const nameHtml = longName ? `<span class="node-detail__name">${escapeHtml(longName)}</span>` : '';
const iconPrefix = isMeshtasticProtocol(nodeProtocol) ? `${meshtasticIconHtml()} ` : '';
const nameHtml = longName ? `<span class="node-detail__name">${iconPrefix}${escapeHtml(longName)}</span>` : '';
const badgeHtml = `<span class="node-detail__badge">${roleAwareBadge}</span>`;
const tableSection = tableHtml ? `<div class="node-detail__table">${tableHtml}</div>` : '';
const contentHtml = sections.length > 0 ? `<div class="node-detail__content">${sections.join('')}</div>` : '';
@@ -0,0 +1,82 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** Relative URL of the Meshtastic protocol icon asset. */
export const MESHTASTIC_ICON_SRC = '/assets/img/meshtastic.svg';
/** Relative URL of the MeshCore protocol icon asset. */
export const MESHCORE_ICON_SRC = '/assets/img/meshcore.svg';
/**
* Return true when the protocol value represents Meshtastic or is absent.
*
* A null/undefined/empty string is treated as Meshtastic because the backend
* defaults all records to ``"meshtastic"`` and pre-existing records that
* predate the protocol column carry an implicit Meshtastic origin.
*
* Comparison is case-sensitive: only the lowercase value ``"meshtastic"``
* matches mixed-case strings such as ``"Meshtastic"`` return ``false``.
* The backend always stores protocol values in lowercase, so this is
* intentional.
*
* @param {string|null|undefined} protocol Protocol string from the API.
* @returns {boolean} Whether the protocol is (or defaults to) Meshtastic.
*/
export function isMeshtasticProtocol(protocol) {
if (protocol == null) return true;
const trimmed = String(protocol).trim();
return trimmed.length === 0 || trimmed === 'meshtastic';
}
/**
* Build an HTML snippet that renders the Meshtastic logo as an inline icon.
*
* The image is intentionally small (12 × 12 px) so it fits comfortably inline
* with text in chat entries, table cells, and legend buttons.
*
* @returns {string} HTML string containing an {@code <img>} element.
*/
export function meshtasticIconHtml() {
return `<img src="${MESHTASTIC_ICON_SRC}" alt="" width="12" height="12"` +
' class="protocol-icon protocol-icon--meshtastic" loading="lazy" decoding="async"' +
' aria-hidden="true">';
}
/**
* Return true when the protocol value represents MeshCore.
*
* @param {string|null|undefined} protocol Protocol string from the API.
* @returns {boolean} Whether the protocol is MeshCore.
*/
export function isMeshcoreProtocol(protocol) {
if (protocol == null) return false;
return String(protocol).trim() === 'meshcore';
}
/**
* Build an HTML snippet that renders the MeshCore logo as an inline icon.
*
* Follows the same sizing and attribute conventions as {@link meshtasticIconHtml}
* so both icons are visually consistent when placed side-by-side in the UI.
*
* @returns {string} HTML string containing an {@code <img>} element.
*/
export function meshcoreIconHtml() {
return `<img src="${MESHCORE_ICON_SRC}" alt="" width="12" height="12"` +
' class="protocol-icon protocol-icon--meshcore" loading="lazy" decoding="async"' +
' aria-hidden="true">';
}
+56 -11
View File
@@ -39,20 +39,65 @@ export const roleIdToName = Object.freeze({
12: 'CLIENT_BASE',
});
// Firmware 2.7.10 / Android 2.7.0 roles and colors (see issue #177)
/**
* 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.
*
* Updated from the original blue-cool palette (see PR #657) to improve
* readability alongside the MeshCore grey-blue palette.
*
* Firmware 2.7.10 / Android 2.7.0 roles (see issue #177).
*
* @type {Readonly<Record<string, string>>}
*/
export const roleColors = Object.freeze({
CLIENT_HIDDEN: '#A9CBE8',
SENSOR: '#A8D5BA',
TRACKER: '#B9DFAC',
CLIENT_MUTE: '#CDE7A9',
CLIENT: '#E8E6A1',
CLIENT_BASE: '#F6D0A6',
REPEATER: '#F7B7A3',
ROUTER_LATE: '#F29AA3',
ROUTER: '#E88B94',
LOST_AND_FOUND: '#C3A8E8'
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',
});
/**
* MeshCore role colour palette cool grey-blue gradient used to distinguish
* MeshCore nodes from Meshtastic nodes in future protocol-aware views.
*
* These colours are defined now for completeness but are not yet applied to
* live UI surfaces see the protocol-aware legend work that will follow once
* MeshCore ingest is implemented.
*
* @type {Readonly<Record<string, string>>}
*/
export const meshcoreRoleColors = Object.freeze({
REPEATER: '#C8D0DC',
ROOM_SERVER: '#8AAAC6',
SENSOR: '#4A7EB4',
COMPANION: '#1A5498',
});
/**
* Return the role colour palette appropriate for the given protocol.
*
* Defaults to {@link roleColors} (Meshtastic) for absent or unrecognised
* protocol values so existing callers are unaffected when MeshCore ingest
* is not yet active.
*
* @param {string|null|undefined} protocol Protocol string from the API.
* @returns {Readonly<Record<string, string>>} Role colour map.
*/
export function getRoleColors(protocol) {
if (protocol != null && String(protocol).trim() === 'meshcore') {
return meshcoreRoleColors;
}
return roleColors;
}
export const roleRenderOrder = Object.freeze({
CLIENT_HIDDEN: 1,
SENSOR: 2,
+6
View File
@@ -1732,6 +1732,12 @@ input[type="radio"] {
border-radius: 2px;
}
.protocol-icon {
display: inline-block;
vertical-align: middle;
margin-right: 2px;
}
.legend-hidden {
display: none !important;
}