Compare commits

..

5 Commits

Author SHA1 Message Date
l5y c64a46a481 web: address review comments 2026-02-14 23:09:41 +01:00
l5y 11e5de2851 web: address review comments 2026-02-14 22:09:04 +01:00
l5y 8b3aa2533c web: address review comments 2026-02-14 22:02:02 +01:00
l5y e004cfacd7 web: address review comments 2026-02-14 21:38:56 +01:00
l5y 05d267d0c0 web: add cursor pagination to apis 2026-02-14 21:16:38 +01:00
22 changed files with 1550 additions and 676 deletions
+2 -2
View File
@@ -15,11 +15,11 @@
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>0.5.11</string>
<string>0.5.10</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>0.5.11</string>
<string>0.5.10</string>
<key>MinimumOSVersion</key>
<string>14.0</string>
</dict>
+1 -1
View File
@@ -1,7 +1,7 @@
name: potato_mesh_reader
description: Meshtastic Reader — read-only view for PotatoMesh messages.
publish_to: "none"
version: 0.5.11
version: 0.5.10
environment:
sdk: ">=3.4.0 <4.0.0"
+1 -1
View File
@@ -18,7 +18,7 @@ The ``data.mesh`` module exposes helpers for reading Meshtastic node and
message information before forwarding it to the accompanying web application.
"""
VERSION = "0.5.11"
VERSION = "0.5.10"
"""Semantic version identifier shared with the dashboard and front-end."""
__version__ = VERSION
+1 -1
View File
@@ -969,7 +969,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "potatomesh-matrix-bridge"
version = "0.5.11"
version = "0.5.10"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -14,7 +14,7 @@
[package]
name = "potatomesh-matrix-bridge"
version = "0.5.11"
version = "0.5.10"
edition = "2021"
[dependencies]
+2 -40
View File
@@ -55,38 +55,8 @@ def _javascript_package_version() -> str:
raise AssertionError("package.json does not expose a string version")
def _flutter_package_version() -> str:
pubspec_path = REPO_ROOT / "app" / "pubspec.yaml"
for line in pubspec_path.read_text(encoding="utf-8").splitlines():
if line.startswith("version:"):
version = line.split(":", 1)[1].strip()
if version:
return version
break
raise AssertionError("pubspec.yaml does not expose a version")
def _rust_package_version() -> str:
cargo_path = REPO_ROOT / "matrix" / "Cargo.toml"
inside_package = False
for line in cargo_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped == "[package]":
inside_package = True
continue
if inside_package and stripped.startswith("[") and stripped.endswith("]"):
break
if inside_package:
literal = re.match(
r'version\s*=\s*["\'](?P<version>[^"\']+)["\']', stripped
)
if literal:
return literal.group("version")
raise AssertionError("Cargo.toml does not expose a package version")
def test_version_identifiers_match_across_languages() -> None:
"""Guard against version drift between Python, Ruby, JavaScript, Flutter, and Rust."""
"""Guard against version drift between Python, Ruby, and JavaScript."""
python_version = getattr(data, "__version__", None)
assert (
@@ -95,13 +65,5 @@ def test_version_identifiers_match_across_languages() -> None:
ruby_version = _ruby_fallback_version()
javascript_version = _javascript_package_version()
flutter_version = _flutter_package_version()
rust_version = _rust_package_version()
assert (
python_version
== ruby_version
== javascript_version
== flutter_version
== rust_version
)
assert python_version == ruby_version == javascript_version
+81 -22
View File
@@ -165,37 +165,96 @@ module PotatoMesh
# malformed rows gracefully. The dataset is restricted to records updated
# within the rolling window defined by PotatoMesh::Config.week_seconds.
#
# @return [Array<Hash>] list of cleaned instance payloads.
def load_instances_for_api
# @param limit [Integer, nil] optional page size used when pagination is enabled.
# @param cursor [String, nil] optional keyset cursor for pagination.
# @param with_pagination [Boolean] when true, return items and next cursor metadata.
# @return [Array<Hash>, Hash] list of cleaned instance payloads or pagination metadata hash.
def load_instances_for_api(limit: nil, cursor: nil, with_pagination: false)
clean_duplicate_instances!
db = open_database(readonly: true)
db.results_as_hash = true
now = Time.now.to_i
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
FROM instances
WHERE domain IS NOT NULL AND TRIM(domain) != ''
AND pubkey IS NOT NULL AND TRIM(pubkey) != ''
AND last_update_time IS NOT NULL AND last_update_time >= ?
ORDER BY LOWER(domain)
SQL
safe_limit = coerce_query_limit(limit) if with_pagination
fetch_limit = with_pagination ? safe_limit + 1 : nil
where_clauses = [
"id IS NOT NULL",
"TRIM(id) != ''",
"domain IS NOT NULL",
"TRIM(domain) != ''",
"pubkey IS NOT NULL",
"TRIM(pubkey) != ''",
"last_update_time IS NOT NULL",
"last_update_time >= ?",
]
items = []
cursor_payload = with_pagination ? decode_query_cursor(cursor) : nil
cursor_domain = cursor_payload ? sanitize_instance_domain(cursor_payload["domain"])&.downcase : nil
cursor_id = cursor_payload ? string_or_nil(cursor_payload["id"]) : nil
rows = with_busy_retry do
db.execute(sql, min_last_update_time)
loop do
page_where_clauses = where_clauses.dup
page_params = [min_last_update_time]
if with_pagination && cursor_domain && cursor_id
page_where_clauses << "(LOWER(domain) > ? OR (LOWER(domain) = ? AND id > ?))"
page_params.concat([cursor_domain, cursor_domain, cursor_id])
end
sql = <<~SQL
SELECT id, domain, pubkey, name, version, channel, frequency,
latitude, longitude, last_update_time, is_private, nodes_count, contact_link, signature
FROM instances
WHERE #{page_where_clauses.join("\n AND ")}
ORDER BY LOWER(domain), id
SQL
sql += " LIMIT ?" if with_pagination
page_params << fetch_limit if with_pagination
rows = with_busy_retry do
db.execute(sql, page_params)
end
rows.each do |row|
normalized = normalize_instance_row(row)
next unless normalized
last_update_time = normalized["lastUpdateTime"]
next unless last_update_time.is_a?(Integer) && last_update_time >= min_last_update_time
items << normalized
end
return items unless with_pagination
break if items.length > safe_limit
break if rows.length < fetch_limit
marker_row = rows.reverse.find do |row|
string_or_nil(row["domain"]) && string_or_nil(row["id"])
end
break unless marker_row
marker_domain = string_or_nil(marker_row["domain"])&.downcase
marker_id = string_or_nil(marker_row["id"])
break unless marker_domain && marker_id
cursor_domain = marker_domain
cursor_id = marker_id
end
rows.each_with_object([]) do |row, memo|
normalized = normalize_instance_row(row)
next unless normalized
last_update_time = normalized["lastUpdateTime"]
next unless last_update_time.is_a?(Integer) && last_update_time >= min_last_update_time
memo << normalized
has_more = items.length > safe_limit
paged_items = has_more ? items.first(safe_limit) : items
next_cursor = nil
if has_more && !paged_items.empty?
marker = paged_items.last
next_cursor = encode_query_cursor({
"domain" => string_or_nil(marker["domain"]),
"id" => string_or_nil(marker["id"]),
})
end
{ items: paged_items, next_cursor: next_cursor }
rescue SQLite3::Exception => e
warn_log(
"Failed to load instance records",
@@ -203,7 +262,7 @@ module PotatoMesh
error_class: e.class.name,
error_message: e.message,
)
[]
with_pagination ? { items: [], next_cursor: nil } : []
ensure
db&.close
end
+372 -50
View File
@@ -127,6 +127,125 @@ module PotatoMesh
[threshold, floor].max
end
# Normalise an optional upper-bound timestamp for keyset pagination.
#
# @param before [Object] requested upper bound expressed as unix seconds.
# @param ceiling [Integer] maximum allowable timestamp.
# @return [Integer, nil] normalized upper bound or nil when absent.
def normalize_before_threshold(before, ceiling: Time.now.to_i)
value = coerce_integer(before)
return nil if value.nil?
value = 0 if value.negative?
[value, ceiling].min
end
# Decode a keyset cursor token previously emitted by {encode_query_cursor}.
#
# @param token [String, nil] base64 cursor token.
# @return [Hash, nil] decoded cursor payload.
def decode_query_cursor(token)
value = string_or_nil(token)
return nil unless value
decoded = Base64.urlsafe_decode64(value)
parsed = JSON.parse(decoded)
parsed.is_a?(Hash) ? parsed : nil
rescue ArgumentError, JSON::ParserError
nil
end
# Encode a cursor payload for keyset pagination transport.
#
# @param payload [Hash] cursor components.
# @return [String] URL-safe base64 cursor token.
def encode_query_cursor(payload)
Base64.urlsafe_encode64(JSON.generate(payload))
end
# Parse a rowid-based time cursor payload from pagination token.
#
# @param cursor [String, nil] cursor token.
# @param time_key [String] payload key containing the timestamp component.
# @return [Array<(Integer, Integer)>, Array<(nil, nil)>] decoded time/rowid pair.
def decode_rowid_time_cursor(cursor, time_key:)
cursor_payload = decode_query_cursor(cursor)
return [nil, nil] unless cursor_payload
[coerce_integer(cursor_payload[time_key]), coerce_integer(cursor_payload["rowid"])]
end
# Build pagination metadata for rowid-keyset collections.
#
# @param items [Array<Hash>] compacted rows.
# @param limit [Integer] requested limit.
# @param time_key [String] cursor payload timestamp key.
# @param marker_time [Proc] extractor receiving marker row hash.
# @return [Hash{Symbol => Object}] items and optional next_cursor.
def build_rowid_pagination_response(items, limit, time_key:, marker_time:)
has_more = items.length > limit
paged_items = has_more ? items.first(limit) : items
next_cursor = nil
if has_more && !paged_items.empty?
marker = paged_items.last
next_cursor = encode_query_cursor({
time_key => coerce_integer(marker_time.call(marker)),
"rowid" => coerce_integer(marker["_cursor_rowid"]),
})
end
paged_items.each { |item| item.delete("_cursor_time") }
paged_items.each { |item| item.delete("_cursor_rowid") }
{ items: paged_items, next_cursor: next_cursor }
end
# Append normalized since/before predicates for time-windowed collections.
#
# @param where_clauses [Array<String>] mutable SQL predicate fragments.
# @param params [Array<Object>] mutable SQL bind parameters.
# @param since [Object] lower-bound timestamp candidate.
# @param before [Object] upper-bound timestamp candidate.
# @param since_floor [Integer] minimum accepted since threshold.
# @param ceiling [Integer] maximum accepted before threshold.
# @param time_expression [String] SQL expression used for temporal filtering.
# @return [Integer] normalized since threshold.
def append_time_window_filters!(
where_clauses:,
params:,
since:,
before:,
since_floor:,
ceiling:,
time_expression:
)
since_threshold = normalize_since_threshold(since, floor: since_floor)
before_threshold = normalize_before_threshold(before, ceiling: ceiling)
where_clauses << "#{time_expression} >= ?"
params << since_threshold
if before_threshold
where_clauses << "#{time_expression} <= ?"
params << before_threshold
end
since_threshold
end
# Append rowid/timestamp keyset predicates for descending time-ordered tables.
#
# @param where_clauses [Array<String>] mutable SQL predicate fragments.
# @param params [Array<Object>] mutable SQL bind parameters.
# @param cursor [String, nil] encoded cursor token.
# @param time_key [String] cursor payload timestamp key.
# @param time_expression [String] SQL timestamp expression used for ordering.
# @return [void]
def append_rowid_time_cursor_filter!(where_clauses:, params:, cursor:, time_key:, time_expression:)
cursor_time, cursor_rowid = decode_rowid_time_cursor(cursor, time_key: time_key)
return unless cursor_time && cursor_rowid
where_clauses << "(#{time_expression} < ? OR (#{time_expression} = ? AND rowid < ?))"
params.concat([cursor_time, cursor_time, cursor_rowid])
end
# Return exact active-node counts across common activity windows.
#
# Counts are resolved directly in SQL with COUNT(*) thresholds against
@@ -252,26 +371,43 @@ module PotatoMesh
# @param node_ref [String, Integer, nil] optional node reference to narrow results.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
# @return [Array<Hash>] compacted node rows suitable for API responses.
def query_nodes(limit, node_ref: nil, since: 0)
def query_nodes(limit, node_ref: nil, since: 0, before: nil, cursor: nil, with_pagination: false)
limit = coerce_query_limit(limit)
fetch_limit = with_pagination ? limit + 1 : limit
db = open_database(readonly: true)
db.results_as_hash = true
now = Time.now.to_i
min_last_heard = now - PotatoMesh::Config.week_seconds
since_floor = node_ref ? 0 : min_last_heard
since_threshold = normalize_since_threshold(since, floor: since_floor)
before_threshold = normalize_before_threshold(before, ceiling: now)
params = []
where_clauses = []
if node_ref
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["num"])
return [] unless clause
return with_pagination ? { items: [], next_cursor: nil } : [] unless clause
where_clauses << clause.first
params.concat(clause.last)
else
where_clauses << "last_heard >= ?"
params << since_threshold
end
if before_threshold
where_clauses << "last_heard <= ?"
params << before_threshold
end
if with_pagination
cursor_payload = decode_query_cursor(cursor)
if cursor_payload
cursor_last_heard = coerce_integer(cursor_payload["last_heard"])
cursor_node_id = string_or_nil(cursor_payload["node_id"])
if cursor_last_heard && cursor_node_id
where_clauses << "(last_heard < ? OR (last_heard = ? AND node_id < ?))"
params.concat([cursor_last_heard, cursor_last_heard, cursor_node_id])
end
end
end
if private_mode?
where_clauses << "(role IS NULL OR role <> 'CLIENT_HIDDEN')"
@@ -287,10 +423,10 @@ module PotatoMesh
SQL
sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any?
sql += <<~SQL
ORDER BY last_heard DESC
ORDER BY last_heard DESC, node_id DESC
LIMIT ?
SQL
params << limit
params << fetch_limit
rows = db.execute(sql, params)
rows = rows.select do |r|
@@ -300,7 +436,15 @@ module PotatoMesh
.max
last_candidate && last_candidate >= since_threshold
end
rows.each do |r|
has_more = with_pagination && rows.length > limit
paged_rows = has_more ? rows.first(limit) : rows
marker_row = has_more ? paged_rows.last : nil
marker_last_heard = marker_row ? coerce_integer(marker_row["last_heard"]) : nil
marker_node_id = marker_row ? string_or_nil(marker_row["node_id"]) : nil
output_rows = with_pagination ? paged_rows : rows
output_rows.each do |r|
r["role"] ||= "CLIENT"
lh = r["last_heard"]&.to_i
pt = r["position_time"]&.to_i
@@ -313,7 +457,18 @@ module PotatoMesh
pb = r["precision_bits"]
r["precision_bits"] = pb.to_i if pb
end
rows.map { |row| compact_api_row(row) }
items = output_rows.map { |row| compact_api_row(row) }
items.each { |item| item.delete("_cursor_rowid") }
return items unless with_pagination
next_cursor = nil
if has_more && marker_last_heard && marker_node_id
next_cursor = encode_query_cursor({
"last_heard" => marker_last_heard,
"node_id" => marker_node_id,
})
end
{ items: items, next_cursor: next_cursor }
ensure
db&.close
end
@@ -323,22 +478,41 @@ module PotatoMesh
# @param limit [Integer] maximum number of ingestors to return.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
# @return [Array<Hash>] compacted ingestor rows suitable for API responses.
def query_ingestors(limit, since: 0)
def query_ingestors(limit, since: 0, before: nil, cursor: nil, with_pagination: false)
limit = coerce_query_limit(limit)
fetch_limit = with_pagination ? limit + 1 : limit
db = open_database(readonly: true)
db.results_as_hash = true
now = Time.now.to_i
cutoff = now - PotatoMesh::Config.week_seconds
since_threshold = normalize_since_threshold(since, floor: cutoff)
before_threshold = normalize_before_threshold(before, ceiling: now)
where_clauses = ["last_seen_time >= ?"]
params = [since_threshold]
if before_threshold
where_clauses << "last_seen_time <= ?"
params << before_threshold
end
if with_pagination
cursor_payload = decode_query_cursor(cursor)
if cursor_payload
cursor_last_seen = coerce_integer(cursor_payload["last_seen_time"])
cursor_node_id = string_or_nil(cursor_payload["node_id"])
if cursor_last_seen && cursor_node_id
where_clauses << "(last_seen_time < ? OR (last_seen_time = ? AND node_id < ?))"
params.concat([cursor_last_seen, cursor_last_seen, cursor_node_id])
end
end
end
sql = <<~SQL
SELECT node_id, start_time, last_seen_time, version, lora_freq, modem_preset
FROM ingestors
WHERE last_seen_time >= ?
ORDER BY last_seen_time DESC
WHERE #{where_clauses.join(" AND ")}
ORDER BY last_seen_time DESC, node_id DESC
LIMIT ?
SQL
rows = db.execute(sql, [since_threshold, limit])
rows = db.execute(sql, params + [fetch_limit])
rows.each do |row|
row.delete_if { |key, _| key.is_a?(Integer) }
start_time = coerce_integer(row["start_time"])
@@ -354,7 +528,21 @@ module PotatoMesh
row["last_seen_iso"] = Time.at(last_seen_time).utc.iso8601 if last_seen_time
end
rows.map { |row| compact_api_row(row) }
items = rows.map { |row| compact_api_row(row) }
items.each { |item| item.delete("_cursor_rowid") }
return items unless with_pagination
has_more = items.length > limit
paged_items = has_more ? items.first(limit) : items
next_cursor = nil
if has_more && !paged_items.empty?
marker = paged_items.last
next_cursor = encode_query_cursor({
"last_seen_time" => coerce_integer(marker["last_seen_time"]),
"node_id" => string_or_nil(marker["node_id"]),
})
end
{ items: paged_items, next_cursor: next_cursor }
ensure
db&.close
end
@@ -366,9 +554,11 @@ module PotatoMesh
# @param include_encrypted [Boolean] when true, include encrypted payloads in the response.
# @param since [Integer] unix timestamp threshold; messages with rx_time older than this are excluded.
# @return [Array<Hash>] compacted message rows safe for API responses.
def query_messages(limit, node_ref: nil, include_encrypted: false, since: 0)
def query_messages(limit, node_ref: nil, include_encrypted: false, since: 0, before: nil, cursor: nil, with_pagination: false)
limit = coerce_query_limit(limit)
fetch_limit = with_pagination ? limit + 1 : limit
since_threshold = normalize_since_threshold(since, floor: 0)
before_threshold = normalize_before_threshold(before)
db = open_database(readonly: true)
db.results_as_hash = true
params = []
@@ -378,10 +568,25 @@ module PotatoMesh
include_encrypted = !!include_encrypted
where_clauses << "m.rx_time >= ?"
params << since_threshold
if before_threshold
where_clauses << "m.rx_time <= ?"
params << before_threshold
end
unless include_encrypted
where_clauses << "COALESCE(TRIM(m.encrypted), '') = ''"
end
if with_pagination
cursor_payload = decode_query_cursor(cursor)
if cursor_payload
cursor_rx_time = coerce_integer(cursor_payload["rx_time"])
cursor_id = string_or_nil(cursor_payload["id"])
if cursor_rx_time && cursor_id
where_clauses << "(m.rx_time < ? OR (m.rx_time = ? AND m.id < ?))"
params.concat([cursor_rx_time, cursor_rx_time, cursor_id])
end
end
end
if node_ref
clause = node_lookup_clause(node_ref, string_columns: ["m.from_id", "m.to_id"])
@@ -399,10 +604,10 @@ module PotatoMesh
SQL
sql += " WHERE #{where_clauses.join(" AND ")}\n"
sql += <<~SQL
ORDER BY m.rx_time DESC
ORDER BY m.rx_time DESC, m.id DESC
LIMIT ?
SQL
params << limit
params << fetch_limit
rows = db.execute(sql, params)
rows.each do |r|
r.delete_if { |key, _| key.is_a?(Integer) }
@@ -444,7 +649,21 @@ module PotatoMesh
)
end
end
rows.map { |row| compact_api_row(row) }
items = rows.map { |row| compact_api_row(row) }
items.each { |item| item.delete("_cursor_rowid") }
return items unless with_pagination
has_more = items.length > limit
paged_items = has_more ? items.first(limit) : items
next_cursor = nil
if has_more && !paged_items.empty?
marker = paged_items.last
next_cursor = encode_query_cursor({
"rx_time" => coerce_integer(marker["rx_time"]),
"id" => string_or_nil(marker["id"]),
})
end
{ items: paged_items, next_cursor: next_cursor }
ensure
db&.close
end
@@ -455,18 +674,26 @@ module PotatoMesh
# @param node_ref [String, Integer, nil] optional node reference to scope results.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
# @return [Array<Hash>] compacted position rows suitable for API responses.
def query_positions(limit, node_ref: nil, since: 0)
def query_positions(limit, node_ref: nil, since: 0, before: nil, cursor: nil, with_pagination: false)
limit = coerce_query_limit(limit)
fetch_limit = with_pagination ? limit + 1 : limit
db = open_database(readonly: true)
db.results_as_hash = true
params = []
where_clauses = []
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
time_expression = "COALESCE(rx_time, position_time, 0)"
since_floor = node_ref ? 0 : min_rx_time
since_threshold = normalize_since_threshold(since, floor: since_floor)
where_clauses << "COALESCE(rx_time, position_time, 0) >= ?"
params << since_threshold
append_time_window_filters!(
where_clauses: where_clauses,
params: params,
since: since,
before: before,
since_floor: since_floor,
ceiling: now,
time_expression: time_expression,
)
if node_ref
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["node_num"])
@@ -475,15 +702,22 @@ module PotatoMesh
params.concat(clause.last)
end
sql = <<~SQL
SELECT * FROM positions
SQL
append_rowid_time_cursor_filter!(
where_clauses: where_clauses,
params: params,
cursor: cursor,
time_key: "cursor_time",
time_expression: time_expression,
) if with_pagination
select_sql = with_pagination ? "SELECT *, rowid AS _cursor_rowid, COALESCE(rx_time, position_time, 0) AS _cursor_time FROM positions" : "SELECT * FROM positions"
sql = "#{select_sql}\n"
sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any?
sql += <<~SQL
ORDER BY rx_time DESC
ORDER BY COALESCE(rx_time, position_time, 0) DESC, rowid DESC
LIMIT ?
SQL
params << limit
params << fetch_limit
rows = db.execute(sql, params)
rows.each do |r|
rx_time = coerce_integer(r["rx_time"])
@@ -503,7 +737,16 @@ module PotatoMesh
r["pdop"] = coerce_float(r["pdop"])
r["snr"] = coerce_float(r["snr"])
end
rows.map { |row| compact_api_row(row) }
items = rows.map { |row| compact_api_row(row) }
items.each { |item| item.delete("_cursor_rowid") } unless with_pagination
return items unless with_pagination
build_rowid_pagination_response(
items,
limit,
time_key: "cursor_time",
marker_time: ->(marker) { marker["_cursor_time"] },
)
ensure
db&.close
end
@@ -514,18 +757,26 @@ module PotatoMesh
# @param node_ref [String, Integer, nil] optional node reference to scope results.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
# @return [Array<Hash>] compacted neighbor rows suitable for API responses.
def query_neighbors(limit, node_ref: nil, since: 0)
def query_neighbors(limit, node_ref: nil, since: 0, before: nil, cursor: nil, with_pagination: false)
limit = coerce_query_limit(limit)
fetch_limit = with_pagination ? limit + 1 : limit
db = open_database(readonly: true)
db.results_as_hash = true
params = []
where_clauses = []
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
time_expression = "COALESCE(rx_time, 0)"
since_floor = node_ref ? 0 : min_rx_time
since_threshold = normalize_since_threshold(since, floor: since_floor)
where_clauses << "COALESCE(rx_time, 0) >= ?"
params << since_threshold
append_time_window_filters!(
where_clauses: where_clauses,
params: params,
since: since,
before: before,
since_floor: since_floor,
ceiling: now,
time_expression: time_expression,
)
if node_ref
clause = node_lookup_clause(node_ref, string_columns: ["node_id", "neighbor_id"])
@@ -534,15 +785,22 @@ module PotatoMesh
params.concat(clause.last)
end
sql = <<~SQL
SELECT * FROM neighbors
SQL
append_rowid_time_cursor_filter!(
where_clauses: where_clauses,
params: params,
cursor: cursor,
time_key: "rx_time",
time_expression: "rx_time",
) if with_pagination
select_sql = with_pagination ? "SELECT *, rowid AS _cursor_rowid FROM neighbors" : "SELECT * FROM neighbors"
sql = "#{select_sql}\n"
sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any?
sql += <<~SQL
ORDER BY rx_time DESC
ORDER BY rx_time DESC, rowid DESC
LIMIT ?
SQL
params << limit
params << fetch_limit
rows = db.execute(sql, params)
rows.each do |r|
rx_time = coerce_integer(r["rx_time"])
@@ -551,7 +809,16 @@ module PotatoMesh
r["rx_iso"] = Time.at(rx_time).utc.iso8601 if rx_time
r["snr"] = coerce_float(r["snr"])
end
rows.map { |row| compact_api_row(row) }
items = rows.map { |row| compact_api_row(row) }
items.each { |item| item.delete("_cursor_rowid") } unless with_pagination
return items unless with_pagination
build_rowid_pagination_response(
items,
limit,
time_key: "rx_time",
marker_time: ->(marker) { marker["rx_time"] },
)
ensure
db&.close
end
@@ -562,18 +829,26 @@ module PotatoMesh
# @param node_ref [String, Integer, nil] optional node reference to scope results.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
# @return [Array<Hash>] compacted telemetry rows suitable for API responses.
def query_telemetry(limit, node_ref: nil, since: 0)
def query_telemetry(limit, node_ref: nil, since: 0, before: nil, cursor: nil, with_pagination: false)
limit = coerce_query_limit(limit)
fetch_limit = with_pagination ? limit + 1 : limit
db = open_database(readonly: true)
db.results_as_hash = true
params = []
where_clauses = []
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
time_expression = "COALESCE(rx_time, telemetry_time, 0)"
since_floor = node_ref ? 0 : min_rx_time
since_threshold = normalize_since_threshold(since, floor: since_floor)
where_clauses << "COALESCE(rx_time, telemetry_time, 0) >= ?"
params << since_threshold
append_time_window_filters!(
where_clauses: where_clauses,
params: params,
since: since,
before: before,
since_floor: since_floor,
ceiling: now,
time_expression: time_expression,
)
if node_ref
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["node_num"])
@@ -582,15 +857,22 @@ module PotatoMesh
params.concat(clause.last)
end
sql = <<~SQL
SELECT * FROM telemetry
SQL
append_rowid_time_cursor_filter!(
where_clauses: where_clauses,
params: params,
cursor: cursor,
time_key: "cursor_time",
time_expression: time_expression,
) if with_pagination
select_sql = with_pagination ? "SELECT *, rowid AS _cursor_rowid, COALESCE(rx_time, telemetry_time, 0) AS _cursor_time FROM telemetry" : "SELECT * FROM telemetry"
sql = "#{select_sql}\n"
sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any?
sql += <<~SQL
ORDER BY rx_time DESC
ORDER BY COALESCE(rx_time, telemetry_time, 0) DESC, rowid DESC
LIMIT ?
SQL
params << limit
params << fetch_limit
rows = db.execute(sql, params)
rows.each do |r|
rx_time = coerce_integer(r["rx_time"])
@@ -638,7 +920,16 @@ module PotatoMesh
r["soil_moisture"] = coerce_integer(r["soil_moisture"])
r["soil_temperature"] = coerce_float(r["soil_temperature"])
end
rows.map { |row| compact_api_row(row) }
items = rows.map { |row| compact_api_row(row) }
items.each { |item| item.delete("_cursor_rowid") } unless with_pagination
return items unless with_pagination
build_rowid_pagination_response(
items,
limit,
time_key: "cursor_time",
marker_time: ->(marker) { marker["_cursor_time"] },
)
ensure
db&.close
end
@@ -771,8 +1062,9 @@ module PotatoMesh
# @param node_ref [String, Integer, nil] optional node reference to scope results.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
# @return [Array<Hash>] compacted trace rows suitable for API responses.
def query_traces(limit, node_ref: nil, since: 0)
def query_traces(limit, node_ref: nil, since: 0, before: nil, cursor: nil, with_pagination: false)
limit = coerce_query_limit(limit)
fetch_limit = with_pagination ? limit + 1 : limit
db = open_database(readonly: true)
db.results_as_hash = true
params = []
@@ -780,14 +1072,19 @@ module PotatoMesh
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.trace_neighbor_window_seconds
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
before_threshold = normalize_before_threshold(before, ceiling: now)
where_clauses << "COALESCE(rx_time, 0) >= ?"
params << since_threshold
if before_threshold
where_clauses << "COALESCE(rx_time, 0) <= ?"
params << before_threshold
end
if node_ref
tokens = node_reference_tokens(node_ref)
numeric_values = tokens[:numeric_values]
if numeric_values.empty?
return []
return with_pagination ? { items: [], next_cursor: nil } : []
end
placeholders = Array.new(numeric_values.length, "?").join(", ")
candidate_clauses = []
@@ -798,16 +1095,28 @@ module PotatoMesh
3.times { params.concat(numeric_values) }
end
if with_pagination
cursor_payload = decode_query_cursor(cursor)
if cursor_payload
cursor_rx_time = coerce_integer(cursor_payload["rx_time"])
cursor_id = coerce_integer(cursor_payload["id"])
if cursor_rx_time && cursor_id
where_clauses << "(rx_time < ? OR (rx_time = ? AND id < ?))"
params.concat([cursor_rx_time, cursor_rx_time, cursor_id])
end
end
end
sql = <<~SQL
SELECT id, request_id, src, dest, rx_time, rx_iso, rssi, snr, elapsed_ms
FROM traces
SQL
sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any?
sql += <<~SQL
ORDER BY rx_time DESC
ORDER BY rx_time DESC, id DESC
LIMIT ?
SQL
params << limit
params << fetch_limit
rows = db.execute(sql, params)
trace_ids = rows.map { |row| coerce_integer(row["id"]) }.compact
@@ -844,7 +1153,20 @@ module PotatoMesh
r["hops"] = hops_by_trace[trace_id]
end
end
rows.map { |row| compact_api_row(row) }
items = rows.map { |row| compact_api_row(row) }
return items unless with_pagination
has_more = items.length > limit
paged_items = has_more ? items.first(limit) : items
next_cursor = nil
if has_more && !paged_items.empty?
marker = paged_items.last
next_cursor = encode_query_cursor({
"rx_time" => coerce_integer(marker["rx_time"]),
"id" => coerce_integer(marker["id"]),
})
end
{ items: paged_items, next_cursor: next_cursor }
ensure
db&.close
end
+118 -15
View File
@@ -64,7 +64,15 @@ module PotatoMesh
app.get "/api/nodes" do
content_type :json
limit = [params["limit"]&.to_i || 200, 1000].min
query_nodes(limit, since: params["since"]).to_json
result = query_nodes(
limit,
since: params["since"],
before: params["before"],
cursor: params["cursor"],
with_pagination: true,
)
response["X-Next-Cursor"] = result[:next_cursor] if result[:next_cursor]
result[:items].to_json
end
app.get "/api/stats" do
@@ -88,7 +96,15 @@ module PotatoMesh
app.get "/api/ingestors" do
content_type :json
limit = coerce_query_limit(params["limit"])
query_ingestors(limit, since: params["since"]).to_json
result = query_ingestors(
limit,
since: params["since"],
before: params["before"],
cursor: params["cursor"],
with_pagination: true,
)
response["X-Next-Cursor"] = result[:next_cursor] if result[:next_cursor]
result[:items].to_json
end
app.get "/api/messages" do
@@ -97,7 +113,16 @@ module PotatoMesh
include_encrypted = coerce_boolean(params["encrypted"]) || false
since = coerce_integer(params["since"])
since = 0 if since.nil? || since.negative?
query_messages(limit, include_encrypted: include_encrypted, since: since).to_json
result = query_messages(
limit,
include_encrypted: include_encrypted,
since: since,
before: params["before"],
cursor: params["cursor"],
with_pagination: true,
)
response["X-Next-Cursor"] = result[:next_cursor] if result[:next_cursor]
result[:items].to_json
end
app.get "/api/messages/:id" do
@@ -108,18 +133,31 @@ module PotatoMesh
include_encrypted = coerce_boolean(params["encrypted"]) || false
since = coerce_integer(params["since"])
since = 0 if since.nil? || since.negative?
query_messages(
result = query_messages(
limit,
node_ref: node_ref,
include_encrypted: include_encrypted,
since: since,
).to_json
before: params["before"],
cursor: params["cursor"],
with_pagination: true,
)
response["X-Next-Cursor"] = result[:next_cursor] if result[:next_cursor]
result[:items].to_json
end
app.get "/api/positions" do
content_type :json
limit = [params["limit"]&.to_i || 200, 1000].min
query_positions(limit, since: params["since"]).to_json
result = query_positions(
limit,
since: params["since"],
before: params["before"],
cursor: params["cursor"],
with_pagination: true,
)
response["X-Next-Cursor"] = result[:next_cursor] if result[:next_cursor]
result[:items].to_json
end
app.get "/api/positions/:id" do
@@ -127,13 +165,30 @@ module PotatoMesh
node_ref = string_or_nil(params["id"])
halt 400, { error: "missing node id" }.to_json unless node_ref
limit = [params["limit"]&.to_i || 200, 1000].min
query_positions(limit, node_ref: node_ref, since: params["since"]).to_json
result = query_positions(
limit,
node_ref: node_ref,
since: params["since"],
before: params["before"],
cursor: params["cursor"],
with_pagination: true,
)
response["X-Next-Cursor"] = result[:next_cursor] if result[:next_cursor]
result[:items].to_json
end
app.get "/api/neighbors" do
content_type :json
limit = [params["limit"]&.to_i || 200, 1000].min
query_neighbors(limit, since: params["since"]).to_json
result = query_neighbors(
limit,
since: params["since"],
before: params["before"],
cursor: params["cursor"],
with_pagination: true,
)
response["X-Next-Cursor"] = result[:next_cursor] if result[:next_cursor]
result[:items].to_json
end
app.get "/api/neighbors/:id" do
@@ -141,13 +196,30 @@ module PotatoMesh
node_ref = string_or_nil(params["id"])
halt 400, { error: "missing node id" }.to_json unless node_ref
limit = [params["limit"]&.to_i || 200, 1000].min
query_neighbors(limit, node_ref: node_ref, since: params["since"]).to_json
result = query_neighbors(
limit,
node_ref: node_ref,
since: params["since"],
before: params["before"],
cursor: params["cursor"],
with_pagination: true,
)
response["X-Next-Cursor"] = result[:next_cursor] if result[:next_cursor]
result[:items].to_json
end
app.get "/api/telemetry" do
content_type :json
limit = [params["limit"]&.to_i || 200, 1000].min
query_telemetry(limit, since: params["since"]).to_json
result = query_telemetry(
limit,
since: params["since"],
before: params["before"],
cursor: params["cursor"],
with_pagination: true,
)
response["X-Next-Cursor"] = result[:next_cursor] if result[:next_cursor]
result[:items].to_json
end
app.get "/api/telemetry/aggregated" do
@@ -190,13 +262,30 @@ module PotatoMesh
node_ref = string_or_nil(params["id"])
halt 400, { error: "missing node id" }.to_json unless node_ref
limit = [params["limit"]&.to_i || 200, 1000].min
query_telemetry(limit, node_ref: node_ref, since: params["since"]).to_json
result = query_telemetry(
limit,
node_ref: node_ref,
since: params["since"],
before: params["before"],
cursor: params["cursor"],
with_pagination: true,
)
response["X-Next-Cursor"] = result[:next_cursor] if result[:next_cursor]
result[:items].to_json
end
app.get "/api/traces" do
content_type :json
limit = [params["limit"]&.to_i || 200, 1000].min
query_traces(limit, since: params["since"]).to_json
result = query_traces(
limit,
since: params["since"],
before: params["before"],
cursor: params["cursor"],
with_pagination: true,
)
response["X-Next-Cursor"] = result[:next_cursor] if result[:next_cursor]
result[:items].to_json
end
app.get "/api/traces/:id" do
@@ -204,7 +293,16 @@ module PotatoMesh
node_ref = string_or_nil(params["id"])
halt 400, { error: "missing node id" }.to_json unless node_ref
limit = [params["limit"]&.to_i || 200, 1000].min
query_traces(limit, node_ref: node_ref, since: params["since"]).to_json
result = query_traces(
limit,
node_ref: node_ref,
since: params["since"],
before: params["before"],
cursor: params["cursor"],
with_pagination: true,
)
response["X-Next-Cursor"] = result[:next_cursor] if result[:next_cursor]
result[:items].to_json
end
app.get "/api/instances" do
@@ -213,8 +311,13 @@ module PotatoMesh
content_type :json
ensure_self_instance_record!
payload = load_instances_for_api
JSON.generate(payload)
result = load_instances_for_api(
limit: params["limit"],
cursor: params["cursor"],
with_pagination: true,
)
response["X-Next-Cursor"] = result[:next_cursor] if result[:next_cursor]
JSON.generate(result[:items])
end
end
end
+1 -1
View File
@@ -187,7 +187,7 @@ module PotatoMesh
#
# @return [String] semantic version identifier.
def version_fallback
"0.5.11"
"0.5.10"
end
# Default refresh interval for frontend polling routines.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "potato-mesh",
"version": "0.5.11",
"version": "0.5.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "potato-mesh",
"version": "0.5.11",
"version": "0.5.10",
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "potato-mesh",
"version": "0.5.11",
"version": "0.5.10",
"type": "module",
"private": true,
"scripts": {
@@ -1,64 +0,0 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
filterDisplayableFederationInstances,
isSuppressedFederationSiteName,
resolveFederationInstanceLabel,
resolveFederationInstanceSortValue,
resolveFederationSiteNameForDisplay,
shouldDisplayFederationInstance,
truncateFederationSiteName
} from '../federation-instance-display.js';
test('isSuppressedFederationSiteName detects URL-like advertising names', () => {
assert.equal(isSuppressedFederationSiteName('http://spam.example offer'), true);
assert.equal(isSuppressedFederationSiteName('Visit www.spam.example today'), true);
assert.equal(isSuppressedFederationSiteName('Mesh Collective'), false);
assert.equal(isSuppressedFederationSiteName(''), false);
assert.equal(isSuppressedFederationSiteName(null), false);
});
test('truncateFederationSiteName shortens names longer than 32 characters', () => {
assert.equal(truncateFederationSiteName('Short Mesh'), 'Short Mesh');
assert.equal(
truncateFederationSiteName('abcdefghijklmnopqrstuvwxyz1234567890'),
'abcdefghijklmnopqrstuvwxyz123...'
);
assert.equal(truncateFederationSiteName('abcdefghijklmnopqrstuvwxyz123456').length, 32);
assert.equal(truncateFederationSiteName(null), '');
});
test('display helpers filter suppressed names and preserve original domains', () => {
const entries = [
{ name: 'Normal Mesh', domain: 'normal.mesh' },
{ name: 'https://spam.example promo', domain: 'spam.mesh' },
{ domain: 'unnamed.mesh' }
];
assert.equal(shouldDisplayFederationInstance(entries[0]), true);
assert.equal(shouldDisplayFederationInstance(entries[1]), false);
assert.deepEqual(filterDisplayableFederationInstances(entries), [
{ name: 'Normal Mesh', domain: 'normal.mesh' },
{ domain: 'unnamed.mesh' }
]);
assert.equal(resolveFederationSiteNameForDisplay(entries[0]), 'Normal Mesh');
assert.equal(resolveFederationInstanceLabel(entries[2]), 'unnamed.mesh');
assert.equal(resolveFederationInstanceSortValue(entries[0]), 'Normal Mesh');
});
@@ -21,39 +21,30 @@ import { createDomEnvironment } from './dom-environment.js';
import { initializeFederationPage } from '../federation-page.js';
import { roleColors } from '../role-helpers.js';
function createBasicFederationPageHarness() {
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
function createFailureScenarioPage(env) {
const { document, createElement, registerElement } = env;
const mapEl = createElement('div', 'map');
registerElement('map', mapEl);
registerElement('map', createElement('div', 'map'));
const statusEl = createElement('div', 'status');
registerElement('status', statusEl);
const tableEl = createElement('table', 'instances');
const tbodyEl = createElement('tbody');
registerElement('instances', tableEl);
tableEl.appendChild(tbodyEl);
const configEl = createElement('div');
configEl.setAttribute('data-app-config', JSON.stringify({ mapCenter: { lat: 0, lon: 0 }, mapZoom: 3 }));
configEl.setAttribute('data-app-config', JSON.stringify({}));
document.querySelector = selector => {
if (selector === '[data-app-config]') return configEl;
if (selector === '#instances tbody') return tbodyEl;
return null;
};
return { ...env, statusEl, tbodyEl };
return { statusEl };
}
function createBasicLeafletStub(options = {}) {
const { markerPopups = null, fitBounds = false } = options;
function createMinimalLeafletStub() {
return {
map() {
return {
setView() {},
on() {},
fitBounds: fitBounds ? () => {} : undefined,
getPane() {
return null;
}
@@ -71,20 +62,10 @@ function createBasicLeafletStub(options = {}) {
};
},
layerGroup() {
return {
addLayer() {},
addTo() {
return this;
}
};
return { addLayer() {}, addTo() { return this; } };
},
circleMarker() {
return {
bindPopup(html) {
markerPopups?.push(html);
return this;
}
};
return { bindPopup() { return this; } };
}
};
}
@@ -671,141 +652,28 @@ test('federation legend toggle respects media query changes', async () => {
});
test('federation page tolerates fetch failures', async () => {
const { cleanup } = createBasicFederationPageHarness();
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
const { cleanup } = env;
createFailureScenarioPage(env);
const leafletStub = createMinimalLeafletStub();
const fetchImpl = async () => {
throw new Error('boom');
};
const leafletStub = createBasicLeafletStub();
await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub });
cleanup();
});
test('federation page suppresses spammy site names and truncates long names in visible UI', async () => {
const { cleanup, statusEl, tbodyEl } = createBasicFederationPageHarness();
const markerPopups = [];
const leafletStub = createBasicLeafletStub({ markerPopups, fitBounds: true });
const fetchImpl = async () => ({
ok: true,
json: async () => [
{
domain: 'visible.mesh',
name: 'abcdefghijklmnopqrstuvwxyz1234567890',
latitude: 1,
longitude: 1,
lastUpdateTime: Math.floor(Date.now() / 1000) - 30
},
{
domain: 'spam.mesh',
name: 'www.spam.example buy now',
latitude: 2,
longitude: 2,
lastUpdateTime: Math.floor(Date.now() / 1000) - 60
}
]
});
try {
await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub });
assert.equal(statusEl.textContent, '1 instances');
assert.equal(tbodyEl.childNodes.length, 1);
assert.match(tbodyEl.childNodes[0].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./);
assert.doesNotMatch(tbodyEl.childNodes[0].innerHTML, /spam\.mesh/);
assert.equal(markerPopups.length, 1);
assert.match(markerPopups[0], /abcdefghijklmnopqrstuvwxyz123\.\.\./);
assert.doesNotMatch(markerPopups[0], /www\.spam\.example/);
} finally {
cleanup();
}
});
test('federation page sorts by full site names before truncating visible labels', async () => {
test('federation page tolerates non-ok paginated instance responses', async () => {
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
const { document, createElement, registerElement, cleanup } = env;
const sharedPrefix = 'abcdefghijklmnopqrstuvwxyz123';
const { statusEl } = createFailureScenarioPage(env);
const { cleanup } = env;
const leafletStub = createMinimalLeafletStub();
const mapEl = createElement('div', 'map');
registerElement('map', mapEl);
const statusEl = createElement('div', 'status');
registerElement('status', statusEl);
const fetchImpl = async () => ({ ok: false, json: async () => [] });
const tableEl = createElement('table', 'instances');
const tbodyEl = createElement('tbody');
registerElement('instances', tableEl);
tableEl.appendChild(tbodyEl);
const headerNameTh = createElement('th');
const headerName = createElement('span');
headerName.classList.add('sort-header');
headerName.dataset.sortKey = 'name';
headerName.dataset.sortLabel = 'Name';
headerNameTh.appendChild(headerName);
const ths = [headerNameTh];
const headers = [headerName];
const headerHandlers = new Map();
headers.forEach(header => {
header.addEventListener = (event, handler) => {
const existing = headerHandlers.get(header) || {};
existing[event] = handler;
headerHandlers.set(header, existing);
};
header.closest = () => ths.find(th => th.childNodes.includes(header));
header.querySelector = () => null;
});
tableEl.querySelectorAll = selector => {
if (selector === 'thead .sort-header[data-sort-key]') return headers;
if (selector === 'thead th') return ths;
return [];
};
const configEl = createElement('div');
configEl.setAttribute('data-app-config', JSON.stringify({ mapCenter: { lat: 0, lon: 0 }, mapZoom: 3 }));
document.querySelector = selector => {
if (selector === '[data-app-config]') return configEl;
if (selector === '#instances tbody') return tbodyEl;
return null;
};
const fetchImpl = async () => ({
ok: true,
json: async () => [
{
domain: 'zeta.mesh',
name: `${sharedPrefix}zeta suffix`,
latitude: 1,
longitude: 1,
lastUpdateTime: Math.floor(Date.now() / 1000) - 30
},
{
domain: 'alpha.mesh',
name: `${sharedPrefix}alpha suffix`,
latitude: 2,
longitude: 2,
lastUpdateTime: Math.floor(Date.now() / 1000) - 60
}
]
});
try {
await initializeFederationPage({
config: {},
fetchImpl,
leaflet: createBasicLeafletStub({ fitBounds: true })
});
const nameHandlers = headerHandlers.get(headerName);
nameHandlers.click();
assert.match(tbodyEl.childNodes[0].innerHTML, /alpha\.mesh/);
assert.match(tbodyEl.childNodes[1].innerHTML, /zeta\.mesh/);
assert.match(tbodyEl.childNodes[0].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./);
assert.match(tbodyEl.childNodes[1].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./);
} finally {
cleanup();
}
await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub });
assert.match(statusEl.textContent, /0 instances/);
cleanup();
});
@@ -154,75 +154,6 @@ test('initializeInstanceSelector populates options alphabetically and selects th
}
});
test('initializeInstanceSelector hides suppressed names and truncates long labels', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
const navLink = env.document.createElement('a');
navLink.classList.add('js-federation-nav');
navLink.textContent = 'Federation';
env.document.body.appendChild(navLink);
const fetchImpl = async () => ({
ok: true,
async json() {
return [
{ name: 'Visit https://spam.example now', domain: 'spam.mesh' },
{ name: 'abcdefghijklmnopqrstuvwxyz1234567890', domain: 'long.mesh' },
{ name: 'Alpha Mesh', domain: 'alpha.mesh' }
];
}
});
try {
await initializeInstanceSelector({
selectElement: select,
fetchImpl,
windowObject: env.window,
documentObject: env.document
});
assert.equal(select.options.length, 3);
assert.equal(select.options[1].textContent, 'abcdefghijklmnopqrstuvwxyz123...');
assert.equal(select.options[2].textContent, 'Alpha Mesh');
assert.equal(navLink.textContent, 'Federation (2)');
assert.equal(select.options.some(option => option.value === 'spam.mesh'), false);
} finally {
env.cleanup();
}
});
test('initializeInstanceSelector sorts by full site names before truncating labels', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
const sharedPrefix = 'abcdefghijklmnopqrstuvwxyz123';
const fetchImpl = async () => ({
ok: true,
async json() {
return [
{ name: `${sharedPrefix}zeta suffix`, domain: 'zeta.mesh' },
{ name: `${sharedPrefix}alpha suffix`, domain: 'alpha.mesh' }
];
}
});
try {
await initializeInstanceSelector({
selectElement: select,
fetchImpl,
windowObject: env.window,
documentObject: env.document
});
assert.equal(select.options[1].value, 'alpha.mesh');
assert.equal(select.options[2].value, 'zeta.mesh');
assert.equal(select.options[1].textContent, 'abcdefghijklmnopqrstuvwxyz123...');
assert.equal(select.options[2].textContent, 'abcdefghijklmnopqrstuvwxyz123...');
} finally {
env.cleanup();
}
});
test('initializeInstanceSelector navigates to the chosen instance domain', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
@@ -290,6 +221,74 @@ test('initializeInstanceSelector updates federation navigation labels with insta
}
});
test('initializeInstanceSelector follows paginated instance responses', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
const calls = [];
const fetchImpl = async url => {
calls.push(url);
if (url === '/api/instances?limit=500') {
return {
ok: true,
headers: { get: name => (name === 'X-Next-Cursor' ? 'cursor-1' : null) },
async json() {
return [{ domain: 'alpha.mesh' }];
}
};
}
if (url === '/api/instances?limit=500&cursor=cursor-1') {
return {
ok: true,
headers: { get: () => null },
async json() {
return [{ domain: 'bravo.mesh' }];
}
};
}
throw new Error(`unexpected url ${url}`);
};
try {
await initializeInstanceSelector({
selectElement: select,
fetchImpl,
windowObject: env.window,
documentObject: env.document
});
assert.deepEqual(calls, ['/api/instances?limit=500', '/api/instances?limit=500&cursor=cursor-1']);
assert.equal(select.options.length, 3);
} finally {
env.cleanup();
}
});
test('initializeInstanceSelector handles non-ok instance responses without adding options', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
const fetchImpl = async () => ({
ok: false,
async json() {
return [{ domain: 'ignored.mesh' }];
}
});
try {
await initializeInstanceSelector({
selectElement: select,
fetchImpl,
windowObject: env.window,
documentObject: env.document
});
assert.equal(select.options.length, 1);
} finally {
env.cleanup();
}
});
test('updateFederationNavCount prefers stored labels and normalizes counts', () => {
const env = createDomEnvironment();
const navLink = env.document.createElement('a');
@@ -19,9 +19,11 @@ import assert from 'node:assert/strict';
import {
computeLocalActiveNodeStats,
fetchPaginatedCollection,
fetchActiveNodeStats,
formatActiveNodeStatsText,
normaliseActiveNodeStatsPayload,
readNextCursorHeader,
} from '../main.js';
const NOW = 1_700_000_000;
@@ -138,6 +140,35 @@ test('fetchActiveNodeStats reuses cached /api/stats response for repeated calls'
assert.deepEqual(first, second);
});
test('fetchActiveNodeStats does not reuse cache across different fetch implementations', async () => {
const callsA = [];
const callsB = [];
const fetchImplA = async (url) => {
callsA.push(url);
return {
ok: true,
async json() {
return { active_nodes: { hour: 1, day: 1, week: 1, month: 1 }, sampled: false };
},
};
};
const fetchImplB = async (url) => {
callsB.push(url);
return {
ok: true,
async json() {
return { active_nodes: { hour: 2, day: 2, week: 2, month: 2 }, sampled: false };
},
};
};
await fetchActiveNodeStats({ nodes: [], nowSeconds: NOW, fetchImpl: fetchImplA });
await fetchActiveNodeStats({ nodes: [], nowSeconds: NOW, fetchImpl: fetchImplB });
assert.equal(callsA.length, 1);
assert.equal(callsB.length, 1);
});
test('fetchActiveNodeStats falls back to local counts when stats fetch fails', async () => {
const nodes = [
{ last_heard: NOW - 120 },
@@ -208,3 +239,161 @@ test('formatActiveNodeStatsText appends sampled marker when local fallback is us
'LongFast (868MHz) — active nodes: 9/hour, 8/day, 7/week, 6/month (sampled).'
);
});
test('readNextCursorHeader reads cursor token from response headers', () => {
const response = {
headers: {
get(name) {
return name === 'X-Next-Cursor' ? 'cursor-token' : null;
},
},
};
assert.equal(readNextCursorHeader(response), 'cursor-token');
});
test('readNextCursorHeader returns null when response headers are missing', () => {
assert.equal(readNextCursorHeader(null), null);
assert.equal(readNextCursorHeader({ headers: {} }), null);
});
test('fetchPaginatedCollection follows cursor headers and merges pages', async () => {
const calls = [];
const pages = new Map([
['/api/nodes?limit=2', { items: [{ id: 'a' }, { id: 'b' }], next: 'cursor-1' }],
['/api/nodes?limit=2&cursor=cursor-1', { items: [{ id: 'c' }], next: null }],
]);
const fetchImpl = async (url) => {
calls.push(url);
const page = pages.get(url);
if (!page) {
throw new Error(`unexpected url ${url}`);
}
return {
ok: true,
headers: { get: (name) => (name === 'X-Next-Cursor' ? page.next : null) },
async json() {
return page.items;
},
};
};
const items = await fetchPaginatedCollection({
path: '/api/nodes',
limit: 2,
maxRows: 10,
fetchImpl,
});
assert.deepEqual(calls, ['/api/nodes?limit=2', '/api/nodes?limit=2&cursor=cursor-1']);
assert.deepEqual(items.map((item) => item.id), ['a', 'b', 'c']);
});
test('fetchPaginatedCollection returns empty list when path is missing', async () => {
const items = await fetchPaginatedCollection({
path: '',
limit: 10,
fetchImpl: async () => ({ ok: true, headers: { get: () => null }, json: async () => [] }),
});
assert.deepEqual(items, []);
});
test('fetchPaginatedCollection enforces maxRows and propagates params', async () => {
const calls = [];
const fetchImpl = async (url) => {
calls.push(url);
return {
ok: true,
headers: { get: () => (url.includes('cursor=') ? null : 'next-1') },
async json() {
return [{ id: 1 }, { id: 2 }, { id: 3 }];
},
};
};
const items = await fetchPaginatedCollection({
path: '/api/messages',
limit: 3,
maxRows: 4,
params: { since: '123', encrypted: 'true' },
fetchImpl,
});
assert.equal(calls[0], '/api/messages?limit=3&since=123&encrypted=true');
assert.equal(items.length, 4);
});
test('fetchPaginatedCollection throws on non-ok responses', async () => {
await assert.rejects(
fetchPaginatedCollection({
path: '/api/messages',
limit: 2,
fetchImpl: async () => ({ ok: false, status: 503, json: async () => [] }),
}),
/HTTP 503/
);
});
test('fetchPaginatedCollection throws on invalid payload shapes', async () => {
await assert.rejects(
fetchPaginatedCollection({
path: '/api/messages',
limit: 2,
fetchImpl: async () => ({ ok: true, headers: { get: () => null }, json: async () => ({}) }),
}),
/invalid paginated payload/
);
});
test('fetchPaginatedCollection ignores blank params and defaults invalid limits', async () => {
const calls = [];
const items = await fetchPaginatedCollection({
path: '/api/messages',
limit: 0,
maxRows: 0,
params: { since: ' ', encrypted: null, scope: 'recent' },
fetchImpl: async (url) => {
calls.push(url);
return {
ok: true,
headers: { get: () => null },
async json() {
return [{ id: 1 }];
},
};
},
});
assert.deepEqual(items, [{ id: 1 }]);
assert.equal(calls[0], '/api/messages?limit=200&scope=recent');
});
test('fetchPaginatedCollection stops when a page is empty even if cursor was present', async () => {
const calls = [];
const fetchImpl = async (url) => {
calls.push(url);
if (url === '/api/nodes?limit=2') {
return {
ok: true,
headers: { get: (name) => (name === 'X-Next-Cursor' ? 'cursor-1' : null) },
async json() {
return [{ id: 'a' }];
},
};
}
return {
ok: true,
headers: { get: () => null },
async json() {
return [];
},
};
};
const items = await fetchPaginatedCollection({
path: '/api/nodes',
limit: 2,
fetchImpl,
});
assert.deepEqual(items, [{ id: 'a' }]);
assert.deepEqual(calls, ['/api/nodes?limit=2', '/api/nodes?limit=2&cursor=cursor-1']);
});
@@ -1,172 +0,0 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const MAX_VISIBLE_SITE_NAME_LENGTH = 32;
const TRUNCATION_SUFFIX = '...';
const TRUNCATED_SITE_NAME_LENGTH = MAX_VISIBLE_SITE_NAME_LENGTH - TRUNCATION_SUFFIX.length;
const SUPPRESSED_SITE_NAME_PATTERN = /(?:^|[^a-z0-9])(?:https?:\/\/|www\.)\S+/i;
/**
* Read a federated instance site name as a trimmed string.
*
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
* @returns {string} Trimmed site name or an empty string when absent.
*/
function readSiteName(entry) {
if (!entry || typeof entry !== 'object') {
return '';
}
return typeof entry.name === 'string' ? entry.name.trim() : '';
}
/**
* Read a federated instance domain as a trimmed string.
*
* @param {{ domain?: string } | null | undefined} entry Federation instance payload entry.
* @returns {string} Trimmed domain or an empty string when absent.
*/
function readDomain(entry) {
if (!entry || typeof entry !== 'object') {
return '';
}
return typeof entry.domain === 'string' ? entry.domain.trim() : '';
}
/**
* Determine whether a remote site name should be suppressed from frontend displays.
*
* @param {string} name Remote site name.
* @returns {boolean} true when the name contains a URL-like advertising token.
*/
export function isSuppressedFederationSiteName(name) {
if (typeof name !== 'string') {
return false;
}
const trimmed = name.trim();
if (!trimmed) {
return false;
}
return SUPPRESSED_SITE_NAME_PATTERN.test(trimmed);
}
/**
* Truncate an instance site name for frontend display without mutating source data.
*
* Names longer than 32 characters are shortened to stay within that 32-character
* budget including the trailing ellipsis.
*
* @param {string} name Remote site name.
* @returns {string} Display-ready site name.
*/
export function truncateFederationSiteName(name) {
if (typeof name !== 'string') {
return '';
}
const trimmed = name.trim();
if (trimmed.length <= MAX_VISIBLE_SITE_NAME_LENGTH) {
return trimmed;
}
return `${trimmed.slice(0, TRUNCATED_SITE_NAME_LENGTH)}${TRUNCATION_SUFFIX}`;
}
/**
* Determine whether an instance should remain visible in frontend federation views.
*
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
* @returns {boolean} true when the entry should be shown to users.
*/
export function shouldDisplayFederationInstance(entry) {
return !isSuppressedFederationSiteName(readSiteName(entry));
}
/**
* Resolve a frontend display name for a federation instance.
*
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
* @returns {string} Display-ready site name or an empty string when absent.
*/
export function resolveFederationSiteNameForDisplay(entry) {
const siteName = readSiteName(entry);
return siteName ? truncateFederationSiteName(siteName) : '';
}
/**
* Resolve the original trimmed site name for a federation instance.
*
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
* @returns {string} Full trimmed site name or an empty string when absent.
*/
export function resolveFederationSiteName(entry) {
return readSiteName(entry);
}
/**
* Determine the full sort value for an instance selector entry.
*
* Sorting must use the original trimmed site name so truncation does not collapse
* multiple entries into the same comparison key.
*
* @param {{ name?: string, domain?: string } | null | undefined} entry Federation instance payload entry.
* @returns {string} Full trimmed site name falling back to the domain.
*/
export function resolveFederationInstanceSortValue(entry) {
const siteName = resolveFederationSiteName(entry);
return siteName || readDomain(entry);
}
/**
* Determine the most suitable display label for an instance list entry.
*
* @param {{ name?: string, domain?: string } | null | undefined} entry Federation instance payload entry.
* @returns {string} Display label falling back to the domain.
*/
export function resolveFederationInstanceLabel(entry) {
const siteName = resolveFederationSiteNameForDisplay(entry);
if (siteName) {
return siteName;
}
return readDomain(entry);
}
/**
* Filter a federation payload down to the instances that should remain visible.
*
* @param {Array<object>} entries Federation payload from the API.
* @returns {Array<object>} Visible instances for frontend rendering.
*/
export function filterDisplayableFederationInstances(entries) {
if (!Array.isArray(entries)) {
return [];
}
return entries.filter(shouldDisplayFederationInstance);
}
export const __test__ = {
MAX_VISIBLE_SITE_NAME_LENGTH,
TRUNCATION_SUFFIX,
TRUNCATED_SITE_NAME_LENGTH,
readDomain,
readSiteName,
SUPPRESSED_SITE_NAME_PATTERN
};
+57 -22
View File
@@ -15,11 +15,6 @@
*/
import { readAppConfig } from './config.js';
import {
filterDisplayableFederationInstances,
resolveFederationSiteName,
resolveFederationSiteNameForDisplay
} from './federation-instance-display.js';
import { resolveLegendVisibility } from './map-legend-visibility.js';
import { mergeConfig } from './settings.js';
import { roleColors } from './role-helpers.js';
@@ -85,6 +80,59 @@ function buildInstanceUrl(domain) {
return `https://${trimmed}`;
}
/**
* Read the next-page cursor token from response headers.
*
* @param {*} response Fetch response candidate.
* @returns {string|null} Cursor token when present.
*/
function readNextCursorHeader(response) {
const headers = response && response.headers;
if (!headers || typeof headers.get !== 'function') return null;
const cursor = headers.get('X-Next-Cursor');
return cursor && String(cursor).trim() ? String(cursor).trim() : null;
}
/**
* Fetch all federation instances using keyset cursor pagination.
*
* @param {Function} fetchImpl Fetch-compatible function.
* @returns {Promise<Array<Object>>} Combined instance rows.
*/
async function fetchAllInstances(fetchImpl) {
const results = [];
let cursor = null;
let pageCount = 0;
const limit = 500;
while (pageCount < 100) {
const query = new URLSearchParams({ limit: String(limit) });
if (cursor) {
query.set('cursor', cursor);
}
const response = await fetchImpl(`/api/instances?${query.toString()}`, {
headers: { Accept: 'application/json' },
credentials: 'omit'
});
if (!response || !response.ok || typeof response.json !== 'function') {
return results;
}
const payload = await response.json();
if (!Array.isArray(payload) || payload.length === 0) {
return results;
}
results.push(...payload);
cursor = readNextCursorHeader(response);
pageCount += 1;
if (!cursor) {
return results;
}
}
return results;
}
const NODE_COUNT_COLOR_STOPS = [
{ limit: 100, color: roleColors.CLIENT_HIDDEN },
{ limit: 200, color: roleColors.SENSOR },
@@ -279,12 +327,7 @@ export async function initializeFederationPage(options = {}) {
? true
: legendCollapsedValue.trim() !== 'false';
const tableSorters = {
name: {
getValue: inst => resolveFederationSiteName(inst),
compare: compareString,
hasValue: hasStringValue,
defaultDirection: 'asc'
},
name: { getValue: inst => inst.name ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
domain: { getValue: inst => inst.domain ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
contact: { getValue: inst => inst.contactLink ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
version: { getValue: inst => inst.version ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
@@ -373,8 +416,7 @@ export async function initializeFederationPage(options = {}) {
for (const instance of sorted) {
const tr = document.createElement('tr');
const url = buildInstanceUrl(instance.domain);
const displayName = resolveFederationSiteNameForDisplay(instance);
const nameHtml = displayName ? escapeHtml(displayName) : '<em>—</em>';
const nameHtml = instance.name ? escapeHtml(instance.name) : '<em>—</em>';
const domainHtml = url
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(instance.domain || '')}</a>`
: escapeHtml(instance.domain || '');
@@ -535,13 +577,7 @@ export async function initializeFederationPage(options = {}) {
// Fetch instances data
let instances = [];
try {
const response = await fetchImpl('/api/instances', {
headers: { Accept: 'application/json' },
credentials: 'omit'
});
if (response.ok) {
instances = filterDisplayableFederationInstances(await response.json());
}
instances = await fetchAllInstances(fetchImpl);
} catch (err) {
console.warn('Failed to fetch federation instances', err);
}
@@ -647,8 +683,7 @@ export async function initializeFederationPage(options = {}) {
bounds.push([lat, lon]);
const displayName = resolveFederationSiteNameForDisplay(instance);
const name = displayName || instance.domain || 'Unknown';
const name = instance.name || instance.domain || 'Unknown';
const url = buildInstanceUrl(instance.domain);
const nodeCountValue = toFiniteNumber(instance.nodesCount ?? instance.nodes_count);
const popupLines = [
+76 -31
View File
@@ -14,12 +14,6 @@
* limitations under the License.
*/
import {
filterDisplayableFederationInstances,
resolveFederationInstanceLabel,
resolveFederationInstanceSortValue
} from './federation-instance-display.js';
/**
* Determine the most suitable label for an instance list entry.
*
@@ -27,7 +21,17 @@ import {
* @returns {string} Preferred display label falling back to the domain.
*/
function resolveInstanceLabel(entry) {
return resolveFederationInstanceLabel(entry);
if (!entry || typeof entry !== 'object') {
return '';
}
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
if (name.length > 0) {
return name;
}
const domain = typeof entry.domain === 'string' ? entry.domain.trim() : '';
return domain;
}
/**
@@ -74,6 +78,64 @@ function updateFederationNavCount(options) {
});
}
/**
* Read the next-page cursor header from an HTTP response.
*
* @param {*} response Fetch response candidate.
* @returns {string|null} Cursor token when available.
*/
function readNextCursorHeader(response) {
const headers = response && response.headers;
if (!headers || typeof headers.get !== 'function') {
return null;
}
const cursor = headers.get('X-Next-Cursor');
return cursor && String(cursor).trim().length > 0 ? String(cursor).trim() : null;
}
/**
* Load federation instances across paginated API responses.
*
* @param {Function} fetchImpl Fetch-compatible function.
* @returns {Promise<Array<Object>>} Combined instance payload rows.
*/
async function fetchAllInstances(fetchImpl) {
const results = [];
let cursor = null;
let pageCount = 0;
const limit = 500;
while (pageCount < 100) {
const query = new URLSearchParams({ limit: String(limit) });
if (cursor) {
query.set('cursor', cursor);
}
const response = await fetchImpl(`/api/instances?${query.toString()}`, {
headers: { Accept: 'application/json' },
credentials: 'omit',
});
if (!response || typeof response.json !== 'function' || !response.ok) {
return results;
}
const payload = await response.json();
if (!Array.isArray(payload) || payload.length === 0) {
return results;
}
results.push(...payload);
cursor = readNextCursorHeader(response);
pageCount += 1;
if (!cursor) {
return results;
}
}
return results;
}
/**
* Construct a navigable URL for the provided instance domain.
*
@@ -175,48 +237,31 @@ export async function initializeInstanceSelector(options) {
return;
}
let response;
let payload;
try {
response = await fetchImpl('/api/instances', {
headers: { Accept: 'application/json' },
credentials: 'omit',
});
payload = await fetchAllInstances(fetchImpl);
} catch (error) {
console.warn('Failed to load federation instances', error);
return;
}
if (!response || typeof response.json !== 'function') {
if (!Array.isArray(payload)) {
return;
}
if (!response.ok) {
return;
}
let payload;
try {
payload = await response.json();
} catch (error) {
console.warn('Invalid federation instances payload', error);
return;
}
const visibleEntries = filterDisplayableFederationInstances(payload);
updateFederationNavCount({ documentObject: doc, count: visibleEntries.length });
updateFederationNavCount({ documentObject: doc, count: payload.length });
const sanitizedDomain = typeof instanceDomain === 'string' ? instanceDomain.trim().toLowerCase() : null;
const sortedEntries = visibleEntries
const sortedEntries = payload
.filter(entry => entry && typeof entry.domain === 'string' && entry.domain.trim() !== '')
.map(entry => ({
domain: entry.domain.trim(),
label: resolveInstanceLabel(entry),
sortLabel: resolveFederationInstanceSortValue(entry),
}))
.sort((a, b) => {
const labelA = a.sortLabel || a.domain;
const labelB = b.sortLabel || b.domain;
const labelA = a.label || a.domain;
const labelB = b.label || b.domain;
return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' });
});
+129 -21
View File
@@ -207,6 +207,83 @@ export function formatActiveNodeStatsText({ channel, frequency, stats }) {
return `${channel} (${frequency}) — active nodes: ${parts.join(', ')}${suffix}.`;
}
/**
* Parse the next-page cursor header from an API response.
*
* @param {*} response Fetch response candidate.
* @returns {string|null} Cursor token for the next page.
*/
export function readNextCursorHeader(response) {
const headers = response && response.headers;
if (!headers || typeof headers.get !== 'function') {
return null;
}
const cursor = headers.get('X-Next-Cursor');
return cursor && String(cursor).trim().length > 0 ? String(cursor).trim() : null;
}
/**
* Fetch an API collection endpoint using keyset cursor pagination.
*
* @param {{
* path: string,
* limit: number,
* maxRows?: number,
* params?: Record<string, string>,
* fetchImpl?: Function
* }} options Request options.
* @returns {Promise<Array<Object>>} Aggregated array of collection rows.
*/
export async function fetchPaginatedCollection({
path,
limit,
maxRows = 5000,
params = {},
fetchImpl = fetch
}) {
const safePath = typeof path === 'string' ? path : '';
if (!safePath) {
return [];
}
const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 200;
const safeMaxRows = Number.isFinite(maxRows) && maxRows > 0 ? Math.floor(maxRows) : safeLimit;
const results = [];
let cursor = null;
let pageCount = 0;
while (results.length < safeMaxRows && pageCount < 100) {
const query = new URLSearchParams({ limit: String(safeLimit) });
Object.entries(params || {}).forEach(([key, value]) => {
if (value == null) return;
const text = String(value).trim();
if (!text) return;
query.set(key, text);
});
if (cursor) {
query.set('cursor', cursor);
}
const response = await fetchImpl(`${safePath}?${query.toString()}`, { cache: 'no-store' });
if (!response || !response.ok) {
throw new Error('HTTP ' + (response ? response.status : 'unknown'));
}
const payload = await response.json();
if (!Array.isArray(payload)) {
throw new Error('invalid paginated payload');
}
if (payload.length === 0) {
break;
}
results.push(...payload);
cursor = readNextCursorHeader(response);
pageCount += 1;
if (!cursor) {
break;
}
}
return results.slice(0, safeMaxRows);
}
/**
* Entry point for the interactive dashboard. Wires up event listeners,
* initializes the map, and triggers the first data refresh cycle.
@@ -336,6 +413,7 @@ export function initializeApp(config) {
const TRACE_MAX_AGE_SECONDS = 28 * 24 * 60 * 60;
const SNAPSHOT_LIMIT = SNAPSHOT_WINDOW;
const CHAT_LIMIT = MESSAGE_LIMIT;
const RECENT_COLLECTION_WINDOW_SECONDS = 7 * 24 * 60 * 60;
const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
const REFRESH_MS = config.refreshMs;
const CHAT_ENABLED = Boolean(config.chatEnabled);
@@ -3605,9 +3683,14 @@ export function initializeApp(config) {
*/
async function fetchNodes(limit = NODE_LIMIT) {
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
const r = await fetch(`/api/nodes?limit=${effectiveLimit}`, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
const maxRows = Math.max(effectiveLimit, effectiveLimit * SNAPSHOT_LIMIT);
const nowSec = Math.floor(Date.now() / 1000);
return fetchPaginatedCollection({
path: '/api/nodes',
limit: effectiveLimit,
maxRows,
params: { since: String(nowSec - RECENT_COLLECTION_WINDOW_SECONDS) }
});
}
/**
@@ -3636,14 +3719,19 @@ export function initializeApp(config) {
async function fetchMessages(limit = MESSAGE_LIMIT, options = {}) {
if (!CHAT_ENABLED) return [];
const safeLimit = normaliseMessageLimit(limit);
const params = new URLSearchParams({ limit: String(safeLimit) });
const nowSec = Math.floor(Date.now() / 1000);
const params = {};
if (options && options.encrypted) {
params.set('encrypted', 'true');
params.encrypted = 'true';
}
const query = params.toString();
const r = await fetch(`/api/messages?${query}`, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
params.since = String(nowSec - CHAT_RECENT_WINDOW_SECONDS);
const maxRows = Math.max(safeLimit, safeLimit * SNAPSHOT_LIMIT);
return fetchPaginatedCollection({
path: '/api/messages',
limit: safeLimit,
maxRows,
params
});
}
/**
@@ -3654,9 +3742,14 @@ export function initializeApp(config) {
*/
async function fetchNeighbors(limit = NODE_LIMIT) {
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
const r = await fetch(`/api/neighbors?limit=${effectiveLimit}`, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
const maxRows = Math.max(effectiveLimit, effectiveLimit * SNAPSHOT_LIMIT);
const nowSec = Math.floor(Date.now() / 1000);
return fetchPaginatedCollection({
path: '/api/neighbors',
limit: effectiveLimit,
maxRows,
params: { since: String(nowSec - RECENT_COLLECTION_WINDOW_SECONDS) }
});
}
/**
@@ -3668,9 +3761,14 @@ export function initializeApp(config) {
async function fetchTraces(limit = TRACE_LIMIT) {
const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : TRACE_LIMIT;
const effectiveLimit = Math.min(safeLimit, NODE_LIMIT);
const r = await fetch(`/api/traces?limit=${effectiveLimit}`, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const traces = await r.json();
const maxRows = Math.max(effectiveLimit, effectiveLimit * SNAPSHOT_LIMIT);
const nowSec = Math.floor(Date.now() / 1000);
const traces = await fetchPaginatedCollection({
path: '/api/traces',
limit: effectiveLimit,
maxRows,
params: { since: String(nowSec - TRACE_MAX_AGE_SECONDS) }
});
return filterRecentTraces(traces, TRACE_MAX_AGE_SECONDS);
}
@@ -3682,9 +3780,14 @@ export function initializeApp(config) {
*/
async function fetchTelemetry(limit = NODE_LIMIT) {
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
const r = await fetch(`/api/telemetry?limit=${effectiveLimit}`, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
const maxRows = Math.max(effectiveLimit, effectiveLimit * SNAPSHOT_LIMIT);
const nowSec = Math.floor(Date.now() / 1000);
return fetchPaginatedCollection({
path: '/api/telemetry',
limit: effectiveLimit,
maxRows,
params: { since: String(nowSec - RECENT_COLLECTION_WINDOW_SECONDS) }
});
}
/**
@@ -3695,9 +3798,14 @@ export function initializeApp(config) {
*/
async function fetchPositions(limit = NODE_LIMIT) {
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
const r = await fetch(`/api/positions?limit=${effectiveLimit}`, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
const maxRows = Math.max(effectiveLimit, effectiveLimit * SNAPSHOT_LIMIT);
const nowSec = Math.floor(Date.now() / 1000);
return fetchPaginatedCollection({
path: '/api/positions',
limit: effectiveLimit,
maxRows,
params: { since: String(nowSec - RECENT_COLLECTION_WINDOW_SECONDS) }
});
}
/**
+425 -5
View File
@@ -80,6 +80,29 @@ RSpec.describe "Potato Mesh Sinatra app" do
File.expand_path("../../tests/#{name}", __dir__)
end
# Assert that an API collection exposes keyset pagination with a non-empty
# cursor and a non-empty second page.
#
# @param path [String] collection endpoint path.
# @param limit [Integer] page size for the test request.
# @return [Array<Array<Hash>, Array<Hash>, String]] first page, second page, and cursor.
def expect_collection_cursor_pagination(path, limit: 2)
get "#{path}?limit=#{limit}"
expect(last_response).to be_ok
first_page = JSON.parse(last_response.body)
expect(first_page.length).to eq(limit)
cursor = last_response.headers["X-Next-Cursor"]
expect(cursor).to be_a(String)
expect(cursor).not_to be_empty
get "#{path}?limit=#{limit}&cursor=#{URI.encode_www_form_component(cursor)}"
expect(last_response).to be_ok
second_page = JSON.parse(last_response.body)
expect(second_page).not_to be_empty
[first_page, second_page, cursor]
end
# Execute the provided block with a configured SQLite connection.
#
# @param readonly [Boolean] whether to open the database in read-only mode.
@@ -2644,6 +2667,196 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
it "supports cursor pagination for instance catalogs" do
clear_database
now = Time.now.to_i
with_db do |db|
upsert_instance_record(
db,
{
id: "instance-a",
domain: "alpha.mesh",
pubkey: remote_key.public_key.export,
name: "Alpha",
version: "1.0.0",
channel: "#LongFast",
frequency: "868MHz",
latitude: nil,
longitude: nil,
last_update_time: now,
is_private: false,
},
"sig-a",
)
upsert_instance_record(
db,
{
id: "instance-b",
domain: "bravo.mesh",
pubkey: remote_key.public_key.export,
name: "Bravo",
version: "1.0.0",
channel: "#LongFast",
frequency: "868MHz",
latitude: nil,
longitude: nil,
last_update_time: now,
is_private: false,
},
"sig-b",
)
upsert_instance_record(
db,
{
id: "instance-c",
domain: "charlie.mesh",
pubkey: remote_key.public_key.export,
name: "Charlie",
version: "1.0.0",
channel: "#LongFast",
frequency: "868MHz",
latitude: nil,
longitude: nil,
last_update_time: now,
is_private: false,
},
"sig-c",
)
end
get "/api/instances?limit=2"
expect(last_response).to be_ok
first_page = JSON.parse(last_response.body)
expect(first_page.length).to eq(2)
cursor = last_response.headers["X-Next-Cursor"]
expect(cursor).to be_a(String)
expect(cursor).not_to be_empty
get "/api/instances?limit=2&cursor=#{URI.encode_www_form_component(cursor)}"
expect(last_response).to be_ok
second_page = JSON.parse(last_response.body)
expect(second_page.length).to be >= 1
combined_domains = (first_page + second_page).map { |entry| entry["domain"] }
expect(combined_domains).to include("alpha.mesh", "bravo.mesh", "charlie.mesh")
end
it "continues paginating when malformed rows are skipped during normalization" do
clear_database
now = Time.now.to_i
with_db do |db|
insert_sql = <<~SQL
INSERT INTO instances (
id, domain, pubkey, name, version, channel, frequency,
latitude, longitude, last_update_time, is_private, signature
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
SQL
db.execute(
insert_sql,
["instance-a", "alpha.mesh", remote_key.public_key.export, "Alpha", "1.0.0", nil, nil, nil, nil, now, 0, "sig-a"],
)
db.execute(
insert_sql,
["instance-bad", "alpha bad mesh", remote_key.public_key.export, "Bad", "1.0.0", nil, nil, nil, nil, now, 0, "sig-bad"],
)
db.execute(
insert_sql,
["instance-b", "bravo.mesh", remote_key.public_key.export, "Bravo", "1.0.0", nil, nil, nil, nil, now, 0, "sig-b"],
)
db.execute(
insert_sql,
["instance-c", "charlie.mesh", remote_key.public_key.export, "Charlie", "1.0.0", nil, nil, nil, nil, now, 0, "sig-c"],
)
end
first_page = application_class.load_instances_for_api(limit: 2, with_pagination: true)
expect(first_page[:items].map { |entry| entry["id"] }).to eq(["instance-a", "instance-b"])
expect(first_page[:next_cursor]).to be_a(String)
expect(first_page[:next_cursor]).not_to be_empty
second_page = application_class.load_instances_for_api(
limit: 2,
cursor: first_page[:next_cursor],
with_pagination: true,
)
expect(second_page[:items].map { |entry| entry["id"] }).to include("instance-c")
end
it "continues pagination when the page-boundary marker row is malformed" do
clear_database
now = Time.now.to_i
with_db do |db|
insert_sql = <<~SQL
INSERT INTO instances (
id, domain, pubkey, name, version, channel, frequency,
latitude, longitude, last_update_time, is_private, signature
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
SQL
db.execute(insert_sql, ["instance-a", "alpha.mesh", remote_key.public_key.export, "Alpha", "1.0.0", nil, nil, nil, nil, now, 0, "sig-a"])
db.execute(insert_sql, ["instance-b", "bravo.mesh", remote_key.public_key.export, "Bravo", "1.0.0", nil, nil, nil, nil, now, 0, "sig-b"])
db.execute(insert_sql, ["instance-bad", "zzy invalid", remote_key.public_key.export, "Bad", "1.0.0", nil, nil, nil, nil, now, 0, "sig-bad"])
db.execute(insert_sql, ["instance-z", "zzz.mesh", remote_key.public_key.export, "Zulu", "1.0.0", nil, nil, nil, nil, now, 0, "sig-z"])
end
first_page = application_class.load_instances_for_api(limit: 2, with_pagination: true)
expect(first_page[:items].map { |entry| entry["id"] }).to eq(["instance-a", "instance-b"])
expect(first_page[:next_cursor]).to be_a(String)
expect(first_page[:next_cursor]).not_to be_empty
second_page = application_class.load_instances_for_api(
limit: 2,
cursor: first_page[:next_cursor],
with_pagination: true,
)
expect(second_page[:items].map { |entry| entry["id"] }).to include("instance-z")
end
it "continues scanning when a full fetched page contains only malformed rows" do
clear_database
now = Time.now.to_i
with_db do |db|
insert_sql = <<~SQL
INSERT INTO instances (
id, domain, pubkey, name, version, channel, frequency,
latitude, longitude, last_update_time, is_private, signature
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
SQL
db.execute(insert_sql, ["instance-a", "alpha.mesh", remote_key.public_key.export, "Alpha", "1.0.0", nil, nil, nil, nil, now, 0, "sig-a"])
db.execute(insert_sql, ["instance-bad-1", "bad domain 1", remote_key.public_key.export, "Bad 1", "1.0.0", nil, nil, nil, nil, now, 0, "sig-b1"])
db.execute(insert_sql, ["instance-bad-2", "bad domain 2", remote_key.public_key.export, "Bad 2", "1.0.0", nil, nil, nil, nil, now, 0, "sig-b2"])
db.execute(insert_sql, ["instance-z", "zzz.mesh", remote_key.public_key.export, "Zulu", "1.0.0", nil, nil, nil, nil, now, 0, "sig-z"])
end
first_page = application_class.load_instances_for_api(limit: 1, with_pagination: true)
expect(first_page[:items].map { |entry| entry["id"] }).to eq(["instance-a"])
expect(first_page[:next_cursor]).to be_a(String)
expect(first_page[:next_cursor]).not_to be_empty
seen_ids = first_page[:items].map { |entry| entry["id"] }
cursor = first_page[:next_cursor]
5.times do
break unless cursor
page = application_class.load_instances_for_api(
limit: 1,
cursor: cursor,
with_pagination: true,
)
seen_ids.concat(page[:items].map { |entry| entry["id"] })
cursor = page[:next_cursor]
break if seen_ids.include?("instance-z")
end
expect(seen_ids).to include("instance-z")
end
context "when federation is disabled" do
around do |example|
original = ENV["FEDERATION"]
@@ -5573,7 +5786,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(payload["node_id"]).to eq("!fresh-node")
end
it "filters node results using the since parameter for collections and single lookups" do
it "filters node collection results using since and before parameters" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
@@ -5594,8 +5807,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
get "/api/nodes?since=#{recent_last_heard}"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload.map { |row| row["node_id"] }).to eq(["!recent-node"])
since_payload = JSON.parse(last_response.body)
expect(since_payload.map { |row| row["node_id"] }).to eq(["!recent-node"])
get "/api/nodes/!older-node?since=#{recent_last_heard}"
expect(last_response.status).to eq(404)
@@ -5604,6 +5817,20 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
detail = JSON.parse(last_response.body)
expect(detail["node_id"]).to eq("!recent-node")
get "/api/nodes?before=#{older_last_heard}"
expect(last_response).to be_ok
before_payload = JSON.parse(last_response.body)
expect(before_payload.map { |row| row["node_id"] }).to eq(["!older-node"])
# Node detail route currently supports `since`, but not `before`.
get "/api/nodes/!older-node"
expect(last_response).to be_ok
expect(JSON.parse(last_response.body)["node_id"]).to eq("!older-node")
get "/api/nodes/!recent-node"
expect(last_response).to be_ok
expect(JSON.parse(last_response.body)["node_id"]).to eq("!recent-node")
end
it "omits blank values from node responses" do
@@ -5663,6 +5890,77 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(payload).not_to have_key("short_name")
expect(payload).not_to have_key("hw_model")
end
it "emits keyset cursor headers for multi-page collection responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?,?)",
["!cursor-a", 1, "a", "A", "TBEAM", "CLIENT", 0.0, now - 1, now - 1],
)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?,?)",
["!cursor-b", 2, "b", "B", "TBEAM", "CLIENT", 0.0, now - 2, now - 2],
)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?,?)",
["!cursor-c", 3, "c", "C", "TBEAM", "CLIENT", 0.0, now - 3, now - 3],
)
end
get "/api/nodes?limit=2"
expect(last_response).to be_ok
first_page = JSON.parse(last_response.body)
expect(first_page.length).to eq(2)
cursor = last_response.headers["X-Next-Cursor"]
expect(cursor).to be_a(String)
expect(cursor).not_to be_empty
get "/api/nodes?limit=2&cursor=#{URI.encode_www_form_component(cursor)}"
expect(last_response).to be_ok
second_page = JSON.parse(last_response.body)
expect(second_page.length).to eq(1)
expect((first_page + second_page).map { |row| row["node_id"] }).to eq(
["!cursor-a", "!cursor-b", "!cursor-c"],
)
end
it "keeps paginating future-dated nodes without skipping later pages" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?,?)",
["!future-a", 1, "a", "A", "TBEAM", "CLIENT", 0.0, now + 300, now - 1],
)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?,?)",
["!future-b", 2, "b", "B", "TBEAM", "CLIENT", 0.0, now + 200, now - 1],
)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?,?)",
["!future-c", 3, "c", "C", "TBEAM", "CLIENT", 0.0, now + 100, now - 1],
)
end
get "/api/nodes?limit=2"
expect(last_response).to be_ok
first_page = JSON.parse(last_response.body)
expect(first_page.map { |row| row["node_id"] }).to eq(["!future-a", "!future-b"])
cursor = last_response.headers["X-Next-Cursor"]
expect(cursor).to be_a(String)
expect(cursor).not_to be_empty
get "/api/nodes?limit=2&cursor=#{URI.encode_www_form_component(cursor)}"
expect(last_response).to be_ok
second_page = JSON.parse(last_response.body)
expect(second_page.map { |row| row["node_id"] }).to eq(["!future-c"])
end
end
describe "GET /api/stats" do
@@ -5839,7 +6137,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
it "filters messages by the since parameter while defaulting to the full history" do
it "filters messages by since and before parameters while defaulting to full history" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
@@ -5879,6 +6177,16 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
scoped = JSON.parse(last_response.body)
expect(scoped.map { |row| row["id"] }).to eq([2])
get "/api/messages?before=#{stale_rx}"
expect(last_response).to be_ok
before_payload = JSON.parse(last_response.body)
expect(before_payload.map { |row| row["id"] }).to eq([1])
get "/api/messages/!old?before=#{stale_rx}"
expect(last_response).to be_ok
scoped_payload = JSON.parse(last_response.body)
expect(scoped_payload.map { |row| row["id"] }).to eq([1])
end
end
@@ -6008,7 +6316,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(filtered.map { |row| row["id"] }).to eq([2, 1])
end
it "filters positions using the since parameter for both global and node queries" do
it "filters positions using since and before parameters for global and node queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
@@ -6037,6 +6345,57 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([11])
get "/api/positions?before=#{older_rx}"
expect(last_response).to be_ok
before_payload = JSON.parse(last_response.body)
expect(before_payload.map { |row| row["id"] }).to eq([10])
get "/api/positions/!pos-since?before=#{older_rx}"
expect(last_response).to be_ok
before_filtered = JSON.parse(last_response.body)
expect(before_filtered.map { |row| row["id"] }).to eq([10])
end
it "encodes position pagination cursors with cursor_time and advances pages" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
[31, "!pos-cursor", 201, now - 10, Time.at(now - 10).utc.iso8601, now - 10, 52.0, 13.0],
)
db.execute(
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
[32, "!pos-cursor", 201, now - 20, Time.at(now - 20).utc.iso8601, now - 20, 53.0, 14.0],
)
db.execute(
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
[33, "!pos-cursor", 201, now - 30, Time.at(now - 30).utc.iso8601, now - 30, 54.0, 15.0],
)
end
get "/api/positions?limit=2"
expect(last_response).to be_ok
first_page = JSON.parse(last_response.body)
expect(first_page.map { |row| row["id"] }).to eq([31, 32])
cursor = last_response.headers["X-Next-Cursor"]
expect(cursor).to be_a(String)
expect(cursor).not_to be_empty
cursor_payload = JSON.parse(Base64.urlsafe_decode64(cursor))
expect(cursor_payload).to have_key("cursor_time")
expect(cursor_payload).not_to have_key("rx_time")
get "/api/positions?limit=2&cursor=#{URI.encode_www_form_component(cursor)}"
expect(last_response).to be_ok
second_page = JSON.parse(last_response.body)
expect(second_page.map { |row| row["id"] }).to eq([33])
end
it "omits blank values from position responses" do
@@ -6217,6 +6576,32 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(filtered.length).to eq(1)
expect(filtered.first).not_to have_key("snr")
end
it "emits pagination cursor headers for neighbor collections" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!origin-cursor", "orig", "Origin", "TBEAM", "CLIENT", 0.0, now, now],
)
%w[!n-a !n-b !n-c].each do |neighbor_id|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
[neighbor_id, "n", "Neighbor", "TBEAM", "CLIENT", 0.0, now, now],
)
end
db.execute("INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)", ["!origin-cursor", "!n-a", 1.0, now - 10])
db.execute("INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)", ["!origin-cursor", "!n-b", 2.0, now - 20])
db.execute("INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)", ["!origin-cursor", "!n-c", 3.0, now - 30])
end
first_page, second_page, = expect_collection_cursor_pagination("/api/neighbors", limit: 2)
expect(first_page.length).to eq(2)
expect(second_page.length).to eq(1)
end
end
describe "GET /api/telemetry" do
@@ -6277,6 +6662,22 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect_same_value(second_entry["soil_temperature"], telemetry_metric(second_latest, "soil_temperature"))
end
it "emits pagination cursor headers for telemetry collections" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute("INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level) VALUES(?,?,?,?,?,?)", [70_001, "!tele-a", now - 10, Time.at(now - 10).utc.iso8601, now - 10, 50.0])
db.execute("INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level) VALUES(?,?,?,?,?,?)", [70_002, "!tele-b", now - 20, Time.at(now - 20).utc.iso8601, now - 20, 60.0])
db.execute("INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level) VALUES(?,?,?,?,?,?)", [70_003, "!tele-c", now - 30, Time.at(now - 30).utc.iso8601, now - 30, 70.0])
end
first_page, second_page, = expect_collection_cursor_pagination("/api/telemetry", limit: 2)
expect(first_page.length).to eq(2)
expect(second_page.length).to eq(1)
end
it "excludes telemetry entries older than seven days from collection queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
@@ -6652,6 +7053,17 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(earlier["elapsed_ms"]).to eq(trace_fixture.last.dig("metrics", "latency_ms"))
end
it "emits pagination cursor headers for trace collections" do
clear_database
post "/api/traces", trace_fixture.to_json, auth_headers
expect(last_response).to be_ok
first_page, second_page, = expect_collection_cursor_pagination("/api/traces", limit: 1)
expect(first_page.length).to eq(1)
expect(second_page.length).to eq(1)
expect(second_page.first["id"]).not_to eq(first_page.first["id"])
end
it "filters traces by node reference across sources" do
clear_database
post "/api/traces", trace_fixture.to_json, auth_headers
@@ -6729,6 +7141,14 @@ RSpec.describe "Potato Mesh Sinatra app" do
scoped = JSON.parse(last_response.body)
expect(scoped.map { |row| row["id"] }).to eq([60_002])
end
it "returns an empty list for non-numeric trace node references" do
clear_database
get "/api/traces/not-a-node"
expect(last_response).to be_ok
expect(JSON.parse(last_response.body)).to eq([])
end
end
describe "GET /nodes/:id" do
+4 -4
View File
@@ -61,7 +61,7 @@ RSpec.describe "Ingestor endpoints" do
node_id: "!abc12345",
start_time: now - 120,
last_seen_time: now - 60,
version: "0.5.11",
version: "0.5.10",
lora_freq: 915,
modem_preset: "LongFast",
}.merge(overrides)
@@ -133,7 +133,7 @@ RSpec.describe "Ingestor endpoints" do
with_db do |db|
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
["!fresh000", now - 100, now - 10, "0.5.11"],
["!fresh000", now - 100, now - 10, "0.5.10"],
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
@@ -141,7 +141,7 @@ RSpec.describe "Ingestor endpoints" do
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version, lora_freq, modem_preset) VALUES(?,?,?,?,?,?)",
["!rich000", now - 200, now - 100, "0.5.11", 915, "MediumFast"],
["!rich000", now - 200, now - 100, "0.5.10", 915, "MediumFast"],
)
end
@@ -173,7 +173,7 @@ RSpec.describe "Ingestor endpoints" do
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
["!new-ingestor", now - 60, now - 30, "0.5.11"],
["!new-ingestor", now - 60, now - 30, "0.5.10"],
)
end