web: facelift (#716)

* web: facelift

* web: facelift

* web: facelift

* web: address review comments

* web: address review comments

* web: address review comments

* web: address review comments

* web: address review comments

* web: address review comments

* web: more css magic

* web: link parsing for chat contact

* web: remove one-letter fallback for shortnames

* Potential fix for pull request finding 'CodeQL / Incomplete multi-character sanitization'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* web: fix fallback for shortnames

* web: address review comments

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
l5y
2026-04-07 21:38:43 +02:00
committed by GitHub
parent 267d2ec9e1
commit 4d38ddd341
29 changed files with 1190 additions and 924 deletions
+21 -14
View File
@@ -35,8 +35,8 @@ Connection type is detected automatically from the target string:
Node identities are derived from the first four bytes (eight hex characters)
of each contact's 32-byte public key, formatted as ``!xxxxxxxx`` to match
the canonical node-ID schema used across the system. Ingested
``user.shortName`` is the first four hex digits of that key (two bytes),
not the advertised name.
``user.shortName`` is the first two bytes (four hex characters) of the
node ID, not the advertised name.
"""
from __future__ import annotations
@@ -165,23 +165,28 @@ def _meshcore_node_id(public_key_hex: str | None) -> str | None:
return "!" + public_key_hex[:8].lower()
def _meshcore_short_name(public_key_hex: str | None) -> str:
"""Return the first four hex digits of a MeshCore public key as short name.
def _meshcore_short_name(node_id: str | None) -> str:
"""Derive a four-character short name from a canonical node ID.
Meshtastic-style ``shortName`` fields are four characters wide; MeshCore
ingest uses the leading two bytes of the 32-byte public key in lowercase
hex so the label is stable and unique per key prefix.
Uses the first two bytes (four hex characters) of the ``!xxxxxxxx`` node
ID. This keeps the short name consistent with the node ID itself — if the
node ID is later replaced when the real public key is heard, the short name
will update alongside it.
Parameters:
public_key_hex: Full public key as a hex string from the MeshCore API.
node_id: Canonical ``!xxxxxxxx`` node ID string (as returned by
:func:`_meshcore_node_id`).
Returns:
Four lowercase hex characters (e.g. ``"aabb"``), or an empty string
when the key is missing or shorter than four hex characters.
Four lowercase hex characters (e.g. ``"cafe"``), or an empty string
when the node ID is missing or too short.
"""
if not public_key_hex or len(public_key_hex) < 4:
if not node_id:
return ""
return public_key_hex[:4].lower()
raw = node_id.lstrip("!")
if len(raw) < 4:
return ""
return raw[:4].lower()
def _meshcore_adv_type_to_role(adv_type: object) -> str | None:
@@ -324,6 +329,7 @@ def _contact_to_node_dict(contact: dict) -> dict:
Node dict compatible with the ``POST /api/nodes`` payload format.
"""
pub_key = contact.get("public_key", "")
node_id = _meshcore_node_id(pub_key)
name = (contact.get("adv_name") or "").strip()
role = _meshcore_adv_type_to_role(contact.get("type"))
node: dict = {
@@ -331,7 +337,7 @@ def _contact_to_node_dict(contact: dict) -> dict:
"protocol": "meshcore",
"user": {
"longName": name,
"shortName": _meshcore_short_name(pub_key),
"shortName": _meshcore_short_name(node_id),
"publicKey": pub_key,
**({"role": role} if role is not None else {}),
},
@@ -377,13 +383,14 @@ def _self_info_to_node_dict(self_info: dict) -> dict:
"""
name = (self_info.get("name") or "").strip()
pub_key = self_info.get("public_key", "")
node_id = _meshcore_node_id(pub_key)
role = _meshcore_adv_type_to_role(self_info.get("adv_type"))
node: dict = {
"lastHeard": int(time.time()),
"protocol": "meshcore",
"user": {
"longName": name,
"shortName": _meshcore_short_name(pub_key),
"shortName": _meshcore_short_name(node_id),
"publicKey": pub_key,
**({"role": role} if role is not None else {}),
},
+9 -8
View File
@@ -661,21 +661,22 @@ def test_meshcore_node_id_none_on_empty():
assert _meshcore_node_id(None) is None # type: ignore[arg-type]
def test_meshcore_short_name_first_four_hex_digits():
"""_meshcore_short_name returns the first four hex chars, lowercased."""
assert _meshcore_short_name("AABBccdd" + "00" * 28) == "aabb"
def test_meshcore_short_name_first_two_bytes_of_node_id():
"""_meshcore_short_name returns the first four hex chars of the node ID."""
assert _meshcore_short_name("!aabbccdd") == "aabb"
assert _meshcore_short_name("!AABBccdd") == "aabb"
def test_meshcore_short_name_empty_when_too_short():
"""_meshcore_short_name returns '' when the key has fewer than four hex digits."""
"""_meshcore_short_name returns '' when the node ID is missing or too short."""
assert _meshcore_short_name("") == ""
assert _meshcore_short_name("abc") == ""
assert _meshcore_short_name("!ab") == ""
assert _meshcore_short_name(None) == "" # type: ignore[arg-type]
def test_meshcore_short_name_exactly_four_chars():
"""_meshcore_short_name with exactly four hex chars returns those four chars."""
assert _meshcore_short_name("abcd") == "abcd"
def test_meshcore_short_name_without_bang_prefix():
"""_meshcore_short_name handles node IDs without the leading '!' prefix."""
assert _meshcore_short_name("cafef00d") == "cafe"
# ---------------------------------------------------------------------------
@@ -86,10 +86,9 @@ module PotatoMesh
# space keeps the badge at four visual columns.
# 2. If the long name contains two or more whitespace-separated words, use
# the capitalised first letters of the first two words: ``" XY "``.
# 3. If the long name is a single word, use its capitalised first letter:
# ``" A "``.
# 4. Return +nil+ when no short name can be derived (blank input, or a
# word without extractable characters).
# 3. Return +nil+ — single-word names fall back to the raw short name stored
# in the database (typically the first two bytes of the node ID). A single
# initial looked poor and carried no more information than the raw value.
#
# @param long_name [String, nil] long name stored on the node.
# @return [String, nil] derived display short name or +nil+.
@@ -111,8 +110,7 @@ module PotatoMesh
return " #{first}#{second} " if first && second
end
letter = words[0][0]&.upcase
letter ? " #{letter} " : nil
nil
end
# Recursively coerce hash keys to strings and normalise nested arrays.
@@ -159,7 +159,16 @@ module PotatoMesh
r["role"] ||= "CLIENT"
if r["role"] == "COMPANION"
derived = meshcore_companion_display_short_name(r["long_name"])
r["short_name"] = derived if derived
if derived
r["short_name"] = derived
elsif r["short_name"].nil? || r["short_name"].strip.empty?
# No derived name and no stored public-key hex — synthesise from
# the node ID (first four hex chars after the leading "!") so the
# badge is stable, unique, and consistent with how the ingestor
# builds short names from public keys.
node_id = r["node_id"].to_s.delete_prefix("!")
r["short_name"] = node_id[0, 4] unless node_id.empty?
end
end
lh = r["last_heard"]&.to_i
pt = r["position_time"]&.to_i
+4 -15
View File
@@ -19,23 +19,12 @@ module PotatoMesh
module Routes
module Root
module Helpers
# Determine the initial theme from the request cookie and persist
# sanitised values back to the client to avoid invalid states.
# Return the fixed dark theme identifier. Light mode is no longer
# supported; theme selection and cookie persistence have been removed.
#
# @return [String] normalised theme value ('dark' or 'light').
# @return [String] always 'dark'.
def resolve_initial_theme
raw_theme = request.cookies["theme"]
theme = %w[dark light].include?(raw_theme) ? raw_theme : "dark"
if raw_theme != theme
response.set_cookie(
"theme",
value: theme,
path: "/",
max_age: 60 * 60 * 24 * 7,
same_site: :lax,
)
end
theme
"dark"
end
# Render a dashboard-oriented ERB template within the shared layout.
@@ -92,14 +92,15 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
);
assert.equal(model.channels.length, 6);
// All channels have 1 message each; ties are broken alphabetically by label.
// Primary channels (index 0) come first, secondary channels (index > 0) come last.
// Within each tier, ties on messageCount are broken alphabetically by label.
assert.deepEqual(model.channels.map(channel => channel.label), [
'1',
'BerlinMesh',
'EnvDefault',
'Fallback',
'MediumFast',
'ShortFast'
'ShortFast',
'1',
'BerlinMesh',
]);
const channelByLabel = Object.fromEntries(model.channels.map(channel => [channel.label, channel]));
@@ -512,3 +513,70 @@ test('buildChatTabModel breaks messageCount ties alphabetically', () => {
assert.equal(model.channels[0].label, 'Apple');
assert.equal(model.channels[1].label, 'Zebra');
});
test('buildChatTabModel puts primary channels (index 0) before secondary channels', () => {
const model = buildChatTabModel({
nodes: [],
messages: [
// Secondary channels with many messages
{ id: 's1', rx_time: NOW - 30, channel: 2, channel_name: 'SecondaryA' },
{ id: 's2', rx_time: NOW - 28, channel: 2, channel_name: 'SecondaryA' },
{ id: 's3', rx_time: NOW - 26, channel: 2, channel_name: 'SecondaryA' },
// Primary channel (index 0) with fewer messages
{ id: 'p1', rx_time: NOW - 20, channel: 0, channel_name: 'LongFast' },
],
nowSeconds: NOW,
windowSeconds: WINDOW
});
assert.equal(model.channels.length, 2);
assert.equal(model.channels[0].label, 'LongFast', 'primary channel must come first regardless of activity');
assert.equal(model.channels[0].index, 0);
assert.equal(model.channels[1].label, 'SecondaryA', 'secondary channel must come second');
});
test('buildChatTabModel sorts primary channels by activity then alpha within the primary tier', () => {
const model = buildChatTabModel({
nodes: [],
messages: [
// LongFast: 1 message
{ id: 'lf1', rx_time: NOW - 30, channel: 0, channel_name: 'LongFast' },
// MediumFast: 3 messages (most active primary)
{ id: 'mf1', rx_time: NOW - 28, channel: 0, channel_name: 'MediumFast' },
{ id: 'mf2', rx_time: NOW - 26, channel: 0, channel_name: 'MediumFast' },
{ id: 'mf3', rx_time: NOW - 24, channel: 0, channel_name: 'MediumFast' },
// Public: 2 messages
{ id: 'pb1', rx_time: NOW - 22, channel: 0, channel_name: 'Public' },
{ id: 'pb2', rx_time: NOW - 20, channel: 0, channel_name: 'Public' },
],
nowSeconds: NOW,
windowSeconds: WINDOW
});
assert.equal(model.channels.length, 3);
assert.equal(model.channels[0].label, 'MediumFast', 'most active primary first');
assert.equal(model.channels[1].label, 'Public', 'second most active primary second');
assert.equal(model.channels[2].label, 'LongFast', 'least active primary last');
});
test('buildChatTabModel sorts secondary channels by activity then alpha after all primaries', () => {
const model = buildChatTabModel({
nodes: [],
messages: [
// Primary with 1 message
{ id: 'p1', rx_time: NOW - 50, channel: 0, channel_name: 'LongFast' },
// Secondary channels
{ id: 'b1', rx_time: NOW - 40, channel: 3, channel_name: 'Beta' },
{ id: 'a1', rx_time: NOW - 38, channel: 1, channel_name: 'Alpha' },
{ id: 'a2', rx_time: NOW - 36, channel: 1, channel_name: 'Alpha' },
{ id: 'a3', rx_time: NOW - 34, channel: 1, channel_name: 'Alpha' },
{ id: 'g1', rx_time: NOW - 32, channel: 2, channel_name: 'Gamma' },
{ id: 'g2', rx_time: NOW - 30, channel: 2, channel_name: 'Gamma' },
],
nowSeconds: NOW,
windowSeconds: WINDOW
});
assert.equal(model.channels.length, 4);
assert.equal(model.channels[0].label, 'LongFast', 'primary always first');
assert.equal(model.channels[1].label, 'Alpha', 'most active secondary first');
assert.equal(model.channels[2].label, 'Gamma', 'second most active secondary second');
assert.equal(model.channels[3].label, 'Beta', 'least active secondary last');
});
@@ -809,3 +809,136 @@ test('federation page sorts by full site names before truncating visible labels'
cleanup();
}
});
test('federation table linkifies Matrix room aliases, user IDs, and bare domain paths', async () => {
const { tbodyEl, cleanup } = (() => {
const e = createBasicFederationPageHarness();
return { tbodyEl: e.tbodyEl, cleanup: e.cleanup.bind(e) };
})();
const fetchImpl = () => Promise.resolve({
ok: true,
json: async () => [
{
domain: 'mesh.example',
name: 'Room Test',
contactLink: '@jmrplens:matrix.jmrp.io',
channel: '#mesh:server.tld',
version: '1.0.0',
latitude: 0,
longitude: 0,
lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
nodesCount: 3
}
]
});
try {
await initializeFederationPage({ config: {}, fetchImpl, leaflet: createBasicLeafletStub() });
const rowHtml = tbodyEl.childNodes[0].innerHTML;
// Matrix user ID: @jmrplens:matrix.jmrp.io → https://matrix.to/#/@jmrplens:matrix.jmrp.io
assert.match(rowHtml, /href="https:\/\/matrix\.to\/#\/@jmrplens:matrix\.jmrp\.io"/);
assert.match(rowHtml, /@jmrplens:matrix\.jmrp\.io/);
// Matrix room alias in channel cell: #mesh:server.tld → https://matrix.to/#/#mesh:server.tld
assert.match(rowHtml, /href="https:\/\/matrix\.to\/#\/#mesh:server\.tld"/);
assert.match(rowHtml, /#mesh:server\.tld/);
} finally {
cleanup();
}
});
test('federation table linkifies bare domain-with-path as https', async () => {
const { tbodyEl, cleanup } = (() => {
const e = createBasicFederationPageHarness();
return { tbodyEl: e.tbodyEl, cleanup: e.cleanup.bind(e) };
})();
const fetchImpl = () => Promise.resolve({
ok: true,
json: async () => [
{
domain: 'mesh.example',
contactLink: 'discord.gg/EGdbRKQnFk',
version: '1.0.0',
latitude: 0,
longitude: 0,
lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
nodesCount: 1
}
]
});
try {
await initializeFederationPage({ config: {}, fetchImpl, leaflet: createBasicLeafletStub() });
const rowHtml = tbodyEl.childNodes[0].innerHTML;
assert.match(rowHtml, /href="https:\/\/discord\.gg\/EGdbRKQnFk"/);
assert.match(rowHtml, /discord\.gg\/EGdbRKQnFk/);
} finally {
cleanup();
}
});
test('federation table sanitises <a> tags and strips other HTML in contact field', async () => {
const { tbodyEl, cleanup } = (() => {
const e = createBasicFederationPageHarness();
return { tbodyEl: e.tbodyEl, cleanup: e.cleanup.bind(e) };
})();
const contactWithHtml =
'<a href=https://t.me/+BpSW3no2mJgzM2I8 target=_blank>YO Telegram group</a><b> Contact:</b> YO3IBZ';
const contactViber =
'<a href="https://invite.viber.com/?g=64h1QIFIC1Unai6DS6SE2Ot8ks9xoTm6">Viber Group</a>';
const fetchImpl = () => Promise.resolve({
ok: true,
json: async () => [
{
domain: 'a.mesh',
contactLink: contactWithHtml,
version: '1.0.0',
latitude: 0,
longitude: 0,
lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
nodesCount: 1
},
{
domain: 'b.mesh',
contactLink: contactViber,
version: '1.0.0',
latitude: 0,
longitude: 0,
lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
nodesCount: 1
}
]
});
try {
await initializeFederationPage({ config: {}, fetchImpl, leaflet: createBasicLeafletStub() });
const rows = tbodyEl.childNodes;
const aHtml = rows[0].innerHTML;
const bHtml = rows[1].innerHTML;
// Unquoted href extracted and normalised
assert.match(aHtml, /href="https:\/\/t\.me\/\+BpSW3no2mJgzM2I8"/);
assert.match(aHtml, /YO Telegram group/);
// <b> tag stripped, text content preserved
assert.match(aHtml, /Contact:/);
assert.doesNotMatch(aHtml, /<b>/);
// Remaining plain text present
assert.match(aHtml, /YO3IBZ/);
// Quoted href passes through correctly
assert.match(bHtml, /href="https:\/\/invite\.viber\.com\/\?g=64h1QIFIC1Unai6DS6SE2Ot8ks9xoTm6"/);
assert.match(bHtml, /Viber Group/);
// No raw HTML from input leaks into output
assert.doesNotMatch(aHtml, /target=_blank/);
assert.doesNotMatch(aHtml, /<\/b>/);
} finally {
cleanup();
}
});
@@ -230,7 +230,7 @@ test('initializeInstanceSelector navigates to the chosen instance domain', async
const fetchImpl = async () => ({
ok: true,
async json() {
return [{ domain: 'mesh.example' }];
return [{ domain: 'mesh.example' }, { domain: 'other.mesh' }];
}
});
@@ -249,7 +249,7 @@ test('initializeInstanceSelector navigates to the chosen instance domain', async
defaultLabel: 'Select region ...'
});
assert.equal(select.options.length, 2);
assert.equal(select.options.length, 3);
assert.equal(select.options[1].value, 'mesh.example');
select.value = 'mesh.example';
@@ -261,6 +261,68 @@ test('initializeInstanceSelector navigates to the chosen instance domain', async
}
});
test('initializeInstanceSelector hides the selector container when fewer than 2 instances are available', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
// Simulate a parent container; mock elements lack closest() so we set
// parentElement directly so the hide logic falls back to it.
const container = env.document.createElement('div');
container.classList.add('header-federation');
select.parentElement = container;
env.document.body.appendChild(container);
const fetchImpl = async () => ({
ok: true,
async json() {
return [{ domain: 'only.mesh' }];
}
});
try {
await initializeInstanceSelector({
selectElement: select,
fetchImpl,
windowObject: env.window,
documentObject: env.document
});
assert.equal(container.hidden, true, 'container should be hidden with fewer than 2 instances');
} finally {
env.cleanup();
}
});
test('initializeInstanceSelector keeps the selector visible when 2 or more instances are available', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
const container = env.document.createElement('div');
container.classList.add('header-federation');
select.parentElement = container;
env.document.body.appendChild(container);
const fetchImpl = async () => ({
ok: true,
async json() {
return [{ domain: 'alpha.mesh' }, { domain: 'beta.mesh' }];
}
});
try {
await initializeInstanceSelector({
selectElement: select,
fetchImpl,
windowObject: env.window,
documentObject: env.document
});
assert.ok(!container.hidden, 'container should remain visible with 2 or more instances');
} finally {
env.cleanup();
}
});
test('initializeInstanceSelector updates federation navigation labels with instance count', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
@@ -44,8 +44,6 @@ export const MINIMAL_CONFIG = Object.freeze({
*/
export 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) };
}
@@ -65,6 +63,24 @@ export function withApp(fn) {
}
}
/**
* Spin up a DOM environment, optionally pre-register elements by id, then
* initialise the app with a custom config override. Returns the test utils,
* the environment (for DOM inspection), and a cleanup handle.
*
* @param {{ extraElements?: string[], configOverrides?: Object }} [opts]
* @returns {{ testUtils: Object, env: Object, cleanup: Function }}
*/
export function setupAppWithOptions({ extraElements = [], configOverrides = {} } = {}) {
const env = createDomEnvironment({ includeBody: true });
for (const id of extraElements) {
env.registerElement(id, env.createElement('span', id));
}
const config = { ...MINIMAL_CONFIG, ...configOverrides };
const { _testUtils } = initializeApp(config);
return { testUtils: _testUtils, env, cleanup: env.cleanup.bind(env) };
}
/**
* Extract the serialised HTML string from a DOM element returned by the test
* utils. The stub environment exposes innerHTML as a plain string; this
@@ -183,28 +183,10 @@ test('fetchActiveNodeStats falls back to local counts on invalid payloads', asyn
assert.equal(stats.month, 0);
});
test('formatActiveNodeStatsText emits expected dashboard string', () => {
test('formatActiveNodeStatsText emits compact day/week/month footer string', () => {
const text = formatActiveNodeStatsText({
channel: 'LongFast',
frequency: '868MHz',
stats: { hour: 1, day: 2, week: 3, month: 4, sampled: false },
stats: { day: 2, week: 3, month: 4, sampled: false },
});
assert.equal(
text,
'LongFast (868MHz) — active nodes: 1/hour, 2/day, 3/week, 4/month.'
);
});
test('formatActiveNodeStatsText appends sampled marker when local fallback is used', () => {
const text = formatActiveNodeStatsText({
channel: 'LongFast',
frequency: '868MHz',
stats: { hour: 9, day: 8, week: 7, month: 6, sampled: true },
});
assert.equal(
text,
'LongFast (868MHz) — active nodes: 9/hour, 8/day, 7/week, 6/month (sampled).'
);
assert.equal(text, '2/day \u00b7 3/week \u00b7 4/month');
});
@@ -0,0 +1,245 @@
/*
* 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 { setupApp, setupAppWithOptions } from './main-app-test-helpers.js';
const NOW = 1_700_000_000;
// ---------------------------------------------------------------------------
// updateLegendProtocolCounts
// ---------------------------------------------------------------------------
test('updateLegendProtocolCounts returns early when both count elements are null', () => {
const { testUtils, cleanup } = setupApp();
try {
// Default state: meshcoreCountEl and meshtasticCountEl are null — should not throw.
assert.doesNotThrow(() => {
testUtils.updateLegendProtocolCounts(
[{ last_heard: NOW - 100, protocol: 'meshcore' }],
NOW,
);
});
} finally {
cleanup();
}
});
test('updateLegendProtocolCounts sets per-protocol counts when elements are present', () => {
const { testUtils, cleanup } = setupApp();
try {
const mcEl = { textContent: '' };
const mtEl = { textContent: '' };
testUtils._setProtocolCountElements(mcEl, mtEl);
const nodes = [
{ last_heard: NOW - 100, protocol: 'meshcore' },
{ last_heard: NOW - 200, protocol: 'meshcore' },
{ last_heard: NOW - 300, protocol: 'meshtastic' },
{ last_heard: NOW - (8 * 86_400) }, // outside 7-day window, should not count
];
testUtils.updateLegendProtocolCounts(nodes, NOW);
assert.equal(mcEl.textContent, ' (2)', 'meshcore count should be 2');
assert.equal(mtEl.textContent, ' (1)', 'meshtastic count should be 1');
} finally {
cleanup();
}
});
test('updateLegendProtocolCounts bins unknown protocols into the meshtastic column', () => {
const { testUtils, cleanup } = setupApp();
try {
const mcEl = { textContent: '' };
const mtEl = { textContent: '' };
testUtils._setProtocolCountElements(mcEl, mtEl);
const nodes = [
{ last_heard: NOW - 100, protocol: 'reticulum' }, // unknown → meshtastic bucket
{ last_heard: NOW - 200, protocol: 'meshcore' },
];
testUtils.updateLegendProtocolCounts(nodes, NOW);
assert.equal(mcEl.textContent, ' (1)');
assert.equal(mtEl.textContent, ' (1)');
} finally {
cleanup();
}
});
test('updateLegendProtocolCounts works when only meshcoreCountEl is present', () => {
const { testUtils, cleanup } = setupApp();
try {
const mcEl = { textContent: '' };
testUtils._setProtocolCountElements(mcEl, null);
testUtils.updateLegendProtocolCounts(
[{ last_heard: NOW - 100, protocol: 'meshcore' }],
NOW,
);
assert.equal(mcEl.textContent, ' (1)');
} finally {
cleanup();
}
});
test('updateLegendProtocolCounts works when only meshtasticCountEl is present', () => {
const { testUtils, cleanup } = setupApp();
try {
const mtEl = { textContent: '' };
testUtils._setProtocolCountElements(null, mtEl);
testUtils.updateLegendProtocolCounts(
[{ last_heard: NOW - 100, protocol: 'meshtastic' }],
NOW,
);
assert.equal(mtEl.textContent, ' (1)');
} finally {
cleanup();
}
});
// ---------------------------------------------------------------------------
// updateFooterStats
// ---------------------------------------------------------------------------
test('updateFooterStats is a no-op when footerActiveNodes element is absent', () => {
const { testUtils, cleanup } = setupApp();
try {
assert.doesNotThrow(() => {
testUtils.updateFooterStats([{ last_heard: NOW - 100 }], NOW);
});
} finally {
cleanup();
}
});
test('updateFooterStats populates the active-stats element when present', async () => {
const { testUtils, env, cleanup } = setupAppWithOptions({
extraElements: ['footerActiveNodes'],
});
try {
const el = env.document.getElementById('footerActiveNodes');
testUtils.updateFooterStats([{ last_heard: NOW - 100 }], NOW);
// Drain the microtask queue so the async .then callback executes.
await new Promise(resolve => setImmediate(resolve));
assert.ok(
el.textContent.includes('/day'),
`expected footerActiveNodes to contain "/day", got: ${el.textContent}`,
);
} finally {
cleanup();
}
});
test('updateFooterStats discards stale responses when a newer request is in flight', async () => {
const { testUtils, env, cleanup } = setupAppWithOptions({
extraElements: ['footerActiveNodes'],
});
try {
const el = env.document.getElementById('footerActiveNodes');
// Fire two sequential updates; only the second should be applied.
testUtils.updateFooterStats([{ last_heard: NOW - 100 }], NOW);
testUtils.updateFooterStats([{ last_heard: NOW - 200 }], NOW);
await new Promise(resolve => setImmediate(resolve));
// Either one or neither result lands; the key invariant is no error thrown
// and the element text is a valid stats string or empty.
const text = el.textContent;
assert.ok(
text === '' || text.includes('/day'),
`unexpected footerActiveNodes content: ${text}`,
);
} finally {
cleanup();
}
});
// ---------------------------------------------------------------------------
// restartAutoRefresh
// ---------------------------------------------------------------------------
test('restartAutoRefresh does not start a timer when refreshMs is 0', () => {
// MINIMAL_CONFIG has refreshMs: 0 — timer must not be armed.
const origSetInterval = globalThis.setInterval;
const calls = [];
globalThis.setInterval = (...args) => { calls.push(args); return origSetInterval(...args); };
try {
const { cleanup } = setupApp(); // uses refreshMs: 0
// restartAutoRefresh is called during init; no timer should have been started.
assert.equal(calls.length, 0, 'setInterval should not be called with refreshMs=0');
cleanup();
} finally {
globalThis.setInterval = origSetInterval;
}
});
test('restartAutoRefresh starts a timer when refreshMs > 0', () => {
const timers = [];
const origSetInterval = globalThis.setInterval;
const origClearInterval = globalThis.clearInterval;
globalThis.setInterval = (fn, ms) => {
const id = Symbol('timer');
timers.push({ fn, ms, id });
return id;
};
globalThis.clearInterval = () => {};
try {
const { cleanup } = setupAppWithOptions({ configOverrides: { refreshMs: 30_000 } });
assert.equal(timers.length, 1, 'setInterval should be called once during init');
assert.equal(timers[0].ms, 30_000, 'interval should match configured refreshMs');
cleanup();
} finally {
globalThis.setInterval = origSetInterval;
globalThis.clearInterval = origClearInterval;
}
});
test('restartAutoRefresh clears the existing timer before starting a new one', () => {
const cleared = [];
const timers = [];
const origSetInterval = globalThis.setInterval;
const origClearInterval = globalThis.clearInterval;
globalThis.setInterval = (fn, ms) => {
const id = Symbol('timer');
timers.push(id);
return id;
};
globalThis.clearInterval = id => { cleared.push(id); };
try {
const { testUtils, cleanup } = setupAppWithOptions({ configOverrides: { refreshMs: 30_000 } });
// One timer started during init.
assert.equal(timers.length, 1);
// Calling restartAutoRefresh again must clear the first timer and start a new one.
testUtils.restartAutoRefresh();
assert.equal(cleared.length, 1, 'existing timer should be cleared');
assert.equal(cleared[0], timers[0], 'the original timer id should be cleared');
assert.equal(timers.length, 2, 'a new timer should be started');
cleanup();
} finally {
globalThis.setInterval = origSetInterval;
globalThis.clearInterval = origClearInterval;
}
});
@@ -21,6 +21,7 @@ import {
buildMessageBody,
buildMessageIndex,
normaliseMessageId,
renderLiteralWithLinks,
resolveReplyPrefix
} from '../message-replies.js';
@@ -279,3 +280,86 @@ test('buildMessageBody with renderMentionHtml: unclosed @[ treated as literal',
// @[ without closing ] does not match the pattern — treated as literal
assert.equal(body, 'ESC(hello @[unclosed)');
});
// ---------------------------------------------------------------------------
// renderLiteralWithLinks — URL detection
// ---------------------------------------------------------------------------
const e = v => `E(${v})`;
test('renderLiteralWithLinks passes plain text through escapeHtml', () => {
assert.equal(renderLiteralWithLinks('hello world', e), 'E(hello world)');
});
test('renderLiteralWithLinks wraps http:// URL in an anchor element', () => {
const result = renderLiteralWithLinks('check http://example.com out', e);
assert.equal(result, 'E(check )<a href="E(http://example.com)" target="_blank" rel="noopener noreferrer">E(http://example.com)</a>E( out)');
});
test('renderLiteralWithLinks wraps https:// URL in an anchor element', () => {
const result = renderLiteralWithLinks('see https://example.com/path?q=1', e);
assert.ok(result.includes('<a href='), 'should produce an anchor');
assert.ok(result.includes('target="_blank"'), 'should open in new tab');
assert.ok(result.includes('rel="noopener noreferrer"'), 'should include noopener rel');
});
test('renderLiteralWithLinks strips trailing period from URL', () => {
const result = renderLiteralWithLinks('visit https://example.com.', e);
assert.ok(result.includes('href="E(https://example.com)"'), 'period should not be in href');
assert.ok(result.includes('>E(https://example.com)<'), 'period should not be in link text');
assert.ok(result.endsWith('E(.)'), 'trailing period should appear as escaped text after the link');
});
test('renderLiteralWithLinks strips trailing comma from URL', () => {
const result = renderLiteralWithLinks('go to https://example.com, then stop', e);
assert.ok(result.includes('href="E(https://example.com)"'), 'comma must not be in href');
});
test('renderLiteralWithLinks handles URL at the start of text', () => {
const result = renderLiteralWithLinks('https://example.com is great', e);
assert.ok(result.startsWith('<a href='), 'anchor should be at start');
assert.ok(result.endsWith('E( is great)'), 'text after URL should be escaped');
});
test('renderLiteralWithLinks handles URL at the end of text', () => {
const result = renderLiteralWithLinks('see https://example.com', e);
assert.ok(result.startsWith('E(see )'), 'text before URL should be escaped');
assert.ok(result.includes('<a href='), 'URL should be linked');
});
test('renderLiteralWithLinks handles multiple URLs in text', () => {
const result = renderLiteralWithLinks('a https://foo.com b https://bar.com c', e);
const matches = result.match(/<a href=/g) || [];
assert.equal(matches.length, 2, 'should produce two anchors');
});
test('renderLiteralWithLinks does not linkify non-http schemes', () => {
const result = renderLiteralWithLinks('ftp://example.com', e);
assert.ok(!result.includes('<a href='), 'ftp:// should not be linkified');
assert.equal(result, 'E(ftp://example.com)');
});
test('renderLiteralWithLinks returns empty string for empty input', () => {
assert.equal(renderLiteralWithLinks('', e), '');
});
test('buildMessageBody linkifies URLs in message text without renderMentionHtml', () => {
const body = buildMessageBody({
message: { text: 'visit https://example.com now' },
escapeHtml: e,
renderEmojiHtml: v => `EMOJI(${v})`,
});
assert.ok(body.includes('<a href='), 'URL should be linkified');
assert.ok(body.includes('target="_blank"'), 'should open in new tab');
});
test('buildMessageBody linkifies URLs alongside @[Name] mentions', () => {
const body = buildMessageBody({
message: { text: '@[Alice] see https://example.com' },
escapeHtml: e,
renderEmojiHtml: v => `EMOJI(${v})`,
renderMentionHtml: name => `BADGE(${name})`,
});
assert.ok(body.startsWith('BADGE(Alice)'), 'mention should be rendered as badge');
assert.ok(body.includes('<a href='), 'URL should be linkified');
});
@@ -237,29 +237,16 @@ test('fetchActiveNodeStats concurrent calls share a single in-flight request', a
// formatActiveNodeStatsText
// ---------------------------------------------------------------------------
test('formatActiveNodeStatsText emits expected dashboard sentence', () => {
test('formatActiveNodeStatsText emits compact day/week/month string', () => {
assert.equal(
formatActiveNodeStatsText({
channel: 'LongFast',
frequency: '868MHz',
stats: { hour: 1, day: 2, week: 3, month: 4, sampled: false },
stats: { day: 2, week: 3, month: 4, sampled: false },
}),
'LongFast (868MHz) \u2014 active nodes: 1/hour, 2/day, 3/week, 4/month.'
);
});
test('formatActiveNodeStatsText appends sampled marker', () => {
assert.equal(
formatActiveNodeStatsText({
channel: 'LongFast',
frequency: '868MHz',
stats: { hour: 9, day: 8, week: 7, month: 6, sampled: true },
}),
'LongFast (868MHz) \u2014 active nodes: 9/hour, 8/day, 7/week, 6/month (sampled).'
'2/day \u00b7 3/week \u00b7 4/month'
);
});
test('formatActiveNodeStatsText handles missing or null stats gracefully', () => {
const text = formatActiveNodeStatsText({ channel: 'X', frequency: 'Y', stats: null });
assert.ok(text.includes('0/hour'), 'defaults to zero counts for null stats');
const text = formatActiveNodeStatsText({ stats: null });
assert.equal(text, '0/day \u00b7 0/week \u00b7 0/month', 'defaults to zero counts for null stats');
});
@@ -57,8 +57,6 @@ function executeInDom(source, url, env) {
test('theme and background modules behave correctly across scenarios', async t => {
const env = createDomEnvironment({ readyState: 'complete', cookie: '' });
try {
const toggle = env.createElement('button', 'themeToggle');
env.registerElement('themeToggle', toggle);
let filterInvocations = 0;
env.window.applyFiltersToAllTiles = () => {
filterInvocations += 1;
@@ -72,52 +70,27 @@ test('theme and background modules behave correctly across scenarios', async t =
const backgroundHelpers = env.window.__potatoBackground;
const backgroundHooks = backgroundHelpers.__testHooks;
await t.test('initialises with a dark theme and persists cookies', () => {
await t.test('initialises with a dark theme', () => {
assert.equal(env.document.documentElement.getAttribute('data-theme'), 'dark');
assert.equal(env.document.body.classList.contains('dark'), true);
assert.equal(toggle.textContent, '☀️');
themeHelpers.persistTheme('light');
themeHelpers.setCookie('bare', '1');
themeHooks.exerciseSetCookieGuard();
themeHelpers.setCookie('flag', 'true', { Secure: true });
const cookieString = env.getCookieString();
assert.equal(themeHelpers.getCookie('flag'), 'true');
assert.equal(themeHelpers.getCookie('missing'), null);
assert.match(cookieString, /theme=light/);
assert.match(cookieString, /; path=\//);
assert.match(cookieString, /; SameSite=Lax/);
assert.match(cookieString, /; Secure/);
});
await t.test('serializeCookieOptions covers boolean and string attributes', () => {
const withAttributes = themeHooks.serializeCookieOptions({ Secure: true, HttpOnly: '1' });
assert.equal(withAttributes.includes('; Secure'), true);
assert.equal(withAttributes.includes('; HttpOnly=1'), true);
const secureOnly = themeHooks.serializeCookieOptions({ Secure: true });
assert.equal(secureOnly.trim(), '; Secure');
assert.equal(themeHooks.formatCookieOption(['HttpOnly', '1']), '; HttpOnly=1');
assert.equal(themeHooks.formatCookieOption(['Secure', true]), '; Secure');
assert.equal(themeHooks.serializeCookieOptions({}), '');
assert.equal(themeHooks.serializeCookieOptions(), '');
});
await t.test('re-bootstrap handles DOMContentLoaded flow and filter hooks', () => {
env.document.readyState = 'loading';
filterInvocations = 0;
env.setCookieString('theme=light');
themeHooks.bootstrap();
env.triggerDOMContentLoaded();
assert.equal(env.document.documentElement.getAttribute('data-theme'), 'light');
assert.equal(env.document.body.classList.contains('dark'), false);
assert.equal(toggle.textContent, '🌙');
assert.equal(env.document.documentElement.getAttribute('data-theme'), 'dark');
assert.equal(env.document.body.classList.contains('dark'), true);
assert.equal(filterInvocations, 1);
env.document.removeEventListener('DOMContentLoaded', themeHooks.handleReady);
});
await t.test('handleReady tolerates missing toggle button', () => {
env.registerElement('themeToggle', null);
await t.test('handleReady calls applyFiltersToAllTiles', () => {
filterInvocations = 0;
env.document.readyState = 'complete';
themeHooks.handleReady();
env.registerElement('themeToggle', toggle);
assert.equal(filterInvocations, 1);
});
await t.test('applyTheme copes with absent DOM nodes', () => {
@@ -125,10 +98,10 @@ test('theme and background modules behave correctly across scenarios', async t =
const originalRoot = env.document.documentElement;
env.document.body = null;
env.document.documentElement = null;
assert.equal(themeHooks.applyTheme('dark'), true);
// Should not throw even when DOM nodes are absent
assert.doesNotThrow(() => themeHooks.applyTheme());
env.document.body = originalBody;
env.document.documentElement = originalRoot;
assert.equal(themeHooks.applyTheme('light'), false);
});
await t.test('background bootstrap waits for DOM readiness', () => {
@@ -161,12 +134,12 @@ test('theme and background modules behave correctly across scenarios', async t =
env.document.body = originalBody;
});
await t.test('theme changes trigger background updates', () => {
env.document.body.classList.remove('dark');
themeHooks.setTheme('light');
await t.test('themechange event triggers background update', () => {
env.document.body.classList.add('dark');
backgroundHooks.init();
env.dispatchWindowEvent('themechange');
assert.equal(env.document.documentElement.style.backgroundColor, '#f6f3ee');
// Background should reflect dark mode
assert.ok(env.document.documentElement.style.backgroundColor !== '');
});
env.window.removeEventListener('themechange', backgroundHelpers.applyBackground);
+11 -4
View File
@@ -311,10 +311,17 @@ export function buildChatTabModel({
channel.entries.sort((a, b) => a.ts - b.ts);
channel.messageCount = channel.entries.length;
}
// Sort channels by activity (most messages first), then alphabetically on ties.
const channels = Array.from(channelBuckets.values()).sort((a, b) =>
b.messageCount - a.messageCount || a.label.localeCompare(b.label)
);
// Sort channels into two tiers:
// 1. Primary channels (channel index 0 — LongFast, MediumFast, Public, etc.)
// ordered by activity desc so the most-active protocol leads within the tier.
// 2. Secondary channels (index > 0) ordered by activity desc, then alpha.
// Within each tier, ties on messageCount are broken alphabetically by label.
const channels = Array.from(channelBuckets.values()).sort((a, b) => {
const aTier = a.index === 0 ? 0 : 1;
const bTier = b.index === 0 ? 0 : 1;
if (aTier !== bTier) return aTier - bTier;
return b.messageCount - a.messageCount || a.label.localeCompare(b.label);
});
return { logEntries, channels };
}
+119 -22
View File
@@ -113,38 +113,135 @@ function colorForNodeCount(count) {
}
/**
* Render arbitrary contact text while hyperlinking recognised URL-like segments.
* Matches recognised link-like segments in plain text:
* - Absolute URLs (https?://, mailto:, matrix:)
* - Matrix room aliases (#room:domain.tld)
* - Matrix user IDs (@user:domain.tld)
* - Bare domain-with-path (discord.gg/..., t.me/...)
*
* @param {*} contact Raw contact value from the API.
* @returns {string} HTML markup safe for insertion.
* Character classes use possessive-style atomic groupings (no overlap) so the
* regex engine cannot backtrack into super-linear runtime.
*/
function renderContactHtml(contact) {
if (typeof contact !== 'string') return '';
const trimmed = contact.trim();
if (!trimmed) return '';
const urlPattern = /(https?:\/\/[^\s]+|mailto:[^\s]+|matrix:[^\s]+)/gi;
const CONTACT_LINK_PATTERN =
/(https?:\/\/\S+|mailto:\S+|matrix:\S+|[@#][a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}|[a-zA-Z0-9-]+\.[a-zA-Z]{2,}\/\S+)/gi;
/**
* Regex matching `<a>` elements (including malformed unquoted attributes) and
* any other HTML tags so they can be handled separately from plain text.
*
* The `<a>` branch uses `[^<]*` (no nesting) instead of `[\s\S]*?` to avoid
* super-linear backtracking when nested or malformed tags are present.
*/
const HTML_SEGMENT_PATTERN = /(<a\b[^>]*>[^<]*<\/a\s*>|<[^>]+>)/gi;
/** Extracts the href value from an `<a>` opening tag, quoted or unquoted. */
const HREF_ATTR_PATTERN = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*))/i;
/** Protocols allowed in whitelisted `<a>` hrefs. */
const SAFE_HREF_RE = /^(?:https?:\/\/|matrix:|mailto:)/i;
/**
* Resolve a raw matched token to an href string.
*
* @param {string} raw Matched token from CONTACT_LINK_PATTERN.
* @returns {string} Absolute href suitable for use in an anchor element.
*/
function resolveHref(raw) {
if (raw.startsWith('#') || raw.startsWith('@')) {
// Matrix room alias or user ID → matrix.to permalink
return `https://matrix.to/#/${raw}`;
}
if (/^[a-zA-Z0-9-]+\./.test(raw) && !raw.includes('://')) {
// Bare domain-with-path (e.g. discord.gg/…) — prepend https://
return `https://${raw}`;
}
return raw;
}
/**
* Linkify URL-like tokens in a plain-text (already HTML-free) segment.
*
* @param {string} text Plain text with no HTML tags.
* @returns {string} HTML-safe string with recognised links wrapped in anchors.
*/
function renderPlainSegment(text) {
const parts = [];
let lastIndex = 0;
const pattern = new RegExp(CONTACT_LINK_PATTERN.source, 'gi');
let match;
while ((match = pattern.exec(text)) !== null) {
const before = text.slice(lastIndex, match.index);
if (before) parts.push(escapeHtml(before));
const raw = match[0];
const href = resolveHref(raw);
parts.push(`<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(raw)}</a>`);
lastIndex = match.index + raw.length;
}
const trailing = text.slice(lastIndex);
if (trailing) parts.push(escapeHtml(trailing));
return parts.join('');
}
/**
* Render arbitrary contact or channel text as safe HTML.
*
* - Existing `<a>` tags are sanitised: href is validated against an allowlist
* of safe protocols (https, http, matrix:, mailto:), link text is escaped,
* and `target="_blank" rel="noopener noreferrer"` is always applied.
* - Other HTML tags (e.g. `<b>`) are stripped; their text content is kept.
* - Plain-text segments are scanned for URLs, Matrix aliases/user-IDs, and
* bare domain-with-path references (e.g. discord.gg/) and linkified.
* - Line breaks are converted to `<br>`.
*
* @param {*} text Raw value from the API (may contain HTML).
* @returns {string} HTML markup safe for insertion.
*/
function renderContactHtml(text) {
if (typeof text !== 'string') return '';
const trimmed = text.trim();
if (!trimmed) return '';
const parts = [];
let lastIndex = 0;
const segPattern = new RegExp(HTML_SEGMENT_PATTERN.source, 'gi');
let match;
while ((match = urlPattern.exec(trimmed)) !== null) {
const textBefore = trimmed.slice(lastIndex, match.index);
if (textBefore) {
parts.push(escapeHtml(textBefore));
while ((match = segPattern.exec(trimmed)) !== null) {
// Linkify plain text before this HTML segment
const before = trimmed.slice(lastIndex, match.index);
if (before) parts.push(renderPlainSegment(before));
const tag = match[0];
if (/^<a\b/i.test(tag)) {
// Whitelisted <a> tag — extract href, validate, re-render safely
const hrefMatch = HREF_ATTR_PATTERN.exec(tag);
const href = hrefMatch ? (hrefMatch[1] ?? hrefMatch[2] ?? hrefMatch[3] ?? '') : '';
// Strip HTML tags to derive plain link text; content is still escaped below.
// The replace runs in a loop to handle residual tags left after the first
// pass — this is safe because a single-pass replace of `<…>` can leave
// behind a reconstructed tag when the input contains e.g. `<<script>`.
let linkText = tag;
let prev;
do { prev = linkText; linkText = linkText.replace(/<[^>]*>/g, ''); } while (linkText !== prev);
linkText = linkText.trim();
if (SAFE_HREF_RE.test(href)) {
parts.push(`<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(linkText || href)}</a>`);
} else {
// Unsafe or missing href — render link text only
parts.push(escapeHtml(linkText));
}
}
const url = match[0];
const safeUrl = escapeHtml(url);
parts.push(`<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeUrl}</a>`);
lastIndex = match.index + url.length;
// Other tags (<b>, </b>, etc.) are stripped; their text content falls
// through as plain text in subsequent iterations.
lastIndex = match.index + tag.length;
}
// Linkify any remaining plain text after the last HTML segment
const trailing = trimmed.slice(lastIndex);
if (trailing) {
parts.push(escapeHtml(trailing));
}
if (trailing) parts.push(renderPlainSegment(trailing));
const html = parts.join('');
return html.replace(/\r?\n/g, '<br>');
return parts.join('').replace(/\r?\n/g, '<br>');
}
/**
@@ -387,7 +484,7 @@ export async function initializeFederationPage(options = {}) {
<td class="instances-col instances-col--domain mono">${domainHtml}</td>
<td class="instances-col instances-col--contact">${contactHtml || '<em>—</em>'}</td>
<td class="instances-col instances-col--version mono">${escapeHtml(instance.version || '')}</td>
<td class="instances-col instances-col--channel">${escapeHtml(instance.channel || '')}</td>
<td class="instances-col instances-col--channel">${renderContactHtml(instance.channel) || ''}</td>
<td class="instances-col instances-col--frequency">${escapeHtml(instance.frequency || '')}</td>
<td class="instances-col instances-col--nodes mono">${nodesCountText}</td>
<td class="instances-col instances-col--latitude mono">${fmtCoords(instance.latitude)}</td>
@@ -205,6 +205,18 @@ export async function initializeInstanceSelector(options) {
const visibleEntries = filterDisplayableFederationInstances(payload);
updateFederationNavCount({ documentObject: doc, count: visibleEntries.length });
// Hide the selector when fewer than two instances are available — a single
// entry (only the current instance) offers no meaningful navigation choice.
if (visibleEntries.length < 2) {
const selectorContainer = (typeof selectElement.closest === 'function'
? selectElement.closest('.header-federation')
: null) || selectElement.parentElement;
if (selectorContainer) {
selectorContainer.hidden = true;
}
return;
}
const sanitizedDomain = typeof instanceDomain === 'string' ? instanceDomain.trim().toLowerCase() : null;
const sortedEntries = visibleEntries
+102 -239
View File
@@ -131,30 +131,20 @@ import {
*/
export function initializeApp(config) {
const statusEl = document.getElementById('status');
const fitBoundsEl = document.getElementById('fitBounds');
const autoRefreshEl = document.getElementById('autoRefresh');
const footerActiveNodes = document.getElementById('footerActiveNodes');
const refreshBtn = document.getElementById('refreshBtn');
const filterInput = document.getElementById('filterInput');
const filterClearButton = document.getElementById('filterClear');
const themeToggle = document.getElementById('themeToggle');
const infoBtn = document.getElementById('infoBtn');
const infoOverlay = document.getElementById('infoOverlay');
const infoClose = document.getElementById('infoClose');
const infoDialog = infoOverlay ? infoOverlay.querySelector('.info-dialog') : null;
const shortInfoTemplate = document.getElementById('shortInfoOverlayTemplate');
const overlayStack = createShortInfoOverlayStack({ document, window, template: shortInfoTemplate });
const titleEl = document.querySelector('title');
const headerEl = document.querySelector('h1');
const headerTitleTextEl = headerEl ? headerEl.querySelector('.site-title-text') : null;
const chatEl = document.getElementById('chat');
const refreshInfo = document.getElementById('refreshInfo');
const instanceSelect = document.getElementById('instanceSelect');
const baseTitle = document.title;
const nodesTable = document.getElementById('nodes');
const sortButtons = nodesTable ? Array.from(nodesTable.querySelectorAll('thead .sort-button[data-sort-key]')) : [];
const infoOverlayHome = infoOverlay
? { parent: infoOverlay.parentNode, nextSibling: infoOverlay.nextSibling }
: null;
const bodyClassList = document.body ? document.body.classList : null;
const isPrivateMode = document.body && document.body.dataset
? String(document.body.dataset.privateMode).toLowerCase() === 'true'
@@ -245,13 +235,6 @@ export function initializeApp(config) {
const REFRESH_MS = config.refreshMs;
const CHAT_ENABLED = Boolean(config.chatEnabled);
const instanceSelectorEnabled = Boolean(config.instancesFeatureEnabled);
if (refreshInfo) {
if (isDashboardView) {
refreshInfo.textContent = `${config.channel} (${config.frequency}) — active nodes: …`;
} else {
refreshInfo.textContent = '';
}
}
if (instanceSelectorEnabled && instanceSelect) {
void initializeInstanceSelector({
@@ -265,7 +248,7 @@ export function initializeApp(config) {
/** @type {ReturnType<typeof setTimeout>|null} */
let refreshTimer = null;
let refreshInfoRequestId = 0;
let activeStatsRequestId = 0;
/**
* Close any open short-info overlays that do not contain the provided anchor.
@@ -463,24 +446,18 @@ export function initializeApp(config) {
*/
function restartAutoRefresh() {
// Tear down any existing timer so the interval never double-fires when
// the user toggles auto-refresh or the config is re-applied.
// the config is re-applied.
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
// Only arm the timer when the auto-refresh checkbox is checked; a
// disabled checkbox (e.g. during an active fetch) keeps it off.
if (autoRefreshEl && autoRefreshEl.checked) {
// 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) {
refreshTimer = setInterval(refresh, REFRESH_MS);
}
}
if (fitBoundsEl && mapZoomOverride !== null) {
fitBoundsEl.checked = false;
fitBoundsEl.disabled = true;
fitBoundsEl.setAttribute('aria-disabled', 'true');
}
const MAP_CENTER_COORDS = Object.freeze({ lat: config.mapCenter.lat, lon: config.mapCenter.lon });
const hasLeaflet = typeof window !== 'undefined' && typeof window.L === 'object' && window.L && typeof window.L.map === 'function';
const mapContainer = document.getElementById('map');
@@ -524,7 +501,7 @@ export function initializeApp(config) {
];
const autoFitController = createMapAutoFitController({
toggleEl: fitBoundsEl,
toggleEl: null,
windowObject: typeof window !== 'undefined' ? window : undefined,
defaultPaddingPx: AUTO_FIT_PADDING_PX
});
@@ -542,7 +519,6 @@ export function initializeApp(config) {
const centerResetHandler = createMapCenterResetHandler({
getMap: () => map,
autoFitController,
fitBoundsEl,
fitMapToBounds,
mapCenterCoords: MAP_CENTER_COORDS,
mapZoomOverride,
@@ -739,62 +715,6 @@ export function initializeApp(config) {
});
});
/**
* Append the informational modal overlay to the fullscreen container when active.
*
* @returns {void}
*/
function attachInfoOverlayToFullscreenHost() {
if (!infoOverlay || !fullscreenContainer) return;
if (infoOverlay.parentNode !== fullscreenContainer) {
fullscreenContainer.appendChild(infoOverlay);
}
if (infoOverlay.classList) {
infoOverlay.classList.add('info-overlay--fullscreen');
}
}
/**
* Restore the informational overlay to its original DOM position.
*
* @returns {void}
*/
function restoreInfoOverlayToHome() {
if (!infoOverlay || !infoOverlayHome || !infoOverlayHome.parent) return;
if (infoOverlay.parentNode === infoOverlayHome.parent) {
if (infoOverlay.classList) {
infoOverlay.classList.remove('info-overlay--fullscreen');
}
return;
}
if (
infoOverlayHome.nextSibling &&
infoOverlayHome.nextSibling.parentNode === infoOverlayHome.parent &&
typeof infoOverlayHome.parent.insertBefore === 'function'
) {
infoOverlayHome.parent.insertBefore(infoOverlay, infoOverlayHome.nextSibling);
} else if (typeof infoOverlayHome.parent.appendChild === 'function') {
infoOverlayHome.parent.appendChild(infoOverlay);
}
if (infoOverlay.classList) {
infoOverlay.classList.remove('info-overlay--fullscreen');
}
}
/**
* Ensure the informational overlay participates in the active fullscreen subtree.
*
* @returns {void}
*/
function syncInfoOverlayHost() {
if (!infoOverlay) return;
if (isMapInFullscreen()) {
attachInfoOverlayToFullscreenHost();
} else {
restoreInfoOverlayToHome();
}
}
/**
* Respond to fullscreen change events originating from the browser.
*
@@ -825,7 +745,6 @@ export function initializeApp(config) {
mapContainer.style.minHeight = '';
}
}
syncInfoOverlayHost();
updateFullscreenToggleState();
refreshMapSize();
}
@@ -853,8 +772,6 @@ export function initializeApp(config) {
});
}
syncInfoOverlayHost();
/** @type {Set<string>} Active compound role-filter keys, each ``"<protocol>:<roleKey>"``. */
const activeRoleFilters = new Set();
/** @type {Map<string, HTMLElement>} Compound key → legend button element. */
@@ -1395,6 +1312,8 @@ export function initializeApp(config) {
let legendContainer = null;
let legendToggleControl = null;
let meshcoreCountEl = null;
let meshtasticCountEl = null;
let legendToggleButton = null;
let legendVisible = true;
@@ -1479,7 +1398,9 @@ export function initializeApp(config) {
if (!neighborLinesToggleButton) return;
const label = neighborLinesVisible ? 'Hide neighbor lines' : 'Show neighbor lines';
neighborLinesToggleButton.textContent = label;
neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'true' : 'false');
// aria-pressed reflects whether the user has *activated* the toggle (i.e. lines are
// currently hidden). When lines are visible (default), the button is unpressed.
neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'false' : 'true');
neighborLinesToggleButton.setAttribute('aria-label', label);
}
@@ -1511,7 +1432,8 @@ export function initializeApp(config) {
if (!traceLinesToggleButton) return;
const label = traceLinesVisible ? 'Hide trace lines' : 'Show trace lines';
traceLinesToggleButton.textContent = label;
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false');
// aria-pressed reflects whether the user has *activated* the toggle (lines hidden).
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'false' : 'true');
traceLinesToggleButton.setAttribute('aria-label', label);
}
@@ -1629,14 +1551,30 @@ export function initializeApp(config) {
}
if (map && hasLeaflet) {
const legend = L.control({ position: 'bottomright' });
// Single combined control: [toggle button | legend panel] in a flex row.
// The toggle sits to the left so it remains accessible when the legend is collapsed.
const legendControl = L.control({ position: 'bottomright' });
/**
* Leaflet control factory that renders the legend UI.
* Leaflet control factory that renders the toggle button and legend panel
* as a single side-by-side control.
*
* @returns {HTMLElement} Legend DOM element.
* @returns {HTMLElement} Wrapper element containing both children.
*/
legend.onAdd = function () {
const div = L.DomUtil.create('div', 'legend');
legendControl.onAdd = function () {
const wrapper = L.DomUtil.create('div', 'legend-outer');
// --- Toggle button (left) ---
const button = L.DomUtil.create('button', 'legend-toggle-button', wrapper);
button.type = 'button';
button.setAttribute('aria-pressed', 'true');
button.setAttribute('aria-controls', 'mapLegend');
button.addEventListener('click', legendClickHandler(() => {
setLegendVisibility(!legendVisible);
}));
legendToggleButton = button;
// --- Legend panel (right) ---
const div = L.DomUtil.create('div', 'legend', wrapper);
div.id = 'mapLegend';
div.setAttribute('role', 'region');
div.setAttribute('aria-label', 'Map legend');
@@ -1648,13 +1586,16 @@ export function initializeApp(config) {
const itemsContainer = L.DomUtil.create('div', 'legend-items legend-items--columns', div);
// --- MeshCore column (left) ---
const meshcoreCol = L.DomUtil.create('div', 'legend-column', itemsContainer);
// --- MeshCore column (left, bottom-aligned) ---
const meshcoreCol = L.DomUtil.create('div', 'legend-column legend-column--bottom', itemsContainer);
const meshcoreColHeader = L.DomUtil.create('div', 'legend-column-header', meshcoreCol);
meshcoreColHeader.appendChild(buildMeshcoreIconImg());
const meshcoreColTitle = document.createElement('span');
meshcoreColTitle.textContent = 'MeshCore';
meshcoreColHeader.appendChild(meshcoreColTitle);
meshcoreCountEl = document.createElement('span');
meshcoreCountEl.className = 'legend-protocol-count';
meshcoreColHeader.appendChild(meshcoreCountEl);
// --- Meshtastic column (right) ---
const meshtasticCol = L.DomUtil.create('div', 'legend-column', itemsContainer);
@@ -1663,18 +1604,17 @@ export function initializeApp(config) {
const meshtasticColTitle = document.createElement('span');
meshtasticColTitle.textContent = 'Meshtastic';
meshtasticColHeader.appendChild(meshtasticColTitle);
meshtasticCountEl = document.createElement('span');
meshtasticCountEl.className = 'legend-protocol-count';
meshtasticColHeader.appendChild(meshtasticCountEl);
legendRoleButtons.clear();
buildRoleButtons(meshcoreCol, meshcoreRoleColors, 'meshcore');
buildRoleButtons(meshtasticCol, roleColors, 'meshtastic');
// --- Protocol hide toggles — one per column footer ---
// --- MeshCore column footer: protocol hide toggle ---
legendProtocolButtons.clear();
const protocolColDefs = [
{ protocol: 'meshcore', col: meshcoreCol },
{ protocol: 'meshtastic', col: meshtasticCol },
];
for (const { protocol, col } of protocolColDefs) {
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';
@@ -1690,9 +1630,10 @@ export function initializeApp(config) {
applyFilter();
}));
legendProtocolButtons.set(protocol, btn);
}
};
buildProtocolToggle('meshcore', meshcoreCol);
// --- Line toggles in the Meshtastic column ---
// --- Meshtastic column: line toggles then protocol hide toggle at bottom ---
neighborLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-neighbors', meshtasticCol);
neighborLinesToggleButton.type = 'button';
neighborLinesToggleButton.addEventListener('click', legendClickHandler(() => {
@@ -1707,12 +1648,15 @@ 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 ---
const toggle = L.DomUtil.create('div', 'legend-toggle', div);
const filterToggle = L.DomUtil.create('div', 'legend-toggle', div);
const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', toggle);
const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', filterToggle);
resetButton.type = 'button';
resetButton.textContent = 'Clear filters';
resetButton.addEventListener('click', legendClickHandler(() => {
@@ -1722,38 +1666,12 @@ export function initializeApp(config) {
applyFilter();
}));
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
return div;
};
legend.addTo(map);
legendContainer = legend.getContainer();
legendToggleControl = L.control({ position: 'bottomright' });
/**
* Leaflet control factory for the legend visibility toggle.
*
* @returns {HTMLElement} Toggle button element.
*/
legendToggleControl.onAdd = function () {
const container = L.DomUtil.create('div', 'leaflet-control legend-toggle');
const button = L.DomUtil.create('button', 'legend-toggle-button', container);
button.type = 'button';
button.textContent = 'Hide legend (filters)';
button.setAttribute('aria-pressed', 'true');
button.setAttribute('aria-label', 'Hide map legend');
button.setAttribute('aria-controls', 'mapLegend');
button.addEventListener('click', legendClickHandler(() => {
setLegendVisibility(!legendVisible);
}));
legendToggleButton = button;
updateLegendToggleState();
L.DomEvent.disableClickPropagation(container);
L.DomEvent.disableScrollPropagation(container);
return container;
L.DomEvent.disableClickPropagation(wrapper);
L.DomEvent.disableScrollPropagation(wrapper);
return wrapper;
};
legendToggleControl.addTo(map);
legendControl.addTo(map);
const legendMediaQuery = window.matchMedia('(max-width: 1024px)');
const initialLegendVisible = resolveLegendVisibility({
@@ -1770,72 +1688,6 @@ export function initializeApp(config) {
setLegendVisibility(false);
}
themeToggle.addEventListener('click', () => {
const dark = document.body.classList.toggle('dark');
const themeValue = dark ? 'dark' : 'light';
document.body.setAttribute('data-theme', themeValue);
if (document.documentElement) {
document.documentElement.setAttribute('data-theme', themeValue);
}
themeToggle.textContent = dark ? '☀️' : '🌙';
if (window.__themeCookie) {
if (typeof window.__themeCookie.persistTheme === 'function') {
window.__themeCookie.persistTheme(themeValue);
} else if (typeof window.__themeCookie.setCookie === 'function') {
window.__themeCookie.setCookie('theme', themeValue);
}
}
window.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeValue } }));
if (typeof window.applyFiltersToAllTiles === 'function') window.applyFiltersToAllTiles();
});
let lastFocusBeforeInfo = null;
/**
* Display the modal overlay containing site information.
*
* @returns {void}
*/
function openInfoOverlay() {
if (!infoOverlay || !infoDialog) return;
syncInfoOverlayHost();
lastFocusBeforeInfo = document.activeElement;
infoOverlay.hidden = false;
document.body.style.setProperty('overflow', 'hidden');
infoDialog.focus();
}
/**
* Hide the site information overlay and restore focus.
*
* @returns {void}
*/
function closeInfoOverlay() {
if (!infoOverlay || !infoDialog) return;
infoOverlay.hidden = true;
document.body.style.removeProperty('overflow');
const target = lastFocusBeforeInfo && typeof lastFocusBeforeInfo.focus === 'function' ? lastFocusBeforeInfo : infoBtn;
if (target && typeof target.focus === 'function') {
target.focus();
}
lastFocusBeforeInfo = null;
}
if (infoBtn && infoOverlay && infoClose) {
infoBtn.addEventListener('click', openInfoOverlay);
infoClose.addEventListener('click', closeInfoOverlay);
infoOverlay.addEventListener('click', event => {
if (event.target === infoOverlay) {
closeInfoOverlay();
}
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !infoOverlay.hidden) {
closeInfoOverlay();
}
});
}
const nodeDetailOverlayManager = createNodeDetailOverlayManager({
document,
privateMode: isPrivateMode,
@@ -4435,10 +4287,6 @@ export function initializeApp(config) {
},
});
}
if (pts.length && fitBoundsEl && fitBoundsEl.checked) {
const bounds = computeBoundsForPoints(pts, { ...autoFitBoundsConfig });
fitMapToBounds(bounds, { animate: false, paddingPx: AUTO_FIT_PADDING_PX });
}
overlayStack.cleanupOrphans();
}
@@ -4521,8 +4369,11 @@ export function initializeApp(config) {
const nowSec = Date.now()/1000;
renderTable(sortedNodes, nowSec);
renderMap(sortedNodes, nowSec);
updateCount(sortedNodes, nowSec);
updateRefreshInfo(sortedNodes, nowSec);
// Title and legend counts are intentionally global — they reflect the whole
// network, not just the nodes visible under the current filter.
updateTitleCount(allNodes, nowSec);
updateLegendProtocolCounts(allNodes, nowSec);
updateFooterStats(sortedNodes, nowSec);
updateSortIndicators();
// Pass the raw filterQuery (not the normalised form) so the chat log can
// highlight matching substrings in their original case.
@@ -4658,8 +4509,8 @@ export function initializeApp(config) {
}
}
// Kick off the first data load immediately; interval-based auto-refresh
// begins only if the checkbox is already checked on page load.
// Kick off the first data load immediately then start the silent background
// auto-refresh timer.
refresh();
restartAutoRefresh();
@@ -4668,27 +4519,16 @@ export function initializeApp(config) {
refreshBtn.addEventListener('click', refresh);
}
if (autoRefreshEl) {
// When the user enables auto-refresh mid-session, trigger an immediate
// fetch so the UI does not sit stale until the next interval fires.
autoRefreshEl.addEventListener('change', () => {
restartAutoRefresh();
if (autoRefreshEl.checked) {
refresh();
}
});
}
/**
* Update the count badge showing how many nodes are displayed.
* Update the page/tab title with the total active-node count for the past 7 days.
*
* @param {Array<Object>} nodes Node payloads.
* @param {Array<Object>} nodes All node payloads (unfiltered counts are global).
* @param {number} nowSec Reference timestamp.
* @returns {void}
*/
function updateCount(nodes, nowSec) {
const dayAgoSec = nowSec - 86400;
const count = nodes.filter(n => n.last_heard && Number(n.last_heard) >= dayAgoSec).length;
function updateTitleCount(nodes, nowSec) {
const weekAgoSec = nowSec - 7 * 86_400;
const count = nodes.filter(n => n.last_heard && Number(n.last_heard) >= weekAgoSec).length;
const text = `${baseTitle} (${count})`;
titleEl.textContent = text;
if (headerTitleTextEl) {
@@ -4699,26 +4539,40 @@ export function initializeApp(config) {
}
/**
* Update the status message describing the currently rendered data.
* Update legend column headers with per-protocol active node counts (7 days).
*
* @param {Array<Object>} nodes All node payloads (unfiltered).
* @param {number} nowSec Reference timestamp.
* @returns {void}
*/
function updateLegendProtocolCounts(nodes, nowSec) {
if (!meshcoreCountEl && !meshtasticCountEl) return;
const weekAgoSec = nowSec - 7 * 86_400;
const recentNodes = nodes.filter(n => Number.isFinite(Number(n.last_heard)) && Number(n.last_heard) >= weekAgoSec);
const meshcoreCount = recentNodes.filter(n => n.protocol === 'meshcore').length;
// Treat any non-meshcore node as Meshtastic until additional protocols are supported.
const meshtasticCount = recentNodes.filter(n => n.protocol !== 'meshcore').length;
if (meshcoreCountEl) meshcoreCountEl.textContent = ` (${meshcoreCount})`;
if (meshtasticCountEl) meshtasticCountEl.textContent = ` (${meshtasticCount})`;
}
/**
* Update the footer active-node stats element with day/week/month counts.
*
* @param {Array<Object>} nodes Node payloads.
* @param {number} nowSec Reference timestamp.
* @returns {void}
*/
function updateRefreshInfo(nodes, nowSec) {
if (!refreshInfo || !isDashboardView) {
function updateFooterStats(nodes, nowSec) {
if (!footerActiveNodes) {
return;
}
const requestId = ++refreshInfoRequestId;
const requestId = ++activeStatsRequestId;
void fetchActiveNodeStats({ nodes, nowSeconds: nowSec }).then(stats => {
if (requestId !== refreshInfoRequestId) {
if (requestId !== activeStatsRequestId) {
return;
}
refreshInfo.textContent = formatActiveNodeStatsText({
channel: config.channel,
frequency: config.frequency,
stats
});
footerActiveNodes.textContent = 'Active: ' + formatActiveNodeStatsText({ stats });
});
}
@@ -4750,6 +4604,15 @@ export function initializeApp(config) {
hiddenProtocols,
legendRoleButtons,
legendProtocolButtons,
updateTitleCount,
updateLegendProtocolCounts,
updateFooterStats,
restartAutoRefresh,
/** Inject mock count span elements for legend protocol count tests. */
_setProtocolCountElements(mc, mt) {
meshcoreCountEl = mc;
meshtasticCountEl = mt;
},
},
};
}
+53 -8
View File
@@ -339,12 +339,55 @@ function resolveMessageTextSegment(message, isReaction) {
}
/**
* Render a text segment, replacing ``@[Name]`` mention patterns with the
* output of ``renderMentionHtml`` when provided. Segments between mentions
* are passed through ``escapeHtml`` to prevent XSS.
* Regex with a single capturing group that matches http:// and https:// URLs.
* Used by {@link renderLiteralWithLinks} to split text into URL and non-URL
* segments while preserving the matched URL in the resulting array.
* @type {RegExp}
*/
const URL_SPLIT_PATTERN = /(https?:\/\/[^\s<>"'[\]]{1,2048})/;
/**
* Strip trailing punctuation characters that are typically sentence
* delimiters rather than part of a URL (e.g. a period at end of sentence).
*
* When ``renderMentionHtml`` is ``null`` the function behaves identically to
* ``escapeHtml(text)``, preserving backward compatibility.
* @param {string} url Raw URL candidate.
* @returns {string} URL with trailing punctuation trimmed.
*/
function trimUrlTrailingPunctuation(url) {
return url.replace(/[.,;!?)]+$/, '');
}
/**
* Render a single raw text segment, converting any ``http://`` or
* ``https://`` URLs into ``<a>`` elements that open in a new tab.
* Non-URL text is passed through ``escapeHtml`` unchanged.
*
* @param {string} text Raw (unescaped) literal text.
* @param {Function} escapeHtml HTML-escape function.
* @returns {string} Safe HTML with URLs wrapped in anchor elements.
*/
export function renderLiteralWithLinks(text, escapeHtml) {
// split() with a capturing group interleaves plain text (even indices)
// and matched URLs (odd indices): ["before", "https://x", " after", ...]
const parts = text.split(URL_SPLIT_PATTERN);
return parts.map((part, i) => {
if (i % 2 === 0) {
return part ? escapeHtml(part) : '';
}
// URL segment — strip trailing punctuation then linkify.
const url = trimUrlTrailingPunctuation(part);
const trailing = part.slice(url.length);
return `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>${trailing ? escapeHtml(trailing) : ''}`;
}).join('');
}
/**
* Render a text segment, replacing ``@[Name]`` mention patterns with the
* output of ``renderMentionHtml`` when provided. Literal text segments are
* passed through {@link renderLiteralWithLinks} so that URLs become clickable.
*
* When ``renderMentionHtml`` is ``null`` the function is equivalent to
* calling {@link renderLiteralWithLinks} on the whole string.
*
* @param {string} text Raw message text segment.
* @param {Function} escapeHtml HTML-escape function.
@@ -353,20 +396,22 @@ function resolveMessageTextSegment(message, isReaction) {
* @returns {string} HTML string safe for insertion into the DOM.
*/
function renderTextWithMentions(text, escapeHtml, renderMentionHtml) {
if (typeof renderMentionHtml !== 'function') return escapeHtml(text);
if (typeof renderMentionHtml !== 'function') return renderLiteralWithLinks(text, escapeHtml);
// split() with a capturing group interleaves literal segments (even indices)
// and captured mention names (odd indices): ["before", "Alice", "after", ...]
const parts = text.split(/@\[([^\]]+)\]/);
return parts.map((part, i) => {
if (i % 2 === 1) return renderMentionHtml(part);
// Empty literal segments (e.g. when a mention is at the start or end) can
// be skipped to avoid unnecessary escapeHtml calls on empty strings.
return part ? escapeHtml(part) : '';
// be skipped to avoid unnecessary renderLiteralWithLinks calls.
return part ? renderLiteralWithLinks(part, escapeHtml) : '';
}).join('');
}
/**
* Build the rendered message body containing text and optional emoji.
* ``http://`` and ``https://`` URLs in the message text are automatically
* converted to ``<a>`` elements that open in a new tab.
*
* @param {{
* message: Object,
+5 -7
View File
@@ -154,18 +154,16 @@ export async function fetchActiveNodeStats({ nodes, nowSeconds, fetchImpl = fetc
}
/**
* Format the dashboard refresh-info sentence for active-node counts.
* Format the active-node counts for display in the footer.
*
* @param {{channel: string, frequency: string, stats: {hour:number,day:number,week:number,month:number,sampled:boolean}}} params Formatting data.
* @returns {string} User-visible sentence for the dashboard header.
* @param {{stats: {day:number,week:number,month:number,sampled:boolean}}} params Formatting data.
* @returns {string} Compact user-visible string, e.g. ``"569/day · 729/week · 1168/month"``.
*/
export function formatActiveNodeStatsText({ channel, frequency, stats }) {
export function formatActiveNodeStatsText({ stats }) {
const parts = [
`${Number(stats?.hour) || 0}/hour`,
`${Number(stats?.day) || 0}/day`,
`${Number(stats?.week) || 0}/week`,
`${Number(stats?.month) || 0}/month`
];
const suffix = stats?.sampled ? ' (sampled)' : '';
return `${channel} (${frequency}) — active nodes: ${parts.join(', ')}${suffix}.`;
return parts.join(' · ');
}
+11 -142
View File
@@ -16,128 +16,22 @@
(function () {
/**
* Number of seconds theme preferences should persist in the cookie store.
* Apply the dark theme to the root HTML and body elements.
*
* @type {number}
*/
var THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
/**
* Retrieve a cookie value by name.
*
* @param {string} name Cookie identifier.
* @returns {?string} Decoded cookie value or ``null`` when absent.
*/
function getCookie(name) {
var matcher = new RegExp(
'(?:^|; )' + name.replace(/([.$?*|{}()\[\]\\/+^])/g, '\\$1') + '=([^;]*)'
);
var match = document.cookie.match(matcher);
return match ? decodeURIComponent(match[1]) : null;
}
/**
* Convert cookie options to a serialized string suitable for ``document.cookie``.
*
* @param {Object<string, *>} options Map of cookie attribute keys and values.
* @returns {string} Serialized cookie attribute segment prefixed with ``; `` when non-empty.
*/
function formatCookieOption(pair) {
var key = pair[0];
var optionValue = pair[1];
if (optionValue === true) {
return '; ' + key;
}
return '; ' + key + '=' + optionValue;
}
/**
* Serialise cookie attributes into a string consumable by ``document.cookie``.
*
* @param {Object<string, *>} options Map of cookie attribute keys and values.
* @returns {string} Concatenated cookie attribute segment.
*/
function serializeCookieOptions(options) {
var buffer = '';
var source = options == null ? {} : options;
var entries = Object.entries(source);
for (var index = 0; index < entries.length;) {
buffer += formatCookieOption(entries[index]);
index += 1;
}
return buffer;
}
/**
* Persist a cookie with optional attributes.
*
* @param {string} name Cookie identifier.
* @param {string} value Value to store.
* @param {Object<string, *>} [opts] Additional cookie attributes.
* @returns {void}
*/
function setCookie(name, value, opts) {
var options = Object.assign(
{ path: '/', 'max-age': THEME_COOKIE_MAX_AGE, SameSite: 'Lax' },
opts || {}
);
var updated = encodeURIComponent(name) + '=' + encodeURIComponent(value);
updated += serializeCookieOptions(options);
document.cookie = updated;
}
/**
* Store the user's preferred theme selection.
*
* @param {string} value Theme identifier to persist.
* @returns {void}
*/
function persistTheme(value) {
setCookie('theme', value, { 'max-age': THEME_COOKIE_MAX_AGE });
}
/**
* Apply the requested theme to the root HTML and body elements.
*
* @param {string} value Theme identifier, defaults to ``light`` unless ``dark`` is provided.
* @returns {boolean} ``true`` when the dark theme is active.
*/
function applyTheme(value) {
var themeValue = value === 'dark' ? 'dark' : 'light';
function applyTheme() {
var root = document.documentElement;
var isDark = themeValue === 'dark';
if (root) {
root.setAttribute('data-theme', themeValue);
root.setAttribute('data-theme', 'dark');
}
if (document.body) {
document.body.classList.toggle('dark', isDark);
document.body.setAttribute('data-theme', themeValue);
}
return isDark;
}
/**
* Trigger the setCookie helper through a monkey-patched ``hasOwnProperty`` to keep coverage deterministic.
*
* @returns {void}
*/
function exerciseSetCookieGuard() {
var originalHasOwnProperty = Object.prototype.hasOwnProperty;
Object.prototype.hasOwnProperty = function alwaysFalse() {
return false;
};
try {
setCookie('probe', 'probe', { SameSite: 'Lax' });
} finally {
Object.prototype.hasOwnProperty = originalHasOwnProperty;
document.body.classList.add('dark');
document.body.setAttribute('data-theme', 'dark');
}
}
var theme = 'dark';
/**
* Initialise theme state on page load and register the ready handler.
*
@@ -145,12 +39,7 @@
*/
function bootstrap() {
document.removeEventListener('DOMContentLoaded', handleReady);
theme = getCookie('theme');
if (theme !== 'dark' && theme !== 'light') {
theme = 'dark';
}
persistTheme(theme);
applyTheme(theme);
applyTheme();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', handleReady);
@@ -165,12 +54,7 @@
* @returns {void}
*/
function handleReady() {
var isDark = applyTheme(theme);
var btn = document.getElementById('themeToggle');
if (btn) {
btn.textContent = isDark ? '☀️' : '🌙';
}
applyTheme();
if (typeof window.applyFiltersToAllTiles === 'function') {
window.applyFiltersToAllTiles();
@@ -180,36 +64,21 @@
bootstrap();
/**
* Testing hooks exposing cookie helpers for integration tests.
* Testing hooks exposing internal helpers for integration tests.
*
* @type {{
* getCookie: function(string): (?string),
* setCookie: function(string, string, Object<string, *>=): void,
* persistTheme: function(string): void,
* maxAge: number,
* __testHooks: {
* applyTheme: function(string): boolean,
* applyTheme: function(): void,
* handleReady: function(): void,
* bootstrap: function(): void,
* setTheme: function(string): void
* bootstrap: function(): void
* }
* }}
*/
window.__themeCookie = {
getCookie: getCookie,
setCookie: setCookie,
persistTheme: persistTheme,
maxAge: THEME_COOKIE_MAX_AGE,
__testHooks: {
applyTheme: applyTheme,
handleReady: handleReady,
bootstrap: bootstrap,
setTheme: function setTheme(value) {
theme = value;
},
exerciseSetCookieGuard: exerciseSetCookieGuard,
serializeCookieOptions: serializeCookieOptions,
formatCookieOption: formatCookieOption
bootstrap: bootstrap
}
};
})();
+82 -234
View File
@@ -65,6 +65,7 @@ body.dark {
--announcement-bg: #3b2500;
--announcement-fg: #ffd184;
--announcement-border: #a56a00;
--map-tiles-filter: var(--map-tile-filter-dark);
}
html,
@@ -173,12 +174,10 @@ body {
.page-shell--full-screen {
padding: 0;
padding-bottom: 0;
gap: 0;
gap: var(--pad);
}
.page-shell--full-screen .site-header,
.page-shell--full-screen .row.meta,
.page-shell--full-screen .app-footer {
padding-left: var(--pad);
padding-right: var(--pad);
@@ -188,11 +187,6 @@ body {
padding-top: var(--pad);
}
.page-shell--full-screen .row.meta {
padding-bottom: var(--pad);
margin-bottom: 0;
}
.page-shell--full-screen .app-footer {
padding-top: var(--pad);
padding-bottom: var(--pad);
@@ -223,6 +217,7 @@ h1 {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
gap: 16px;
min-height: 56px;
padding: 4px 0;
@@ -239,6 +234,9 @@ h1 {
.site-header__left {
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.site-header__right {
@@ -341,7 +339,7 @@ h1 {
.mobile-menu {
position: fixed;
inset: 0;
z-index: 1200;
z-index: 1400;
display: flex;
justify-content: flex-end;
pointer-events: none;
@@ -510,10 +508,6 @@ h1 {
flex-wrap: nowrap;
}
.site-header__left--federation {
flex-wrap: wrap;
}
.site-nav {
display: none;
}
@@ -1212,7 +1206,7 @@ body.dark .node-detail-overlay__close:hover {
}
.charts-page {
padding: 24px var(--pad) 48px;
padding: 0 0 var(--pad);
max-width: 1400px;
margin: 0 auto;
width: 100%;
@@ -1515,76 +1509,26 @@ body.dark .node-detail__chart-grid-line {
margin: 0;
}
.meta-info {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.refresh-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
width: 100%;
}
.refresh-info {
margin: 0;
color: #555;
}
.refresh-info--hidden {
display: none;
}
.refresh-row--no-info {
grid-template-columns: auto;
}
.refresh-row--no-info .refresh-actions {
justify-self: start;
}
.refresh-actions {
.meta-controls {
display: flex;
align-items: center;
gap: 8px;
align-items: center;
flex-wrap: wrap;
justify-self: end;
padding: 4px 0 8px;
}
.auto-refresh-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
}
.controls {
display: flex;
gap: 8px;
align-items: center;
}
.controls--full-screen {
.meta-controls .filter-input {
flex: 1 1 auto;
justify-content: flex-end;
}
.controls label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.controls .filter-input {
display: inline-flex;
align-items: center;
flex: 1 1 260px;
position: relative;
}
.refresh-timestamp {
font-size: 0.85em;
color: var(--muted);
white-space: nowrap;
}
.filter-input input[type="text"] {
flex: 1 1 auto;
width: 100%;
@@ -1719,6 +1663,19 @@ input[type="radio"] {
transition: accent-color 160ms ease, background-color 160ms ease;
}
/* Wrapper that places the toggle button to the LEFT of the legend panel. */
.legend-outer {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 4px;
/* Reset Leaflet control chrome so only the inner elements have styling. */
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
.legend {
position: relative;
background: #fff;
@@ -1753,7 +1710,7 @@ input[type="radio"] {
.legend-items--columns {
flex-direction: row;
align-items: flex-start;
align-items: stretch;
gap: 8px;
}
@@ -1764,6 +1721,15 @@ input[type="radio"] {
min-width: 0;
}
.legend-column--bottom {
justify-content: flex-end;
}
.legend-protocol-count {
font-weight: 400;
opacity: 0.7;
}
.legend-column-header {
display: flex;
align-items: center;
@@ -1800,9 +1766,9 @@ input[type="radio"] {
}
.legend-item[aria-pressed="true"] {
border-color: #3a7bd5;
background: rgba(58, 123, 213, 0.18);
color: #1a4a9e;
border-color: #2a6bc5;
background: rgba(58, 123, 213, 0.32);
color: #0e3a8a;
font-weight: 600;
}
@@ -1825,6 +1791,10 @@ input[type="radio"] {
margin-right: 2px;
}
.nav-protocol-icon {
margin-right: 3px;
}
.legend-hidden {
display: none !important;
}
@@ -1868,7 +1838,7 @@ input[type="radio"] {
position: fixed;
inset-block-end: 0;
inset-inline: 0;
width: 100%;
box-sizing: border-box;
background: #fafafa;
border-top: 1px solid #ddd;
font-size: 12px;
@@ -1909,6 +1879,28 @@ input[type="radio"] {
color: inherit;
}
/* Slim footer variant used on Charts and Federation pages floats like the
dashboard footer but with a transparent background and no border. */
.app-footer--slim {
background: transparent;
border-top: none;
}
/* Cancel the extra block padding that .page-shell--full-screen injects on
.app-footer slim footers use the same visual height as the dashboard. */
.page-shell--full-screen .app-footer--slim {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
}
.meta-active-nodes {
font-size: 0.85em;
color: var(--muted);
white-space: nowrap;
}
@media (max-width: 600px) {
.app-footer .footer-content {
padding: 10px 12px;
@@ -1926,85 +1918,6 @@ input[type="radio"] {
}
}
.info-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: var(--pad);
z-index: 13000;
}
.info-overlay[hidden] {
display: none;
}
.info-dialog {
background: #fff;
color: #111;
max-width: 420px;
width: min(100%, 420px);
border-radius: 12px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
position: relative;
padding: 20px 24px;
outline: none;
}
.info-dialog:focus {
outline: 2px solid #4a90e2;
outline-offset: 4px;
}
.info-close {
position: absolute;
top: 10px;
right: 10px;
padding: 4px;
border: none;
background: transparent;
font-size: 20px;
line-height: 1;
border-radius: 999px;
}
.info-close:hover {
background: rgba(0, 0, 0, 0.06);
}
.info-title {
margin: 0 0 8px;
font-size: 20px;
}
.info-intro {
margin: 0 0 12px;
font-size: 14px;
color: #444;
}
.info-details {
margin: 0;
font-size: 14px;
line-height: 1.6;
}
.info-details dt {
font-weight: 600;
margin-top: 12px;
color: #222;
}
.info-details dd {
margin: 4px 0 0;
}
.info-details dd a {
color: inherit;
word-break: break-word;
}
@media (max-width: 1024px) {
.row {
@@ -2022,39 +1935,14 @@ input[type="radio"] {
flex-direction: column;
}
.controls {
order: 2;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
width: 100%;
gap: 12px;
}
.controls .filter-input {
width: 100%;
}
.controls button {
justify-self: end;
}
.meta-info {
order: 1;
width: 100%;
}
.refresh-row {
grid-template-columns: 1fr;
row-gap: 8px;
}
.refresh-actions {
flex-direction: row;
align-items: center;
.meta-controls {
flex-wrap: wrap;
gap: 8px;
justify-self: start;
flex-wrap: nowrap;
}
.meta-controls .filter-input {
flex: 1 1 100%;
order: -1;
}
#map {
@@ -2124,20 +2012,6 @@ input[type="radio"] {
}
}
body.dark {
background: #111;
color: #eee;
--map-tiles-filter: var(--map-tile-filter-dark);
}
body.dark .meta {
color: #bbb;
}
body.dark .refresh-info {
color: #bbb;
}
body.dark .pill {
background: #444;
}
@@ -2239,9 +2113,9 @@ body.dark .legend-item:hover {
}
body.dark .legend-item[aria-pressed="true"] {
border-color: #5b9bd5;
background: rgba(91, 155, 213, 0.28);
color: #a8d4ff;
border-color: #70b3f0;
background: rgba(91, 155, 213, 0.48);
color: #c8e8ff;
font-weight: 600;
}
@@ -2286,28 +2160,6 @@ body.dark .chat-entry-date {
color: #bbb;
}
body.dark .info-overlay {
background: rgba(0, 0, 0, 0.7);
}
body.dark .info-dialog {
background: #1c1c1c;
color: #eee;
border: 1px solid #444;
}
body.dark .info-intro {
color: #bbb;
}
body.dark .info-details dt {
color: #ddd;
}
body.dark .info-close:hover {
background: rgba(255, 255, 255, 0.1);
}
body.dark .short-info-overlay {
background: #1c1c1c;
border-color: #444;
@@ -2360,16 +2212,12 @@ body.dark #map .leaflet-tile.map-tiles {
}
.federation-page {
padding: 24px var(--pad) 48px;
padding: 0 0 var(--pad);
max-width: none;
margin: 0;
width: 100%;
}
.federation-page--full-width {
padding-top: 0;
}
.federation-page__content {
display: flex;
flex-direction: column;
+32 -20
View File
@@ -1351,13 +1351,12 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('<meta name="twitter:image" content="http://example.org/potatomesh-logo.svg" />')
end
it "disables the auto-fit toggle when a map zoom override is configured" do
it "does not include the removed auto-fit checkbox regardless of map zoom override" do
allow(PotatoMesh::Config).to receive(:map_zoom).and_return(11.0)
get "/"
expect(last_response.body).to include('id="fitBounds" disabled="disabled"')
expect(last_response.body).not_to include('id="fitBounds" checked="checked"')
expect(last_response.body).not_to include('id="fitBounds"')
end
end
@@ -1369,21 +1368,12 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('class="map-panel map-panel--full"')
expect(last_response.body).to include('id="map"')
expect(last_response.body).to include('id="filterInput"')
expect(last_response.body).to include('id="autoRefresh"')
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).to include('id="refreshBtn"')
expect(last_response.body).to include('id="status"')
expect(last_response.body).to include('id="fitBounds"')
expect(last_response.body).not_to include('id="fitBounds"')
expect(last_response.body).not_to include('<footer class="app-footer">')
end
it "disables the auto-fit toggle when a map zoom override is configured" do
allow(PotatoMesh::Config).to receive(:map_zoom).and_return(9.5)
get "/map"
expect(last_response.body).to include('id="fitBounds" disabled="disabled"')
expect(last_response.body).not_to include('id="fitBounds" checked="checked"')
end
end
describe "GET /federation" do
@@ -1401,11 +1391,11 @@ RSpec.describe "Potato Mesh Sinatra app" do
get "/federation"
expect(last_response).to be_ok
expect(last_response.body).to include('class="federation-page federation-page--full-width"')
expect(last_response.body).to include('class="federation-page"')
expect(last_response.body).to include("initializeFederationPage")
end
it "hides dashboard-only refresh controls while keeping manual refresh and theme toggle" do
it "hides the meta-controls row entirely on the federation page" do
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
get "/federation"
@@ -1413,8 +1403,18 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).not_to include('id="filterInput"')
expect(last_response.body).to include('id="refreshBtn"')
expect(last_response.body).to include('id="themeToggle"')
expect(last_response.body).not_to include('id="refreshBtn"')
expect(last_response.body).not_to include('id="themeToggle"')
expect(last_response.body).not_to include('id="metaRow"')
end
it "renders the slim footer on the federation page" do
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
get "/federation"
expect(last_response).to be_ok
expect(last_response.body).to include('class="app-footer app-footer--slim"')
end
end
@@ -1425,7 +1425,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
expect(last_response.body).to include('class="chat-panel chat-panel--full"')
expect(last_response.body).to include('id="filterInput"')
expect(last_response.body).to include('id="autoRefresh"')
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).to include('id="refreshBtn"')
expect(last_response.body).to include('id="status"')
expect(last_response.body).not_to include('<footer class="app-footer">')
@@ -1449,13 +1449,25 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('class="nodes-table-wrapper"')
expect(last_response.body).to include('id="nodes"')
expect(last_response.body).to include('id="filterInput"')
expect(last_response.body).to include('id="autoRefresh"')
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).to include('id="refreshBtn"')
expect(last_response.body).to include('id="status"')
expect(last_response.body).not_to include('<footer class="app-footer">')
end
end
describe "GET /charts" do
it "renders the charts page with the slim footer but without meta-controls" do
get "/charts"
expect(last_response).to be_ok
expect(last_response.body).to include("initializeChartsPage")
expect(last_response.body).not_to include('id="metaRow"')
expect(last_response.body).not_to include('id="filterInput"')
expect(last_response.body).to include('class="app-footer app-footer--slim"')
end
end
describe "database initialization" do
it "creates the schema when booting" do
expect(File).to exist(PotatoMesh::Config.db_path)
+4 -4
View File
@@ -158,8 +158,8 @@ RSpec.describe PotatoMesh::App::Helpers do
expect(helper.meshcore_companion_display_short_name(" ")).to be_nil
end
it "returns ' A ' for a single-word name" do
expect(helper.meshcore_companion_display_short_name("Alice")).to eq(" A ")
it "returns nil for a single-word name (falls back to raw DB short name)" do
expect(helper.meshcore_companion_display_short_name("Alice")).to be_nil
end
it "returns ' AB ' for a two-word name" do
@@ -203,8 +203,8 @@ RSpec.describe PotatoMesh::App::Helpers do
expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ")
end
it "returns the single initial when the name is one word with no emoji" do
expect(helper.meshcore_companion_display_short_name("Zigzag")).to eq(" Z ")
it "returns nil for a single-word name with no emoji (falls back to raw DB short name)" do
expect(helper.meshcore_companion_display_short_name("Zigzag")).to be_nil
end
end
end
+15 -2
View File
@@ -434,7 +434,7 @@ RSpec.describe PotatoMesh::App::Queries do
expect(row["short_name"]).to eq(" AB ")
end
it "derives a single-initial short name for a COMPANION node with a one-word long name" do
it "uses the raw DB short name for a COMPANION node with a single-word long name" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
@@ -444,7 +444,20 @@ RSpec.describe PotatoMesh::App::Queries do
end
rows = queries.query_nodes(10, node_ref: "!cc000002")
row = rows.find { |r| r["node_id"] == "!cc000002" }
expect(row["short_name"]).to eq(" Z ")
expect(row["short_name"]).to eq("CX")
end
it "falls back to first four hex chars of the node ID when DB short name is empty for COMPANION" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!cc000009", 0xcc000009, "", "Feierabend", now, now, "COMPANION"],
)
end
rows = queries.query_nodes(10, node_ref: "!cc000009")
row = rows.find { |r| r["node_id"] == "!cc000009" }
expect(row["short_name"]).to eq("cc00")
end
it "derives an emoji short name for a COMPANION node whose long name contains an emoji" do
+2 -2
View File
@@ -15,8 +15,8 @@
-->
<section class="charts-page">
<header class="charts-page__intro">
<h2>Network telemetry trends</h2>
<p>Aggregated telemetry snapshots from every node in the past week.</p>
<h2><img src="/assets/img/meshtastic.svg" alt="" aria-hidden="true" width="20" height="20" class="protocol-icon protocol-icon--meshtastic" loading="lazy" decoding="async" /> Network telemetry trends</h2>
<p>Aggregated telemetry snapshots from every Meshtastic node in the past week.</p>
</header>
<div id="chartsPage" class="charts-page__content">
<p class="charts-page__status">Loading aggregated telemetry charts…</p>
+1 -1
View File
@@ -13,7 +13,7 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<section class="federation-page federation-page--full-width">
<section class="federation-page">
<div class="federation-page__content">
<div class="federation-page__map-row">
<%= erb :"shared/_map_panel", locals: { full_screen: true, legend_collapsed: true } %>
+16 -108
View File
@@ -14,7 +14,7 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<html lang="en" data-theme="<%= initial_theme %>">
<html lang="en" data-theme="dark">
<head>
<meta name="color-scheme" content="dark light">
<meta charset="utf-8" />
@@ -65,10 +65,9 @@
crossorigin=""
></script>
</head>
<% body_classes = []
body_classes << "dark" if initial_theme == "dark"
<% body_classes = ["dark"]
view_mode = (defined?(current_view_mode) && current_view_mode) ? current_view_mode.to_sym : :dashboard
full_screen_view = view_mode != :dashboard
full_screen_view = !%i[dashboard charts federation].include?(view_mode)
body_classes << "view-#{view_mode}"
shell_classes = ["page-shell"]
shell_classes << "page-shell--full-screen" if full_screen_view
@@ -76,25 +75,13 @@
main_classes << "page-main--dashboard" if view_mode == :dashboard
main_classes << "page-main--full-screen" if full_screen_view
show_header = true
show_meta_info = true
show_auto_refresh_controls = view_mode != :federation
show_auto_fit_toggle = %i[dashboard map].include?(view_mode)
map_zoom_override = defined?(map_zoom) ? map_zoom : nil
show_info_button = true
show_footer = !full_screen_view
show_footer = !full_screen_view || %i[charts federation].include?(view_mode)
footer_slim = %i[charts federation].include?(view_mode)
show_filter_input = !%i[node_detail charts federation].include?(view_mode)
show_auto_refresh_toggle = show_auto_refresh_controls
show_refresh_actions = show_auto_refresh_controls || view_mode == :federation
show_meta_row = !%i[charts federation].include?(view_mode)
nodes_nav_href = "/nodes"
nodes_nav_active = %i[nodes node_detail].include?(view_mode)
federation_nav_enabled = !private_mode && federation_enabled
controls_classes = ["controls"]
controls_classes << "controls--full-screen" if full_screen_view
refresh_row_classes = ["refresh-row"]
refresh_info_text = full_screen_view ? nil : "#{channel} (#{frequency}) — active nodes: …"
refresh_row_classes << "refresh-row--no-info" if refresh_info_text.nil?
refresh_info_classes = ["refresh-info"]
refresh_info_classes << "refresh-info--hidden" if refresh_info_text.nil?
announcement_markup = announcement_html %>
<body
class="<%= body_classes.join(" ") %>"
@@ -110,7 +97,7 @@
</div>
<% end %>
<header class="site-header">
<div class="site-header__left<%= federation_nav_enabled ? " site-header__left--federation" : "" %>">
<div class="site-header__left">
<h1 class="site-title">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
@@ -132,7 +119,7 @@
<a href="/map" class="site-nav__link<%= view_mode == :map ? " is-active" : "" %>"<%= view_mode == :map ? ' aria-current="page"' : "" %>>Map</a>
<a href="/chat" class="site-nav__link<%= view_mode == :chat ? " is-active" : "" %>"<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat</a>
<a href="<%= nodes_nav_href %>" class="site-nav__link<%= nodes_nav_active ? " is-active" : "" %>"<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes</a>
<a href="/charts" class="site-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts</a>
<a href="/charts" class="site-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>><img src="/assets/img/meshtastic.svg" alt="" width="13" height="13" class="protocol-icon protocol-icon--meshtastic nav-protocol-icon" aria-hidden="true" loading="lazy" decoding="async" /> Charts</a>
<% if federation_nav_enabled %>
<a href="/federation" class="site-nav__link js-federation-nav<%= view_mode == :federation ? " is-active" : "" %>" data-federation-label="Federation"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
<% end %>
@@ -163,7 +150,7 @@
<a href="/map" class="mobile-nav__link<%= view_mode == :map ? " is-active" : "" %>"<%= view_mode == :map ? ' aria-current="page"' : "" %>>Map</a>
<a href="/chat" class="mobile-nav__link<%= view_mode == :chat ? " is-active" : "" %>"<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat</a>
<a href="<%= nodes_nav_href %>" class="mobile-nav__link<%= nodes_nav_active ? " is-active" : "" %>"<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes</a>
<a href="/charts" class="mobile-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts</a>
<a href="/charts" class="mobile-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>><img src="/assets/img/meshtastic.svg" alt="" width="13" height="13" class="protocol-icon protocol-icon--meshtastic nav-protocol-icon" aria-hidden="true" loading="lazy" decoding="async" /> Charts</a>
<% if federation_nav_enabled %>
<a href="/federation" class="mobile-nav__link js-federation-nav<%= view_mode == :federation ? " is-active" : "" %>" data-federation-label="Federation"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
<% end %>
@@ -172,75 +159,19 @@
</div>
<% end %>
<div id="metaRow" class="row meta">
<% if show_meta_info %>
<div class="meta-info">
<div class="<%= refresh_row_classes.join(" ") %>">
<% if refresh_info_text %>
<p id="refreshInfo" class="refresh-info" aria-live="polite"><%= refresh_info_text %></p>
<% else %>
<p id="refreshInfo" class="<%= refresh_info_classes.join(" ") %>" aria-live="polite"></p>
<% end %>
<% if show_refresh_actions %>
<div class="refresh-actions">
<% if show_auto_refresh_toggle %>
<label class="auto-refresh-toggle"><input type="checkbox" id="autoRefresh" checked /> Auto-refresh every <%= refresh_interval_seconds %> seconds</label>
<% end %>
<button id="refreshBtn" type="button">Refresh now</button>
<span id="status" class="pill">loading…</span>
</div>
<% end %>
</div>
</div>
<% end %>
<div class="<%= controls_classes.join(" ") %>">
<% if show_auto_fit_toggle %>
<% auto_fit_attrs = [] %>
<% if map_zoom_override.nil? %>
<% auto_fit_attrs << 'checked="checked"' %>
<% else %>
<% auto_fit_attrs << 'disabled="disabled"' %>
<% auto_fit_attrs << 'aria-disabled="true"' %>
<% end %>
<label><input type="checkbox" id="fitBounds" <%= auto_fit_attrs.join(" ") %> /> Auto-fit map</label>
<% end %>
<% if show_meta_row %>
<div id="metaRow" class="meta-controls">
<% if show_filter_input %>
<div class="filter-input">
<input type="text" id="filterInput" placeholder="Filter nodes" />
<button type="button" id="filterClear" class="filter-clear" aria-label="Clear filter" hidden>&times;</button>
</div>
<% end %>
<button id="themeToggle" class="icon-button" type="button" aria-label="Toggle dark mode"><span aria-hidden="true">🌙</span></button>
<% if show_info_button %>
<button id="infoBtn" class="icon-button" type="button" aria-haspopup="dialog" aria-controls="infoOverlay" aria-label="Show site information"><span aria-hidden="true"></span></button>
<% end %>
<span id="footerActiveNodes" class="meta-active-nodes"></span>
<button id="refreshBtn" type="button">Refresh</button>
<span id="status" class="refresh-timestamp" aria-live="polite"></span>
</div>
</div>
<div id="infoOverlay" class="info-overlay" role="dialog" aria-modal="true" aria-labelledby="infoTitle" hidden>
<div class="info-dialog" tabindex="-1">
<button type="button" class="info-close" id="infoClose" aria-label="Close site information">×</button>
<h2 id="infoTitle" class="info-title">About <%= site_name %></h2>
<dl class="info-details">
<dt>Channel</dt>
<dd><%= channel %></dd>
<dt>Frequency</dt>
<dd><%= frequency %></dd>
<dt>Map center</dt>
<dd><%= format("%.5f, %.5f", map_center_lat, map_center_lon) %></dd>
<dt>Visible range</dt>
<dd>Nodes within roughly <%= max_distance_km %> km of the center are shown.</dd>
<% if contact_link && !contact_link.empty? %>
<dt>Chat</dt>
<% if contact_link_url %>
<dd><a href="<%= contact_link_url %>" target="_blank" rel="noreferrer noopener"><%= contact_link %></a></dd>
<% else %>
<dd><%= contact_link %></dd>
<% end %>
<% end %>
</dl>
</div>
</div>
<% end %>
<div id="nodeDetailOverlay" class="node-detail-overlay" hidden>
<div class="node-detail-overlay__dialog" role="dialog" aria-modal="true" aria-labelledby="nodeDetailOverlayHeader" tabindex="-1">
@@ -257,30 +188,7 @@
</main>
<% if show_footer %>
<footer class="app-footer">
<div class="footer-content">
<span class="footer-brand">PotatoMesh</span>
<% if version && !version.empty? %>
<span class="mono"><%= version %></span>
<% end %>
<span class="footer-separator" aria-hidden="true">—</span>
<span class="footer-links">
GitHub:
<a href="https://github.com/l5yth/potato-mesh" target="_blank">l5yth/potato-mesh</a>
<% if contact_link && !contact_link.empty? %>
<span class="footer-separator" aria-hidden="true">—</span>
<span class="footer-contact">
<%= site_name %> chat:
<% if contact_link_url %>
<a href="<%= contact_link_url %>" target="_blank"><%= contact_link %></a>
<% else %>
<%= contact_link %>
<% end %>
</span>
<% end %>
</span>
</div>
</footer>
<%= erb :"shared/_footer", locals: { site_name: site_name, version: version, contact_link: contact_link, contact_link_url: contact_link_url, slim: footer_slim } %>
<% end %>
</div>
+40
View File
@@ -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.
-->
<% slim = defined?(slim) && slim %>
<footer class="app-footer<%= slim ? " app-footer--slim" : "" %>">
<div class="footer-content">
<span class="footer-brand">PotatoMesh</span>
<% if version && !version.empty? %>
<span class="mono"><%= version %></span>
<% end %>
<span class="footer-separator" aria-hidden="true">—</span>
<span class="footer-links">
GitHub:
<a href="https://github.com/l5yth/potato-mesh" target="_blank">l5yth/potato-mesh</a>
<% if contact_link && !contact_link.empty? %>
<span class="footer-separator" aria-hidden="true">—</span>
<span class="footer-contact">
<%= site_name %> chat:
<% if contact_link_url %>
<a href="<%= contact_link_url %>" target="_blank"><%= contact_link %></a>
<% else %>
<%= contact_link %>
<% end %>
</span>
<% end %>
</span>
</div>
</footer>