From b951dbffebbb996784a8088420d27a4d3b407d90 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:26:16 +0200 Subject: [PATCH] web: per protocol active node counts (#735) * web: per protocol active node counts * web: address review comments --- data/instances.sql | 2 + web/lib/potato_mesh/application/database.rb | 11 ++ web/lib/potato_mesh/application/federation.rb | 60 +++++- web/lib/potato_mesh/application/instances.rb | 5 +- .../application/queries/node_queries.rb | 41 ++++- web/lib/potato_mesh/application/routes/api.rb | 8 +- .../js/app/__tests__/dom-environment.js | 8 + .../js/app/__tests__/main-stats.test.js | 68 ++++--- .../app/__tests__/main-update-counts.test.js | 174 +++++++++++++----- .../assets/js/app/__tests__/stats.test.js | 136 ++++++++++---- web/public/assets/js/app/federation-page.js | 19 ++ web/public/assets/js/app/main.js | 91 +++++---- web/public/assets/js/app/stats.js | 46 ++++- web/spec/app_spec.rb | 28 ++- web/spec/federation_spec.rb | 42 +++++ web/spec/instances_spec.rb | 47 +++++ web/views/shared/_instances_table.erb | 2 + 17 files changed, 619 insertions(+), 169 deletions(-) diff --git a/data/instances.sql b/data/instances.sql index a355d24..5e43d9e 100644 --- a/data/instances.sql +++ b/data/instances.sql @@ -27,6 +27,8 @@ CREATE TABLE IF NOT EXISTS instances ( last_update_time INTEGER, is_private BOOLEAN NOT NULL DEFAULT 0, nodes_count INTEGER, + meshcore_nodes_count INTEGER, + meshtastic_nodes_count INTEGER, contact_link TEXT, signature TEXT ); diff --git a/web/lib/potato_mesh/application/database.rb b/web/lib/potato_mesh/application/database.rb index 1793743..ce0a9d5 100644 --- a/web/lib/potato_mesh/application/database.rb +++ b/web/lib/potato_mesh/application/database.rb @@ -209,6 +209,17 @@ module PotatoMesh unless instance_columns.include?("nodes_count") db.execute("ALTER TABLE instances ADD COLUMN nodes_count INTEGER") + instance_columns << "nodes_count" + end + + unless instance_columns.include?("meshcore_nodes_count") + db.execute("ALTER TABLE instances ADD COLUMN meshcore_nodes_count INTEGER") + instance_columns << "meshcore_nodes_count" + end + + unless instance_columns.include?("meshtastic_nodes_count") + db.execute("ALTER TABLE instances ADD COLUMN meshtastic_nodes_count INTEGER") + instance_columns << "meshtastic_nodes_count" end telemetry_tables = diff --git a/web/lib/potato_mesh/application/federation.rb b/web/lib/potato_mesh/application/federation.rb index db76972..051f8db 100644 --- a/web/lib/potato_mesh/application/federation.rb +++ b/web/lib/potato_mesh/application/federation.rb @@ -63,7 +63,11 @@ module PotatoMesh def self_instance_attributes domain = self_instance_domain last_update = latest_node_update_timestamp || Time.now.to_i - nodes_count = active_node_count_since(Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age) + cutoff = Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age + db = open_database(readonly: true) + nodes_count = active_node_count_since(cutoff, db: db) + mc_count = active_node_count_since_for_protocol(cutoff, "meshcore", db: db) + mt_count = active_node_count_since_for_protocol(cutoff, "meshtastic", db: db) { id: app_constant(:SELF_INSTANCE_ID), domain: domain, @@ -78,7 +82,11 @@ module PotatoMesh is_private: private_mode?, contact_link: sanitized_contact_link, nodes_count: nodes_count, + meshcore_nodes_count: mc_count, + meshtastic_nodes_count: mt_count, } + ensure + db&.close end # Count the number of nodes active since the supplied timestamp. @@ -107,6 +115,39 @@ module PotatoMesh handle&.close unless db end + # Count the number of nodes for a specific protocol active since the + # supplied timestamp. + # + # @param cutoff [Integer] unix timestamp in seconds. + # @param protocol [String] protocol name (e.g. "meshcore", "meshtastic"). + # @param db [SQLite3::Database, nil] optional open handle to reuse. + # @return [Integer, nil] node count or nil when unavailable. + def active_node_count_since_for_protocol(cutoff, protocol, db: nil) + return nil unless cutoff && protocol + + handle = db || open_database(readonly: true) + count = + with_busy_retry do + handle.get_first_value( + "SELECT COUNT(*) FROM nodes WHERE last_heard >= ? AND protocol = ?", + cutoff.to_i, + protocol, + ) + end + Integer(count) + rescue SQLite3::Exception, ArgumentError => e + warn_log( + "Failed to count active nodes for protocol", + context: "instances.protocol_nodes_count", + protocol: protocol, + error_class: e.class.name, + error_message: e.message, + ) + nil + ensure + handle&.close unless db + end + def sign_instance_attributes(attributes) payload = canonical_instance_payload(attributes) Base64.strict_encode64( @@ -1097,6 +1138,14 @@ module PotatoMesh ) attributes[:nodes_count] = stats_count if stats_count + # Extract per-protocol 24h counts (informational, not signed). + if stats_payload.is_a?(Hash) + mc_day = stats_payload.dig("meshcore", "day") + mt_day = stats_payload.dig("meshtastic", "day") + attributes[:meshcore_nodes_count] = coerce_integer(mc_day) if mc_day + attributes[:meshtastic_nodes_count] = coerce_integer(mt_day) if mt_day + end + nodes_since_path = "/api/nodes?since=#{recent_cutoff}&limit=1000" nodes_since_window, nodes_since_metadata = fetch_instance_json(attributes[:domain], nodes_since_path) if stats_count.nil? && attributes[:nodes_count].nil? && nodes_since_window.is_a?(Array) @@ -1398,8 +1447,9 @@ module PotatoMesh sql = <<~SQL INSERT INTO instances ( id, domain, pubkey, name, version, channel, frequency, - latitude, longitude, last_update_time, is_private, nodes_count, contact_link, signature - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + latitude, longitude, last_update_time, is_private, nodes_count, + meshcore_nodes_count, meshtastic_nodes_count, contact_link, signature + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET domain=excluded.domain, pubkey=excluded.pubkey, @@ -1412,6 +1462,8 @@ module PotatoMesh last_update_time=excluded.last_update_time, is_private=excluded.is_private, nodes_count=excluded.nodes_count, + meshcore_nodes_count=excluded.meshcore_nodes_count, + meshtastic_nodes_count=excluded.meshtastic_nodes_count, contact_link=excluded.contact_link, signature=excluded.signature SQL @@ -1430,6 +1482,8 @@ module PotatoMesh attributes[:last_update_time], attributes[:is_private] ? 1 : 0, nodes_count, + coerce_integer(attributes[:meshcore_nodes_count]), + coerce_integer(attributes[:meshtastic_nodes_count]), attributes[:contact_link], signature, ] diff --git a/web/lib/potato_mesh/application/instances.rb b/web/lib/potato_mesh/application/instances.rb index f0d4fa8..fb153b2 100644 --- a/web/lib/potato_mesh/application/instances.rb +++ b/web/lib/potato_mesh/application/instances.rb @@ -144,6 +144,8 @@ module PotatoMesh "lastUpdateTime" => last_update_time, "isPrivate" => private_flag, "nodesCount" => coerce_integer(row["nodes_count"]), + "meshcoreNodesCount" => coerce_integer(row["meshcore_nodes_count"]), + "meshtasticNodesCount" => coerce_integer(row["meshtastic_nodes_count"]), "contactLink" => string_or_nil(row["contact_link"]), "signature" => signature, } @@ -175,7 +177,8 @@ module PotatoMesh min_last_update_time = now - PotatoMesh::Config.week_seconds sql = <<~SQL SELECT id, domain, pubkey, name, version, channel, frequency, - latitude, longitude, last_update_time, is_private, nodes_count, contact_link, signature + latitude, longitude, last_update_time, is_private, nodes_count, + meshcore_nodes_count, meshtastic_nodes_count, contact_link, signature FROM instances WHERE domain IS NOT NULL AND TRIM(domain) != '' AND pubkey IS NOT NULL AND TRIM(pubkey) != '' diff --git a/web/lib/potato_mesh/application/queries/node_queries.rb b/web/lib/potato_mesh/application/queries/node_queries.rb index 10669b1..1357bbd 100644 --- a/web/lib/potato_mesh/application/queries/node_queries.rb +++ b/web/lib/potato_mesh/application/queries/node_queries.rb @@ -238,7 +238,8 @@ module PotatoMesh # # @param now [Integer] reference unix timestamp in seconds. # @param db [SQLite3::Database, nil] optional open database handle to reuse. - # @return [Hash{String => Integer}] counts keyed by hour/day/week/month. + # @return [Hash{String => Object}] counts keyed by hour/day/week/month plus + # per-protocol breakdowns under "meshcore" and "meshtastic" sub-hashes. def query_active_node_stats(now: Time.now.to_i, db: nil) handle = db || open_database(readonly: true) handle.results_as_hash = true @@ -247,22 +248,48 @@ module PotatoMesh day_cutoff = reference_now - 86_400 week_cutoff = reference_now - PotatoMesh::Config.week_seconds month_cutoff = reference_now - (30 * 24 * 60 * 60) - private_filter = private_mode? ? " AND (role IS NULL OR role <> 'CLIENT_HIDDEN')" : "" + pf = private_mode? ? " AND (role IS NULL OR role <> 'CLIENT_HIDDEN')" : "" + proto = " AND protocol = ?" sql = <<~SQL SELECT - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{private_filter}) AS hour_count, - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{private_filter}) AS day_count, - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{private_filter}) AS week_count, - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{private_filter}) AS month_count + (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}) AS hour_count, + (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}) AS day_count, + (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}) AS week_count, + (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}) AS month_count, + (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mc_hour, + (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mc_day, + (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mc_week, + (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mc_month, + (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mt_hour, + (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mt_day, + (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mt_week, + (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mt_month SQL + cutoffs = [hour_cutoff, day_cutoff, week_cutoff, month_cutoff] + # Total counts bind only cutoffs; per-protocol counts bind cutoff + protocol string. + params = cutoffs + + cutoffs.flat_map { |c| [c, "meshcore"] } + + cutoffs.flat_map { |c| [c, "meshtastic"] } row = with_busy_retry do - handle.get_first_row(sql, [hour_cutoff, day_cutoff, week_cutoff, month_cutoff]) + handle.get_first_row(sql, params) end || {} { "hour" => row["hour_count"].to_i, "day" => row["day_count"].to_i, "week" => row["week_count"].to_i, "month" => row["month_count"].to_i, + "meshcore" => { + "hour" => row["mc_hour"].to_i, + "day" => row["mc_day"].to_i, + "week" => row["mc_week"].to_i, + "month" => row["mc_month"].to_i, + }, + "meshtastic" => { + "hour" => row["mt_hour"].to_i, + "day" => row["mt_day"].to_i, + "week" => row["mt_week"].to_i, + "month" => row["mt_month"].to_i, + }, } ensure handle&.close unless db diff --git a/web/lib/potato_mesh/application/routes/api.rb b/web/lib/potato_mesh/application/routes/api.rb index a713951..d9431ce 100644 --- a/web/lib/potato_mesh/application/routes/api.rb +++ b/web/lib/potato_mesh/application/routes/api.rb @@ -69,8 +69,14 @@ module PotatoMesh app.get "/api/stats" do content_type :json + stats = query_active_node_stats { - active_nodes: query_active_node_stats, + active_nodes: { + "hour" => stats["hour"], "day" => stats["day"], + "week" => stats["week"], "month" => stats["month"], + }, + meshcore: stats["meshcore"], + meshtastic: stats["meshtastic"], sampled: false, }.to_json end diff --git a/web/public/assets/js/app/__tests__/dom-environment.js b/web/public/assets/js/app/__tests__/dom-environment.js index 63f2c50..993f7a8 100644 --- a/web/public/assets/js/app/__tests__/dom-environment.js +++ b/web/public/assets/js/app/__tests__/dom-environment.js @@ -326,6 +326,14 @@ export function createDomEnvironment(options = {}) { querySelector() { return null; }, + querySelectorAll(selector) { + // Delegate to body when available — MockElement.querySelectorAll supports + // class selectors which covers the majority of test-time lookups. + if (document.body && typeof document.body.querySelectorAll === 'function') { + return document.body.querySelectorAll(selector); + } + return []; + }, createElement(tagName) { return new MockElement(tagName, registry); }, diff --git a/web/public/assets/js/app/__tests__/main-stats.test.js b/web/public/assets/js/app/__tests__/main-stats.test.js index fc35967..5a20759 100644 --- a/web/public/assets/js/app/__tests__/main-stats.test.js +++ b/web/public/assets/js/app/__tests__/main-stats.test.js @@ -26,24 +26,24 @@ import { const NOW = 1_700_000_000; -test('computeLocalActiveNodeStats calculates local hour/day/week/month counts', () => { +test('computeLocalActiveNodeStats calculates local hour/day/week/month counts with per-protocol data', () => { const nodes = [ - { last_heard: NOW - 60 }, - { last_heard: NOW - 4_000 }, - { last_heard: NOW - 90_000 }, - { last_heard: NOW - (8 * 86_400) }, - { last_heard: NOW - (20 * 86_400) }, + { last_heard: NOW - 60, protocol: 'meshtastic' }, + { last_heard: NOW - 4_000, protocol: 'meshcore' }, + { last_heard: NOW - 90_000, protocol: 'meshtastic' }, + { last_heard: NOW - (8 * 86_400), protocol: 'meshcore' }, + { last_heard: NOW - (20 * 86_400), protocol: 'meshtastic' }, ]; const stats = computeLocalActiveNodeStats(nodes, NOW); - assert.deepEqual(stats, { - hour: 1, - day: 2, - week: 3, - month: 5, - sampled: true, - }); + assert.equal(stats.hour, 1); + assert.equal(stats.day, 2); + assert.equal(stats.week, 3); + assert.equal(stats.month, 5); + assert.equal(stats.sampled, true); + assert.deepEqual(stats.meshcore, { hour: 0, day: 1, week: 1, month: 2 }); + assert.deepEqual(stats.meshtastic, { hour: 1, day: 1, week: 2, month: 3 }); }); test('normaliseActiveNodeStatsPayload validates and normalizes API payload', () => { @@ -57,17 +57,27 @@ test('normaliseActiveNodeStatsPayload validates and normalizes API payload', () sampled: false, }; - assert.deepEqual(normaliseActiveNodeStatsPayload(payload), { - hour: 11, - day: 22, - week: 33, - month: 44, - sampled: false, - }); + const result = normaliseActiveNodeStatsPayload(payload); + assert.equal(result.hour, 11); + assert.equal(result.day, 22); + assert.equal(result.week, 33); + assert.equal(result.month, 44); + assert.equal(result.sampled, false); assert.equal(normaliseActiveNodeStatsPayload({}), null); }); +test('normaliseActiveNodeStatsPayload includes per-protocol buckets when present', () => { + const result = normaliseActiveNodeStatsPayload({ + active_nodes: { hour: 10, day: 20, week: 30, month: 40 }, + meshcore: { hour: 3, day: 8, week: 12, month: 15 }, + meshtastic: { hour: 7, day: 12, week: 18, month: 25 }, + sampled: false, + }); + assert.deepEqual(result.meshcore, { hour: 3, day: 8, week: 12, month: 15 }); + assert.deepEqual(result.meshtastic, { hour: 7, day: 12, week: 18, month: 25 }); +}); + test('normaliseActiveNodeStatsPayload rejects malformed stat values', () => { assert.equal( normaliseActiveNodeStatsPayload({ active_nodes: { hour: 'x', day: 1, week: 1, month: 1 } }), @@ -140,8 +150,8 @@ test('fetchActiveNodeStats reuses cached /api/stats response for repeated calls' test('fetchActiveNodeStats falls back to local counts when stats fetch fails', async () => { const nodes = [ - { last_heard: NOW - 120 }, - { last_heard: NOW - (10 * 86_400) }, + { last_heard: NOW - 120, protocol: 'meshtastic' }, + { last_heard: NOW - (10 * 86_400), protocol: 'meshcore' }, ]; const fetchImpl = async () => { throw new Error('network down'); @@ -149,13 +159,13 @@ test('fetchActiveNodeStats falls back to local counts when stats fetch fails', a const stats = await fetchActiveNodeStats({ nodes, nowSeconds: NOW, fetchImpl }); - assert.deepEqual(stats, { - hour: 1, - day: 1, - week: 1, - month: 2, - sampled: true, - }); + assert.equal(stats.hour, 1); + assert.equal(stats.day, 1); + assert.equal(stats.week, 1); + assert.equal(stats.month, 2); + assert.equal(stats.sampled, true); + assert.ok(stats.meshcore != null, 'fallback should include meshcore'); + assert.ok(stats.meshtastic != null, 'fallback should include meshtastic'); }); test('fetchActiveNodeStats falls back to local counts on non-OK HTTP responses', async () => { diff --git a/web/public/assets/js/app/__tests__/main-update-counts.test.js b/web/public/assets/js/app/__tests__/main-update-counts.test.js index c6d28e7..8791ad7 100644 --- a/web/public/assets/js/app/__tests__/main-update-counts.test.js +++ b/web/public/assets/js/app/__tests__/main-update-counts.test.js @@ -21,6 +21,32 @@ import { setupApp, setupAppWithOptions } from './main-app-test-helpers.js'; const NOW = 1_700_000_000; +// --------------------------------------------------------------------------- +// updateTitleCount +// --------------------------------------------------------------------------- + +test('updateTitleCount does not throw when title and header elements are absent', () => { + const { testUtils, cleanup } = setupApp(); + try { + assert.doesNotThrow(() => { + testUtils.updateTitleCount({ hour: 5, day: 20, week: 42, month: 100, sampled: false }); + }); + } finally { + cleanup(); + } +}); + +test('updateTitleCount handles null and undefined stats gracefully', () => { + const { testUtils, cleanup } = setupApp(); + try { + assert.doesNotThrow(() => testUtils.updateTitleCount(null)); + assert.doesNotThrow(() => testUtils.updateTitleCount(undefined)); + assert.doesNotThrow(() => testUtils.updateTitleCount({})); + } finally { + cleanup(); + } +}); + // --------------------------------------------------------------------------- // updateLegendProtocolCounts // --------------------------------------------------------------------------- @@ -30,10 +56,11 @@ test('updateLegendProtocolCounts returns early when both count elements are null try { // Default state: meshcoreCountEl and meshtasticCountEl are null — should not throw. assert.doesNotThrow(() => { - testUtils.updateLegendProtocolCounts( - [{ last_heard: NOW - 100, protocol: 'meshcore' }], - NOW, - ); + testUtils.updateLegendProtocolCounts({ + week: 10, + meshcore: { hour: 1, day: 2, week: 3, month: 4 }, + meshtastic: { hour: 5, day: 6, week: 7, month: 8 }, + }); }); } finally { cleanup(); @@ -47,13 +74,11 @@ test('updateLegendProtocolCounts sets per-protocol counts when elements are pres 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); + testUtils.updateLegendProtocolCounts({ + week: 3, + meshcore: { hour: 1, day: 1, week: 2, month: 3 }, + meshtastic: { hour: 0, day: 1, week: 1, month: 2 }, + }); assert.equal(mcEl.textContent, ' (2)', 'meshcore count should be 2'); assert.equal(mtEl.textContent, ' (1)', 'meshtastic count should be 1'); @@ -62,21 +87,18 @@ test('updateLegendProtocolCounts sets per-protocol counts when elements are pres } }); -test('updateLegendProtocolCounts bins unknown protocols into the meshtastic column', () => { +test('updateLegendProtocolCounts handles missing per-protocol data gracefully', () => { 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); + // Stats without per-protocol breakdowns (e.g. from an old instance). + testUtils.updateLegendProtocolCounts({ week: 5 }); - assert.equal(mcEl.textContent, ' (1)'); - assert.equal(mtEl.textContent, ' (1)'); + assert.equal(mcEl.textContent, ' (0)'); + assert.equal(mtEl.textContent, ' (0)'); } finally { cleanup(); } @@ -88,10 +110,10 @@ test('updateLegendProtocolCounts works when only meshcoreCountEl is present', () const mcEl = { textContent: '' }; testUtils._setProtocolCountElements(mcEl, null); - testUtils.updateLegendProtocolCounts( - [{ last_heard: NOW - 100, protocol: 'meshcore' }], - NOW, - ); + testUtils.updateLegendProtocolCounts({ + week: 5, + meshcore: { hour: 0, day: 0, week: 1, month: 2 }, + }); assert.equal(mcEl.textContent, ' (1)'); } finally { cleanup(); @@ -104,10 +126,10 @@ test('updateLegendProtocolCounts works when only meshtasticCountEl is present', const mtEl = { textContent: '' }; testUtils._setProtocolCountElements(null, mtEl); - testUtils.updateLegendProtocolCounts( - [{ last_heard: NOW - 100, protocol: 'meshtastic' }], - NOW, - ); + testUtils.updateLegendProtocolCounts({ + week: 5, + meshtastic: { hour: 0, day: 0, week: 1, month: 2 }, + }); assert.equal(mtEl.textContent, ' (1)'); } finally { cleanup(); @@ -122,53 +144,107 @@ 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); + testUtils.updateFooterStats({ day: 1, week: 2, month: 3, sampled: false }); }); } finally { cleanup(); } }); -test('updateFooterStats populates the active-stats element when present', async () => { +test('updateFooterStats populates the active-stats element when present', () => { 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)); + testUtils.updateFooterStats({ day: 10, week: 20, month: 30, sampled: false }); assert.ok( el.textContent.includes('/day'), `expected footerActiveNodes to contain "/day", got: ${el.textContent}`, ); + assert.ok( + el.textContent.includes('10/day'), + `expected footerActiveNodes to contain "10/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'], - }); +// --------------------------------------------------------------------------- +// applyProtocolVisibility +// --------------------------------------------------------------------------- + +test('applyProtocolVisibility hides meshcore column when meshcore week is 0', () => { + const { testUtils, cleanup } = setupApp(); try { - const el = env.document.getElementById('footerActiveNodes'); + const mcCol = { style: { display: '' } }; + const mtCol = { style: { display: '' } }; + testUtils._setProtocolColElements(mcCol, mtCol); - // Fire two sequential updates; only the second should be applied. - testUtils.updateFooterStats([{ last_heard: NOW - 100 }], NOW); - testUtils.updateFooterStats([{ last_heard: NOW - 200 }], NOW); + testUtils.applyProtocolVisibility({ + meshcore: { hour: 0, day: 0, week: 0, month: 0 }, + meshtastic: { hour: 1, day: 5, week: 10, month: 20 }, + }); - await new Promise(resolve => setImmediate(resolve)); + assert.equal(mcCol.style.display, 'none', 'meshcore column should be hidden'); + assert.equal(mtCol.style.display, '', 'meshtastic column should remain visible'); + } finally { + cleanup(); + } +}); - // 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}`, - ); +test('applyProtocolVisibility hides meshtastic column when meshtastic week is 0', () => { + const { testUtils, cleanup } = setupApp(); + try { + const mcCol = { style: { display: '' } }; + const mtCol = { style: { display: '' } }; + testUtils._setProtocolColElements(mcCol, mtCol); + + testUtils.applyProtocolVisibility({ + meshcore: { hour: 1, day: 5, week: 10, month: 20 }, + meshtastic: { hour: 0, day: 0, week: 0, month: 0 }, + }); + + assert.equal(mcCol.style.display, '', 'meshcore column should remain visible'); + assert.equal(mtCol.style.display, 'none', 'meshtastic column should be hidden'); + } finally { + cleanup(); + } +}); + +test('applyProtocolVisibility shows both columns when both protocols have active nodes', () => { + const { testUtils, cleanup } = setupApp(); + try { + const mcCol = { style: { display: 'none' } }; + const mtCol = { style: { display: 'none' } }; + testUtils._setProtocolColElements(mcCol, mtCol); + + testUtils.applyProtocolVisibility({ + meshcore: { hour: 1, day: 2, week: 5, month: 10 }, + meshtastic: { hour: 2, day: 3, week: 8, month: 15 }, + }); + + assert.equal(mcCol.style.display, '', 'meshcore column should be visible'); + assert.equal(mtCol.style.display, '', 'meshtastic column should be visible'); + } finally { + cleanup(); + } +}); + +test('applyProtocolVisibility handles missing per-protocol data gracefully', () => { + const { testUtils, cleanup } = setupApp(); + try { + const mcCol = { style: { display: '' } }; + const mtCol = { style: { display: '' } }; + testUtils._setProtocolColElements(mcCol, mtCol); + + // No per-protocol data at all — treat as 0. + testUtils.applyProtocolVisibility({ week: 5 }); + + assert.equal(mcCol.style.display, 'none'); + assert.equal(mtCol.style.display, 'none'); } finally { cleanup(); } diff --git a/web/public/assets/js/app/__tests__/stats.test.js b/web/public/assets/js/app/__tests__/stats.test.js index 806c363..e1b6c68 100644 --- a/web/public/assets/js/app/__tests__/stats.test.js +++ b/web/public/assets/js/app/__tests__/stats.test.js @@ -32,39 +32,41 @@ const NOW = 1_700_000_000; test('computeLocalActiveNodeStats counts nodes within each window', () => { const nodes = [ - { last_heard: NOW - 60 }, // within hour, day, week, month - { last_heard: NOW - 4_000 }, // within day, week, month - { last_heard: NOW - 90_000 }, // within week, month - { last_heard: NOW - (8 * 86_400) }, // within month only - { last_heard: NOW - (20 * 86_400) }, // within month only + { last_heard: NOW - 60, protocol: 'meshtastic' }, // within hour, day, week, month + { last_heard: NOW - 4_000, protocol: 'meshcore' }, // within day, week, month + { last_heard: NOW - 90_000, protocol: 'meshtastic' }, // within week, month + { last_heard: NOW - (8 * 86_400), protocol: 'meshcore' }, // within month only + { last_heard: NOW - (20 * 86_400), protocol: 'meshtastic' }, // within month only ]; - assert.deepEqual(computeLocalActiveNodeStats(nodes, NOW), { - hour: 1, - day: 2, - week: 3, - month: 5, - sampled: true, - }); + const result = computeLocalActiveNodeStats(nodes, NOW); + assert.equal(result.hour, 1); + assert.equal(result.day, 2); + assert.equal(result.week, 3); + assert.equal(result.month, 5); + assert.equal(result.sampled, true); + assert.deepEqual(result.meshcore, { hour: 0, day: 1, week: 1, month: 2 }); + assert.deepEqual(result.meshtastic, { hour: 1, day: 1, week: 2, month: 3 }); }); test('computeLocalActiveNodeStats returns zero counts for empty node array', () => { - assert.deepEqual(computeLocalActiveNodeStats([], NOW), { - hour: 0, - day: 0, - week: 0, - month: 0, - sampled: true, - }); + const result = computeLocalActiveNodeStats([], NOW); + assert.equal(result.hour, 0); + assert.equal(result.day, 0); + assert.equal(result.week, 0); + assert.equal(result.month, 0); + assert.equal(result.sampled, true); + assert.deepEqual(result.meshcore, { hour: 0, day: 0, week: 0, month: 0 }); + assert.deepEqual(result.meshtastic, { hour: 0, day: 0, week: 0, month: 0 }); }); test('computeLocalActiveNodeStats handles non-array nodes gracefully', () => { - assert.deepEqual(computeLocalActiveNodeStats(null, NOW), { - hour: 0, day: 0, week: 0, month: 0, sampled: true, - }); - assert.deepEqual(computeLocalActiveNodeStats(undefined, NOW), { - hour: 0, day: 0, week: 0, month: 0, sampled: true, - }); + const result = computeLocalActiveNodeStats(null, NOW); + assert.equal(result.hour, 0); + assert.deepEqual(result.meshcore, { hour: 0, day: 0, week: 0, month: 0 }); + const result2 = computeLocalActiveNodeStats(undefined, NOW); + assert.equal(result2.hour, 0); + assert.deepEqual(result2.meshcore, { hour: 0, day: 0, week: 0, month: 0 }); }); test('computeLocalActiveNodeStats ignores nodes with missing last_heard', () => { @@ -74,9 +76,10 @@ test('computeLocalActiveNodeStats ignores nodes with missing last_heard', () => { last_heard: undefined }, { last_heard: 'not-a-number' }, ]; - assert.deepEqual(computeLocalActiveNodeStats(nodes, NOW), { - hour: 0, day: 0, week: 0, month: 0, sampled: true, - }); + const result = computeLocalActiveNodeStats(nodes, NOW); + assert.equal(result.hour, 0); + assert.deepEqual(result.meshcore, { hour: 0, day: 0, week: 0, month: 0 }); + assert.deepEqual(result.meshtastic, { hour: 0, day: 0, week: 0, month: 0 }); }); test('computeLocalActiveNodeStats uses Date.now() when nowSeconds is non-finite', () => { @@ -84,13 +87,27 @@ test('computeLocalActiveNodeStats uses Date.now() when nowSeconds is non-finite' const result = computeLocalActiveNodeStats([{ last_heard: Date.now() / 1000 - 60 }], NaN); assert.equal(typeof result.hour, 'number'); assert.ok(result.hour >= 0); + assert.ok(result.meshcore != null); }); test('computeLocalActiveNodeStats counts nodes exactly at window boundary', () => { // A node whose last_heard equals exactly now - 3600 is within the hour window (<=). - const nodes = [{ last_heard: NOW - 3600 }]; + const nodes = [{ last_heard: NOW - 3600, protocol: 'meshtastic' }]; const result = computeLocalActiveNodeStats(nodes, NOW); assert.equal(result.hour, 1); + assert.equal(result.meshtastic.hour, 1); + assert.equal(result.meshcore.hour, 0); +}); + +test('computeLocalActiveNodeStats bins unknown protocols into meshtastic bucket', () => { + const nodes = [ + { last_heard: NOW - 100, protocol: 'reticulum' }, + { last_heard: NOW - 200, protocol: 'meshcore' }, + ]; + const result = computeLocalActiveNodeStats(nodes, NOW); + assert.equal(result.hour, 2); + assert.equal(result.meshcore.hour, 1); + assert.equal(result.meshtastic.hour, 1); }); // --------------------------------------------------------------------------- @@ -98,13 +115,47 @@ test('computeLocalActiveNodeStats counts nodes exactly at window boundary', () = // --------------------------------------------------------------------------- test('normaliseActiveNodeStatsPayload validates and normalises API payload', () => { - assert.deepEqual( - normaliseActiveNodeStatsPayload({ - active_nodes: { hour: '11', day: 22, week: 33, month: 44 }, - sampled: false, - }), - { hour: 11, day: 22, week: 33, month: 44, sampled: false } - ); + const result = normaliseActiveNodeStatsPayload({ + active_nodes: { hour: '11', day: 22, week: 33, month: 44 }, + sampled: false, + }); + assert.equal(result.hour, 11); + assert.equal(result.day, 22); + assert.equal(result.week, 33); + assert.equal(result.month, 44); + assert.equal(result.sampled, false); +}); + +test('normaliseActiveNodeStatsPayload includes per-protocol buckets when present', () => { + const result = normaliseActiveNodeStatsPayload({ + active_nodes: { hour: 10, day: 20, week: 30, month: 40 }, + meshcore: { hour: 3, day: 8, week: 12, month: 15 }, + meshtastic: { hour: 7, day: 12, week: 18, month: 25 }, + sampled: false, + }); + assert.deepEqual(result.meshcore, { hour: 3, day: 8, week: 12, month: 15 }); + assert.deepEqual(result.meshtastic, { hour: 7, day: 12, week: 18, month: 25 }); +}); + +test('normaliseActiveNodeStatsPayload omits per-protocol buckets when absent', () => { + const result = normaliseActiveNodeStatsPayload({ + active_nodes: { hour: 1, day: 2, week: 3, month: 4 }, + sampled: false, + }); + assert.equal(result.meshcore, undefined); + assert.equal(result.meshtastic, undefined); +}); + +test('normaliseActiveNodeStatsPayload ignores malformed per-protocol buckets', () => { + const result = normaliseActiveNodeStatsPayload({ + active_nodes: { hour: 1, day: 2, week: 3, month: 4 }, + meshcore: { hour: 'bad', day: 1, week: 1, month: 1 }, + meshtastic: 'not-an-object', + sampled: false, + }); + assert.equal(result.hour, 1); + assert.equal(result.meshcore, undefined); + assert.equal(result.meshtastic, undefined); }); test('normaliseActiveNodeStatsPayload returns null for missing active_nodes', () => { @@ -157,13 +208,22 @@ test('fetchActiveNodeStats returns remote stats when /api/stats succeeds', async }); test('fetchActiveNodeStats falls back to local counts on network error', async () => { - const nodes = [{ last_heard: NOW - 120 }, { last_heard: NOW - (10 * 86_400) }]; + const nodes = [ + { last_heard: NOW - 120, protocol: 'meshtastic' }, + { last_heard: NOW - (10 * 86_400), protocol: 'meshcore' }, + ]; const stats = await fetchActiveNodeStats({ nodes, nowSeconds: NOW, fetchImpl: async () => { throw new Error('network down'); }, }); - assert.deepEqual(stats, { hour: 1, day: 1, week: 1, month: 2, sampled: true }); + assert.equal(stats.hour, 1); + assert.equal(stats.day, 1); + assert.equal(stats.week, 1); + assert.equal(stats.month, 2); + assert.equal(stats.sampled, true); + assert.ok(stats.meshcore != null, 'fallback should include meshcore'); + assert.ok(stats.meshtastic != null, 'fallback should include meshtastic'); }); test('fetchActiveNodeStats falls back to local counts on non-OK status', async () => { diff --git a/web/public/assets/js/app/federation-page.js b/web/public/assets/js/app/federation-page.js index 3b3c91c..ad41323 100644 --- a/web/public/assets/js/app/federation-page.js +++ b/web/public/assets/js/app/federation-page.js @@ -23,6 +23,7 @@ import { import { resolveLegendVisibility } from './map-legend-visibility.js'; import { mergeConfig } from './settings.js'; import { roleColors } from './role-helpers.js'; +import { meshcoreIconHtml, meshtasticIconHtml } from './protocol-helpers.js'; /** * Escape HTML special characters to prevent XSS. @@ -393,6 +394,18 @@ export async function initializeFederationPage(options = {}) { hasValue: hasNumberValue, defaultDirection: 'desc' }, + meshcoreNodesCount: { + getValue: inst => toFiniteNumber(inst.meshcoreNodesCount), + compare: compareNumber, + hasValue: hasNumberValue, + defaultDirection: 'desc' + }, + meshtasticNodesCount: { + getValue: inst => toFiniteNumber(inst.meshtasticNodesCount), + compare: compareNumber, + hasValue: hasNumberValue, + defaultDirection: 'desc' + }, latitude: { getValue: inst => toFiniteNumber(inst.latitude), compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' }, longitude: { getValue: inst => toFiniteNumber(inst.longitude), compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' }, lastUpdateTime: { @@ -478,6 +491,10 @@ export async function initializeFederationPage(options = {}) { const contactHtml = renderContactHtml(instance.contactLink); const nodesCountValue = toFiniteNumber(instance.nodesCount ?? instance.nodes_count); const nodesCountText = nodesCountValue == null ? '—' : escapeHtml(String(nodesCountValue)); + const mcNodesVal = toFiniteNumber(instance.meshcoreNodesCount); + const mcNodesText = mcNodesVal == null ? '—' : `${meshcoreIconHtml()} ${escapeHtml(String(mcNodesVal))}`; + const mtNodesVal = toFiniteNumber(instance.meshtasticNodesCount); + const mtNodesText = mtNodesVal == null ? '—' : `${meshtasticIconHtml()} ${escapeHtml(String(mtNodesVal))}`; tr.innerHTML = `