mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-06 17:03:27 +02:00
web: per protocol active node counts (#735)
* web: per protocol active node counts * web: address review comments
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user