web: per protocol active node counts (#735)

* web: per protocol active node counts

* web: address review comments
This commit is contained in:
l5y
2026-04-13 18:26:16 +02:00
committed by GitHub
parent 10e6c99196
commit b951dbffeb
17 changed files with 619 additions and 169 deletions
+2
View File
@@ -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
);
@@ -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 =
+57 -3
View File
@@ -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,
]
+4 -1
View File
@@ -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) != ''
@@ -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
@@ -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
@@ -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);
},
@@ -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 () => {
@@ -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();
}
@@ -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 () => {
@@ -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 ? '<em>—</em>' : escapeHtml(String(nodesCountValue));
const mcNodesVal = toFiniteNumber(instance.meshcoreNodesCount);
const mcNodesText = mcNodesVal == null ? '<em>—</em>' : `${meshcoreIconHtml()} ${escapeHtml(String(mcNodesVal))}`;
const mtNodesVal = toFiniteNumber(instance.meshtasticNodesCount);
const mtNodesText = mtNodesVal == null ? '<em>—</em>' : `${meshtasticIconHtml()} ${escapeHtml(String(mtNodesVal))}`;
tr.innerHTML = `
<td class="instances-col instances-col--name">${nameHtml}</td>
@@ -487,6 +504,8 @@ export async function initializeFederationPage(options = {}) {
<td class="instances-col instances-col--channel">${renderContactHtml(instance.channel) || ''}</td>
<td class="instances-col instances-col--frequency">${escapeHtml(instance.frequency || '')}</td>
<td class="instances-col instances-col--nodes mono">${nodesCountText}</td>
<td class="instances-col instances-col--meshcore-nodes mono">${mcNodesText}</td>
<td class="instances-col instances-col--meshtastic-nodes mono">${mtNodesText}</td>
<td class="instances-col instances-col--latitude mono">${fmtCoords(instance.latitude)}</td>
<td class="instances-col instances-col--longitude mono">${fmtCoords(instance.longitude)}</td>
<td class="instances-col instances-col--last-update mono">${timeAgo(instance.lastUpdateTime, nowSec)}</td>
+58 -33
View File
@@ -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<Object>} 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<Object>} 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<Object>} 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;
},
},
};
}
+40 -6
View File
@@ -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<Object>} 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.
+26 -2
View File
@@ -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
+42
View File
@@ -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
+47
View File
@@ -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
+2
View File
@@ -24,6 +24,8 @@
<th class="instances-col instances-col--channel" data-sort-key="channel"><span class="sort-header" role="button" tabindex="0" data-sort-key="channel" data-sort-label="Channel">Channel <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--frequency" data-sort-key="frequency"><span class="sort-header" role="button" tabindex="0" data-sort-key="frequency" data-sort-label="Frequency">Frequency <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--nodes" data-sort-key="nodesCount"><span class="sort-header" role="button" tabindex="0" data-sort-key="nodesCount" data-sort-label="Active Nodes (24h)">Active Nodes (24h) <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--meshcore-nodes" data-sort-key="meshcoreNodesCount"><span class="sort-header" role="button" tabindex="0" data-sort-key="meshcoreNodesCount" data-sort-label="MeshCore (24h)"><img src="/assets/img/meshcore.svg" alt="" width="13" height="13" class="protocol-icon protocol-icon--meshcore" aria-hidden="true" loading="lazy" decoding="async" /> MeshCore (24h) <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--meshtastic-nodes" data-sort-key="meshtasticNodesCount"><span class="sort-header" role="button" tabindex="0" data-sort-key="meshtasticNodesCount" data-sort-label="Meshtastic (24h)"><img src="/assets/img/meshtastic.svg" alt="" width="13" height="13" class="protocol-icon protocol-icon--meshtastic" aria-hidden="true" loading="lazy" decoding="async" /> Meshtastic (24h) <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--latitude" data-sort-key="latitude"><span class="sort-header" role="button" tabindex="0" data-sort-key="latitude" data-sort-label="Latitude">Latitude <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--longitude" data-sort-key="longitude"><span class="sort-header" role="button" tabindex="0" data-sort-key="longitude" data-sort-label="Longitude">Longitude <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--last-update" data-sort-key="lastUpdateTime"><span class="sort-header" role="button" tabindex="0" data-sort-key="lastUpdateTime" data-sort-label="Last Update">Last Update <span class="sort-indicator" aria-hidden="true"></span></span></th>