From 083de6418fea287bb96941fcb320adc3789403bc Mon Sep 17 00:00:00 2001
From: l5y <220195275+l5yth@users.noreply.github.com>
Date: Wed, 8 Apr 2026 14:36:43 +0200
Subject: [PATCH] web: fix federation for multi protocol (#722)
* web: fix federation for multi protocol
* web: fix short name emojis
* web: address review comments
* ci: fix the codeql gap
* ci: fix the codeql gap
* ci: fix the codeql gap
* ci: remove swift
---
.github/workflows/codeql.yml | 2 +-
web/lib/potato_mesh/application/federation.rb | 5 ++-
.../js/app/__tests__/main-protocol.test.js | 43 +++++++++++++++++++
web/public/assets/js/app/main.js | 22 +++++-----
web/public/assets/styles/base.css | 9 ++++
web/spec/app_spec.rb | 15 +++++++
web/views/layouts/app.erb | 8 ++--
7 files changed, 88 insertions(+), 16 deletions(-)
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 45d54e7..16f383c 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -23,7 +23,7 @@ on:
jobs:
analyze:
name: Analyze (${{ matrix.language }})
- runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
+ runs-on: ubuntu-latest
permissions:
security-events: write
packages: read
diff --git a/web/lib/potato_mesh/application/federation.rb b/web/lib/potato_mesh/application/federation.rb
index 90f06c1..db76972 100644
--- a/web/lib/potato_mesh/application/federation.rb
+++ b/web/lib/potato_mesh/application/federation.rb
@@ -297,9 +297,12 @@ module PotatoMesh
def shutdown_federation_background_work!(timeout: nil)
request_federation_shutdown!
timeout_value = timeout || PotatoMesh::Config.federation_shutdown_timeout_seconds
+ # Drain the worker pool first so federation threads blocked in
+ # wait_for_federation_tasks unblock promptly instead of waiting
+ # for each task's individual timeout to expire.
+ shutdown_federation_worker_pool!
stop_federation_thread!(:initial_federation_thread, timeout: timeout_value)
stop_federation_thread!(:federation_thread, timeout: timeout_value)
- shutdown_federation_worker_pool!
clear_federation_crawl_state!
end
diff --git a/web/public/assets/js/app/__tests__/main-protocol.test.js b/web/public/assets/js/app/__tests__/main-protocol.test.js
index d0c3c8a..0b2fb4c 100644
--- a/web/public/assets/js/app/__tests__/main-protocol.test.js
+++ b/web/public/assets/js/app/__tests__/main-protocol.test.js
@@ -346,3 +346,46 @@ test('createMessageChatEntry: meshtastic message with @[Name] is NOT resolved as
assert.ok(shortNameCount <= 1, 'only the sender badge should be present, no mention badge');
});
});
+
+// --- renderShortHtml badge padding ---
+
+test('renderShortHtml leaves 4-char ASCII names unpadded', () => {
+ withApp(() => {
+ const html = globalThis.PotatoMesh.renderShortHtml('0ac7', 'CLIENT');
+ assert.ok(!html.includes(' 0ac7'), 'should not add leading space');
+ assert.ok(!html.includes('0ac7 '), 'should not add trailing space');
+ });
+});
+
+test('renderShortHtml adds single space padding for short emoji names', () => {
+ withApp(() => {
+ const html = globalThis.PotatoMesh.renderShortHtml('\u26A1', 'CLIENT');
+ // Should produce " ⚡ " — one leading, one trailing space (as )
+ assert.ok(html.includes(' \u26A1 '), 'emoji should have one space on each side');
+ // Should NOT have double leading spaces
+ assert.ok(!html.includes(' \u26A1'), 'should not double-pad emoji');
+ });
+});
+
+test('renderShortHtml adds single space padding for surrogate pair emoji', () => {
+ withApp(() => {
+ const html = globalThis.PotatoMesh.renderShortHtml('\uD83D\uDE43', 'CLIENT');
+ // 🙃 is a surrogate pair (length 2 in JS) but 1 grapheme
+ assert.ok(html.includes(' \uD83D\uDE43 '), 'surrogate emoji should have one space on each side');
+ });
+});
+
+test('renderShortHtml adds single space padding for ZWJ emoji sequence', () => {
+ withApp(() => {
+ const zwj = '\u{1F3C3}\u{200D}\u{2642}\u{FE0F}'; // 🏃♂️ — length 5, 1 grapheme
+ const html = globalThis.PotatoMesh.renderShortHtml(zwj, 'CLIENT');
+ assert.ok(html.includes(` ${zwj} `), 'ZWJ emoji should have one space on each side');
+ });
+});
+
+test('renderShortHtml adds single space padding for plain 2-char name', () => {
+ withApp(() => {
+ const html = globalThis.PotatoMesh.renderShortHtml('ab', 'CLIENT');
+ assert.ok(html.includes(' ab '), '2-char name should have one space on each side');
+ });
+});
diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js
index 1d6096c..f70b664 100644
--- a/web/public/assets/js/app/main.js
+++ b/web/public/assets/js/app/main.js
@@ -1853,22 +1853,22 @@ export function initializeApp(config) {
infoAttr = attrParts.join('');
}
if (!short) {
- return `? `;
+ return ` ? `;
}
- // Centre the label within a 4-column badge. padStart alone only adds
- // leading spaces, producing " C" for a 1-char name with no trailing
- // space. Instead distribute padding evenly: 1-char → " C ", 2-char →
- // " AB ", 3-char → " ABC", 4-char → unchanged. Names already at 4+
- // chars are left as-is (meshtastic always stores exactly 4; the Ruby
- // COMPANION override also produces exactly 4).
+ // Pad the label for the badge. For plain-ASCII names that are already
+ // 4 characters (meshtastic always stores exactly 4) no padding is added.
+ // Shorter names or names containing emoji/non-ASCII get a single space
+ // on each side — grapheme width varies too much for character-count
+ // centering to work reliably.
const raw = String(short);
+ const graphemeCount = typeof Intl !== 'undefined' && Intl.Segmenter
+ ? [...new Intl.Segmenter().segment(raw)].length
+ : raw.length;
let centred;
- if (raw.length >= 4) {
+ if (graphemeCount >= 4) {
centred = raw;
} else {
- const leading = Math.ceil((4 - raw.length) / 2);
- const trailing = 4 - raw.length - leading;
- centred = ' '.repeat(leading) + raw + ' '.repeat(trailing);
+ centred = ` ${raw} `;
}
const padded = escapeHtml(centred).replace(/ /g, ' ');
const protocol = nodeData?.protocol ?? null;
diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css
index c8a6eb3..9751a00 100644
--- a/web/public/assets/styles/base.css
+++ b/web/public/assets/styles/base.css
@@ -274,6 +274,15 @@ h1 {
min-width: 0;
}
+.site-title__link {
+ display: inline-flex;
+ align-items: center;
+ gap: inherit;
+ color: inherit;
+ text-decoration: none;
+ min-width: 0;
+}
+
.site-title-text {
min-width: 0;
max-width: 100%;
diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb
index 90c014e..1203fbe 100644
--- a/web/spec/app_spec.rb
+++ b/web/spec/app_spec.rb
@@ -1311,6 +1311,13 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('class="footer-content"')
end
+ it "renders the site title as a link to the dashboard" do
+ get "/"
+
+ expect(last_response.body).to include('class="site-title__link"')
+ expect(last_response.body).to match(%r{})
+ end
+
it "renders the federation instance selector when federation is enabled" do
get "/"
@@ -6980,6 +6987,14 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include(node["node_id"])
end
+ it "does not render the meta row on the node detail page" do
+ node = nodes_fixture.first
+ get "/nodes/#{node["node_id"]}"
+ expect(last_response).to be_ok
+ expect(last_response.body).not_to include('id="metaRow"')
+ expect(last_response.body).not_to include('id="refreshBtn"')
+ end
+
it "returns 404 when the node cannot be located" do
get "/nodes/!deadbeef"
expect(last_response.status).to eq(404)
diff --git a/web/views/layouts/app.erb b/web/views/layouts/app.erb
index d714485..2afd156 100644
--- a/web/views/layouts/app.erb
+++ b/web/views/layouts/app.erb
@@ -78,7 +78,7 @@
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_meta_row = !%i[charts federation].include?(view_mode)
+ show_meta_row = !%i[node_detail 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
@@ -99,8 +99,10 @@