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 = ` ${nameHtml} @@ -487,6 +504,8 @@ export async function initializeFederationPage(options = {}) { ${renderContactHtml(instance.channel) || ''} ${escapeHtml(instance.frequency || '')} ${nodesCountText} + ${mcNodesText} + ${mtNodesText} ${fmtCoords(instance.latitude)} ${fmtCoords(instance.longitude)} ${timeAgo(instance.lastUpdateTime, nowSec)} diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index f70b664..6fb9d32 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -1314,6 +1314,8 @@ export function initializeApp(config) { let legendToggleControl = null; let meshcoreCountEl = null; let meshtasticCountEl = null; + let meshcoreColEl = null; + let meshtasticColEl = null; let legendToggleButton = null; let legendVisible = true; @@ -1588,6 +1590,7 @@ export function initializeApp(config) { // --- MeshCore column (left, bottom-aligned) --- const meshcoreCol = L.DomUtil.create('div', 'legend-column legend-column--bottom', itemsContainer); + meshcoreColEl = meshcoreCol; const meshcoreColHeader = L.DomUtil.create('div', 'legend-column-header', meshcoreCol); meshcoreColHeader.appendChild(buildMeshcoreIconImg()); const meshcoreColTitle = document.createElement('span'); @@ -1599,6 +1602,7 @@ export function initializeApp(config) { // --- Meshtastic column (right) --- const meshtasticCol = L.DomUtil.create('div', 'legend-column', itemsContainer); + meshtasticColEl = meshtasticCol; const meshtasticColHeader = L.DomUtil.create('div', 'legend-column-header', meshtasticCol); meshtasticColHeader.appendChild(buildMeshtasticIconImg()); const meshtasticColTitle = document.createElement('span'); @@ -4369,11 +4373,20 @@ export function initializeApp(config) { const nowSec = Date.now()/1000; renderTable(sortedNodes, nowSec); renderMap(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); + // Show an immediate local estimate for the title so it doesn't flicker + // to (0) while waiting for the async /api/stats response. + const localStats = computeLocalActiveNodeStats(allNodes, nowSec); + updateTitleCount(localStats); + // Title, legend, footer, and visibility are then corrected by /api/stats + // which provides the authoritative, uncapped counts. + const statsRequestId = ++activeStatsRequestId; + void fetchActiveNodeStats({ nodes: allNodes, nowSeconds: nowSec }).then(stats => { + if (statsRequestId !== activeStatsRequestId) return; + updateTitleCount(stats); + updateLegendProtocolCounts(stats); + updateFooterStats(stats); + applyProtocolVisibility(stats); + }); updateSortIndicators(); // Pass the raw filterQuery (not the normalised form) so the chat log can // highlight matching substrings in their original case. @@ -4522,15 +4535,13 @@ export function initializeApp(config) { /** * Update the page/tab title with the total active-node count for the past 7 days. * - * @param {Array} nodes All node payloads (unfiltered — counts are global). - * @param {number} nowSec Reference timestamp. + * @param {{week: number}} stats Active-node stats from /api/stats. * @returns {void} */ - function updateTitleCount(nodes, nowSec) { - const weekAgoSec = nowSec - 7 * 86_400; - const count = nodes.filter(n => n.last_heard && Number(n.last_heard) >= weekAgoSec).length; + function updateTitleCount(stats) { + const count = stats?.week ?? 0; const text = `${baseTitle} (${count})`; - titleEl.textContent = text; + if (titleEl) titleEl.textContent = text; if (headerTitleTextEl) { headerTitleTextEl.textContent = text; } else if (headerEl) { @@ -4541,38 +4552,46 @@ export function initializeApp(config) { /** * Update legend column headers with per-protocol active node counts (7 days). * - * @param {Array} nodes All node payloads (unfiltered). - * @param {number} nowSec Reference timestamp. + * @param {{meshcore?: {week: number}, meshtastic?: {week: number}}} stats Stats from /api/stats. * @returns {void} */ - function updateLegendProtocolCounts(nodes, nowSec) { + function updateLegendProtocolCounts(stats) { 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})`; + if (meshcoreCountEl) meshcoreCountEl.textContent = ` (${stats?.meshcore?.week ?? 0})`; + if (meshtasticCountEl) meshtasticCountEl.textContent = ` (${stats?.meshtastic?.week ?? 0})`; } /** * Update the footer active-node stats element with day/week/month counts. * - * @param {Array} nodes Node payloads. - * @param {number} nowSec Reference timestamp. + * @param {{day: number, week: number, month: number, sampled: boolean}} stats Stats from /api/stats. * @returns {void} */ - function updateFooterStats(nodes, nowSec) { - if (!footerActiveNodes) { - return; - } - const requestId = ++activeStatsRequestId; - void fetchActiveNodeStats({ nodes, nowSeconds: nowSec }).then(stats => { - if (requestId !== activeStatsRequestId) { - return; - } - footerActiveNodes.textContent = 'Active: ' + formatActiveNodeStatsText({ stats }); + function updateFooterStats(stats) { + if (!footerActiveNodes) return; + footerActiveNodes.textContent = 'Active: ' + formatActiveNodeStatsText({ stats }); + } + + /** + * Hide/show UI elements based on per-protocol activity in the past 7 days. + * + * Hides the Charts nav link when meshtastic has no active nodes, and hides + * legend columns for protocols with zero weekly activity. + * + * @param {{meshcore?: {week: number}, meshtastic?: {week: number}}} stats Stats from /api/stats. + * @returns {void} + */ + function applyProtocolVisibility(stats) { + const meshcoreWeek = stats?.meshcore?.week ?? 0; + const meshtasticWeek = stats?.meshtastic?.week ?? 0; + + // Hide legend columns for protocols with no activity in the past 7 days. + if (meshcoreColEl) meshcoreColEl.style.display = meshcoreWeek === 0 ? 'none' : ''; + if (meshtasticColEl) meshtasticColEl.style.display = meshtasticWeek === 0 ? 'none' : ''; + + // Charts is meshtastic-only; hide the nav link when no meshtastic activity. + document.querySelectorAll('a[href="/charts"]').forEach(el => { + el.style.display = meshtasticWeek === 0 ? 'none' : ''; }); } @@ -4607,12 +4626,18 @@ export function initializeApp(config) { updateTitleCount, updateLegendProtocolCounts, updateFooterStats, + applyProtocolVisibility, restartAutoRefresh, /** Inject mock count span elements for legend protocol count tests. */ _setProtocolCountElements(mc, mt) { meshcoreCountEl = mc; meshtasticCountEl = mt; }, + /** Inject mock column elements for protocol visibility tests. */ + _setProtocolColElements(mc, mt) { + meshcoreColEl = mc; + meshtasticColEl = mt; + }, }, }; } diff --git a/web/public/assets/js/app/stats.js b/web/public/assets/js/app/stats.js index ae007ad..3b06ece 100644 --- a/web/public/assets/js/app/stats.js +++ b/web/public/assets/js/app/stats.js @@ -26,11 +26,12 @@ */ /** - * Compute active-node counts from a local node array. + * Compute active-node counts from a local node array, including per-protocol + * breakdowns for meshcore and meshtastic. * * @param {Array} nodes Node payloads. * @param {number} nowSeconds Reference timestamp (Unix seconds). - * @returns {{hour: number, day: number, week: number, month: number, sampled: boolean}} Local count snapshot. + * @returns {{hour: number, day: number, week: number, month: number, sampled: boolean, meshcore?: Object, meshtastic?: Object}} Local count snapshot. */ export function computeLocalActiveNodeStats(nodes, nowSeconds) { const safeNodes = Array.isArray(nodes) ? nodes : []; @@ -42,20 +43,48 @@ export function computeLocalActiveNodeStats(nodes, nowSeconds) { { key: 'month', secs: 30 * 86_400 } ]; const counts = { sampled: true }; + const meshcore = {}; + const meshtastic = {}; for (const window of windows) { - counts[window.key] = safeNodes.filter(node => { + const active = safeNodes.filter(node => { const lastHeard = Number(node?.last_heard); return Number.isFinite(lastHeard) && referenceNow - lastHeard <= window.secs; - }).length; + }); + counts[window.key] = active.length; + meshcore[window.key] = active.filter(n => n.protocol === 'meshcore').length; + meshtastic[window.key] = active.filter(n => n.protocol !== 'meshcore').length; } + counts.meshcore = meshcore; + counts.meshtastic = meshtastic; return counts; } +/** + * Normalise a per-protocol bucket ({hour, day, week, month}) from the payload. + * + * @param {*} bucket Candidate object. + * @returns {{hour: number, day: number, week: number, month: number}|null} Normalized bucket or null. + */ +function normaliseProtocolBucket(bucket) { + if (!bucket || typeof bucket !== 'object') return null; + const hour = Number(bucket.hour); + const day = Number(bucket.day); + const week = Number(bucket.week); + const month = Number(bucket.month); + if (![hour, day, week, month].every(Number.isFinite)) return null; + return { + hour: Math.max(0, Math.trunc(hour)), + day: Math.max(0, Math.trunc(day)), + week: Math.max(0, Math.trunc(week)), + month: Math.max(0, Math.trunc(month)), + }; +} + /** * Parse and validate the ``/api/stats`` payload. * * @param {*} payload Candidate JSON object from the stats endpoint. - * @returns {{hour: number, day: number, week: number, month: number, sampled: boolean}|null} Normalized stats or null. + * @returns {{hour: number, day: number, week: number, month: number, sampled: boolean, meshcore?: Object, meshtastic?: Object}|null} Normalized stats or null. */ export function normaliseActiveNodeStatsPayload(payload) { const activeNodes = payload && typeof payload === 'object' ? payload.active_nodes : null; @@ -69,13 +98,18 @@ export function normaliseActiveNodeStatsPayload(payload) { if (![hour, day, week, month].every(Number.isFinite)) { return null; } - return { + const result = { hour: Math.max(0, Math.trunc(hour)), day: Math.max(0, Math.trunc(day)), week: Math.max(0, Math.trunc(week)), month: Math.max(0, Math.trunc(month)), sampled: Boolean(payload.sampled) }; + const meshcore = normaliseProtocolBucket(payload.meshcore); + const meshtastic = normaliseProtocolBucket(payload.meshtastic); + if (meshcore) result.meshcore = meshcore; + if (meshtastic) result.meshtastic = meshtastic; + return result; } // Module-level cache state for the remote stats endpoint. diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 1203fbe..1b945a1 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -5909,14 +5909,15 @@ RSpec.describe "Potato Mesh Sinatra app" do end describe "GET /api/stats" do - it "returns exact SQL-backed activity counts without list-endpoint sampling" do + it "returns exact SQL-backed activity counts with per-protocol breakdowns" do clear_database now = reference_time.to_i allow(Time).to receive(:now).and_return(reference_time) with_db do |db| db.transaction - 1005.times do |index| + # 1000 meshtastic nodes heard within the hour (protocol defaults to meshtastic) + 1000.times do |index| heard = now - (index % 1800) node_id = format("!%08x", index + 1) db.execute( @@ -5924,10 +5925,21 @@ RSpec.describe "Potato Mesh Sinatra app" do [node_id, index + 1, "n#{index}", "Node #{index}", "TBEAM", "CLIENT", heard, heard], ) end + # 5 meshcore nodes heard within the hour + 5.times do |index| + heard = now - (index % 1800) + node_id = format("!mc%06x", index + 1) + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, hw_model, role, last_heard, first_heard, protocol) VALUES(?,?,?,?,?,?,?,?,?)", + [node_id, 100_001 + index, "mc#{index}", "MC Node #{index}", "TBEAM", "CLIENT", heard, heard, "meshcore"], + ) + end + # 1 meshtastic node heard 2 days ago (week window only) db.execute( INSERT_NODE_WITH_METADATA_SQL, ["!week0001", 200_001, "week", "Week Node", "TBEAM", "CLIENT", now - (2 * 86_400), now - (2 * 86_400)], ) + # 1 meshtastic node heard 20 days ago (month window only) db.execute( INSERT_NODE_WITH_METADATA_SQL, ["!month001", 200_002, "month", "Month Node", "TBEAM", "CLIENT", now - (20 * 86_400), now - (20 * 86_400)], @@ -5946,6 +5958,18 @@ RSpec.describe "Potato Mesh Sinatra app" do "week" => 1006, "month" => 1007, ) + expect(payload["meshcore"]).to include( + "hour" => 5, + "day" => 5, + "week" => 5, + "month" => 5, + ) + expect(payload["meshtastic"]).to include( + "hour" => 1000, + "day" => 1000, + "week" => 1001, + "month" => 1002, + ) end end diff --git a/web/spec/federation_spec.rb b/web/spec/federation_spec.rb index e855b39..3978dae 100644 --- a/web/spec/federation_spec.rb +++ b/web/spec/federation_spec.rb @@ -626,6 +626,48 @@ RSpec.describe PotatoMesh::App::Federation do expect(row[2]).to eq("sig-2") end end + + it "stores per-protocol node counts for new records" do + with_db do |db| + attrs = base_attributes.merge( + nodes_count: 50, + meshcore_nodes_count: 20, + meshtastic_nodes_count: 30, + ) + federation_helpers.send(:upsert_instance_record, db, attrs, "sig-1") + + row = db.get_first_row( + "SELECT nodes_count, meshcore_nodes_count, meshtastic_nodes_count FROM instances WHERE id = ?", + base_attributes[:id], + ) + expect(row[0]).to eq(50) + expect(row[1]).to eq(20) + expect(row[2]).to eq(30) + end + end + + it "updates per-protocol node counts on conflict" do + with_db do |db| + attrs = base_attributes.merge( + meshcore_nodes_count: 10, + meshtastic_nodes_count: 15, + ) + federation_helpers.send(:upsert_instance_record, db, attrs, "sig-1") + + updated_attrs = base_attributes.merge( + meshcore_nodes_count: 25, + meshtastic_nodes_count: 40, + ) + federation_helpers.send(:upsert_instance_record, db, updated_attrs, "sig-2") + + row = db.get_first_row( + "SELECT meshcore_nodes_count, meshtastic_nodes_count FROM instances WHERE id = ?", + base_attributes[:id], + ) + expect(row[0]).to eq(25) + expect(row[1]).to eq(40) + end + end end describe ".federation_user_agent_header" do diff --git a/web/spec/instances_spec.rb b/web/spec/instances_spec.rb index af100b3..ae3ec79 100644 --- a/web/spec/instances_spec.rb +++ b/web/spec/instances_spec.rb @@ -176,5 +176,52 @@ RSpec.describe PotatoMesh::App::Instances do expect(with_nodes["nodesCount"]).to eq(42) expect(zero_nodes["nodesCount"]).to eq(0) end + + it "includes per-protocol node counts when present and omits when nil" do + fixed_time = Time.utc(2025, 2, 3, 8, 0, 0) + allow(Time).to receive(:now).and_return(fixed_time) + + with_db do |db| + db.execute( + <<~SQL, + INSERT INTO instances (id, domain, pubkey, last_update_time, is_private, nodes_count, meshcore_nodes_count, meshtastic_nodes_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + SQL + [ + "instance-proto-counts", + "proto.mesh.test", + PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM, + fixed_time.to_i, + 0, + 50, + 20, + 30, + ], + ) + db.execute( + <<~SQL, + INSERT INTO instances (id, domain, pubkey, last_update_time, is_private, nodes_count) + VALUES (?, ?, ?, ?, ?, ?) + SQL + [ + "instance-no-proto", + "noproto.mesh.test", + PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM, + fixed_time.to_i, + 0, + 10, + ], + ) + end + + payload = application_class.load_instances_for_api + with_proto = payload.find { |row| row["domain"] == "proto.mesh.test" } + without_proto = payload.find { |row| row["domain"] == "noproto.mesh.test" } + + expect(with_proto["meshcoreNodesCount"]).to eq(20) + expect(with_proto["meshtasticNodesCount"]).to eq(30) + expect(without_proto.key?("meshcoreNodesCount")).to be(false) + expect(without_proto.key?("meshtasticNodesCount")).to be(false) + end end end diff --git a/web/views/shared/_instances_table.erb b/web/views/shared/_instances_table.erb index 716f33f..5394d87 100644 --- a/web/views/shared/_instances_table.erb +++ b/web/views/shared/_instances_table.erb @@ -24,6 +24,8 @@ Channel Frequency Active Nodes (24h) + MeshCore (24h) + Meshtastic (24h) Latitude Longitude Last Update