Files
2026-06-24 13:02:50 +02:00

494 lines
22 KiB
Ruby

# 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.
# frozen_string_literal: true
module PotatoMesh
module App
module Queries
def node_reference_tokens(node_ref)
parts = canonical_node_parts(node_ref)
canonical_id, numeric_id = parts ? parts[0, 2] : [nil, nil]
string_values = []
numeric_values = []
case node_ref
when Integer
numeric_values << node_ref
string_values << node_ref.to_s
when Numeric
coerced = node_ref.to_i
numeric_values << coerced
string_values << coerced.to_s
when String
trimmed = node_ref.strip
unless trimmed.empty?
string_values << trimmed
numeric_values << trimmed.to_i if trimmed.match?(/\A-?\d+\z/)
end
when nil
# no-op
else
coerced = node_ref.to_s.strip
string_values << coerced unless coerced.empty?
end
if canonical_id
string_values << canonical_id
string_values << canonical_id.upcase
end
if numeric_id
numeric_values << numeric_id
string_values << numeric_id.to_s
end
cleaned_strings = string_values.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq
cleaned_numbers = numeric_values.compact.map do |value|
begin
value.is_a?(String) ? Integer(value, 10) : Integer(value)
rescue ArgumentError, TypeError
nil
end
end.compact.uniq
{
string_values: cleaned_strings,
numeric_values: cleaned_numbers,
}
end
# Build a WHERE clause fragment for looking up a node across one or more
# columns. When +numeric_columns+ are provided together with an open +db+
# handle the numeric identifiers are resolved to canonical +node_id+
# strings up-front so the resulting SQL uses only string-column +IN+
# predicates. This avoids an +OR+ across heterogeneous columns which
# prevents SQLite from choosing the optimal index.
#
# @param node_ref [String, Integer, nil] raw node reference from the request.
# @param string_columns [Array<String>] SQL column names holding string identifiers.
# @param numeric_columns [Array<String>] SQL column names holding numeric identifiers.
# @param db [SQLite3::Database, nil] open database handle used to resolve
# numeric IDs to canonical strings. When provided and +numeric_columns+
# is non-empty the numeric branch is folded into the string branch.
# @return [Array(String, Array), nil] SQL fragment and bind parameters, or
# +nil+ when no lookup can be constructed.
def node_lookup_clause(node_ref, string_columns:, numeric_columns: [], db: nil)
tokens = node_reference_tokens(node_ref)
string_values = tokens[:string_values]
numeric_values = tokens[:numeric_values]
# When a database handle is available, resolve numeric identifiers to
# canonical node_id strings so the query can use a single indexed column
# instead of an OR across string and numeric columns.
if db && !numeric_columns.empty? && !numeric_values.empty?
numeric_values.each do |num|
resolved = db.get_first_value("SELECT node_id FROM nodes WHERE num = ? LIMIT 1", [num])
if resolved
string_values << resolved unless string_values.include?(resolved)
end
end
# All numeric values have been folded into string_values; drop the
# numeric branch so the generated SQL avoids an OR.
numeric_columns = []
numeric_values = []
end
clauses = []
params = []
unless string_columns.empty? || string_values.empty?
string_columns.each do |column|
placeholders = Array.new(string_values.length, "?").join(", ")
clauses << "#{column} IN (#{placeholders})"
params.concat(string_values)
end
end
unless numeric_columns.empty? || numeric_values.empty?
numeric_columns.each do |column|
placeholders = Array.new(numeric_values.length, "?").join(", ")
clauses << "#{column} IN (#{placeholders})"
params.concat(numeric_values)
end
end
return nil if clauses.empty?
["(#{clauses.join(" OR ")})", params]
end
# Fetch node state optionally scoped by identifier and timestamp.
#
# @param limit [Integer] maximum number of rows to return.
# @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.
# @param before [Integer, nil] inclusive upper-bound +last_heard+ cursor for
# backward pagination (SPEC BP1); nodes newer than this are excluded.
# @return [Array<Hash>] compacted node rows suitable for API responses.
def query_nodes(limit, node_ref: nil, since: 0, before: nil, protocol: nil)
limit = coerce_query_limit(limit)
db = open_database(readonly: true)
db.results_as_hash = true
now = Time.now.to_i
# Bulk listings stay on the seven-day window so the dashboard does not
# render stale nodes; per-id lookups widen to twenty-eight days so
# callers can backfill older records that fall outside the bulk floor.
since_floor = node_ref ? now - PotatoMesh::Config.four_weeks_seconds : now - PotatoMesh::Config.week_seconds
since_threshold = normalize_since_threshold(since, floor: since_floor)
params = []
where_clauses = []
if node_ref
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["num"], db: db)
return [] unless clause
where_clauses << clause.first
params.concat(clause.last)
else
where_clauses << "last_heard >= ?"
params << since_threshold
end
# Inclusive upper-bound cursor for backward pagination (SPEC BP1-BP3).
# Bounds the +last_heard+ sort column so callers can page newest ->
# oldest past the +MAX_QUERY_LIMIT+ cap; only ever narrows (BP2). The
# per-id route never supplies +before+, so single-node lookups are
# unaffected.
append_before_filter(where_clauses, params, before, column: "last_heard")
if private_mode?
where_clauses << "(role IS NULL OR role <> 'CLIENT_HIDDEN')"
end
append_opt_out_filter(where_clauses, params, opt_out_self_filter)
append_protocol_filter(where_clauses, params, protocol)
sql = <<~SQL
SELECT node_id, short_name, long_name, hw_model, role, snr,
battery_level, voltage, last_heard, first_heard,
uptime_seconds, channel_utilization, air_util_tx,
position_time, location_source, precision_bits,
latitude, longitude, altitude, lora_freq, modem_preset, protocol
FROM nodes
SQL
sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any?
sql += <<~SQL
ORDER BY last_heard DESC
LIMIT ?
SQL
params << limit
rows = db.execute(sql, params)
rows = rows.select do |r|
last_candidate = [r["last_heard"], r["position_time"], r["first_heard"]]
.map { |value| coerce_integer(value) }
.compact
.max
last_candidate && last_candidate >= since_threshold
end
rows.each do |r|
r["role"] ||= "CLIENT"
if r["role"] == "COMPANION"
derived = meshcore_companion_display_short_name(r["long_name"])
if derived
r["short_name"] = derived
elsif r["short_name"].nil? || r["short_name"].strip.empty?
# No derived name and no stored public-key hex — synthesise from
# the node ID (first four hex chars after the leading "!") so the
# badge is stable, unique, and consistent with how the ingestor
# builds short names from public keys.
node_id = r["node_id"].to_s.delete_prefix("!")
r["short_name"] = node_id[0, 4] unless node_id.empty?
end
end
lh = coerce_positive_or_nil(r["last_heard"])
pt = coerce_positive_or_nil(r["position_time"], ceiling: now)
lh = now if lh && lh > now
r["last_heard"] = lh
r["position_time"] = pt
r["last_seen_iso"] = Time.at(lh).utc.iso8601 if lh
# I2: position_time (unix int) is the sole position-time key; the
# redundant ISO twin (pos_time_iso / position_time_iso) is not emitted.
pb = r["precision_bits"]
r["precision_bits"] = pb.to_i if pb
end
rows.map { |row| compact_api_row(row) }
ensure
db&.close
end
# Fetch ingestor heartbeats with optional freshness filtering.
#
# @param limit [Integer] maximum number of ingestors to return.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
# @param before [Integer, nil] inclusive upper-bound +last_seen_time+ cursor
# for backward pagination (SPEC BP1); ingestors newer than this are excluded.
# @return [Array<Hash>] compacted ingestor rows suitable for API responses.
def query_ingestors(limit, since: 0, before: nil, protocol: nil)
limit = coerce_query_limit(limit)
db = open_database(readonly: true)
db.results_as_hash = true
now = Time.now.to_i
# Ingestor heartbeats are sparse (one per ingestor per cycle) so widen
# the rolling window to twenty-eight days to keep slow-tick ingestors
# visible in the federation overview.
cutoff = now - PotatoMesh::Config.four_weeks_seconds
since_threshold = normalize_since_threshold(since, floor: cutoff)
where_clauses = ["last_seen_time >= ?"]
params = [since_threshold]
# Inclusive upper-bound cursor for backward pagination (SPEC BP1);
# bounds the +last_seen_time+ sort column.
append_before_filter(where_clauses, params, before, column: "last_seen_time")
append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("node_id"))
append_protocol_filter(where_clauses, params, protocol)
sql = <<~SQL
SELECT node_id, start_time, last_seen_time, version, lora_freq, modem_preset, protocol
FROM ingestors
WHERE #{where_clauses.join(" AND ")}
ORDER BY last_seen_time DESC
LIMIT ?
SQL
params << limit
rows = db.execute(sql, params)
rows.each do |row|
row.delete_if { |key, _| key.is_a?(Integer) }
start_time = coerce_integer(row["start_time"])
last_seen_time = coerce_integer(row["last_seen_time"])
start_time = now if start_time && start_time > now
last_seen_time = now if last_seen_time && last_seen_time > now
if start_time && last_seen_time && last_seen_time < start_time
last_seen_time = start_time
end
row["start_time"] = start_time
row["last_seen_time"] = last_seen_time
row["start_time_iso"] = Time.at(start_time).utc.iso8601 if start_time
row["last_seen_iso"] = Time.at(last_seen_time).utc.iso8601 if last_seen_time
end
rows.map { |row| compact_api_row(row) }
ensure
db&.close
end
# Activity windows shared by every /api/stats metric, in a fixed order so
# generated column aliases (+total_hour+, +mc_day+, …) line up with their
# bind parameters.
STATS_WINDOWS = %w[hour day week month].freeze
# Per-protocol scopes counted alongside the unfiltered +total+, paired with
# the short column-alias prefix used in the generated SQL.
STATS_PROTOCOL_SCOPES = [["meshcore", "mc"], ["meshtastic", "mt"]].freeze
# Return exact activity counts for /api/stats as a scope → metric → window
# tree.
#
# The shape is:
#
# { "total" => { "nodes" => {...}, "messages" => {...}, "telemetry" => {...} },
# "meshcore" => { ... }, "meshtastic" => { ... },
# "reticulum" => { ... all zero (stub) } }
#
# where each metric maps to a +{ "hour", "day", "week", "month" }+ window
# hash. +total+ counts every visible row regardless of protocol; the
# protocol scopes are +WHERE protocol = ?+ subsets, so
# +total ≥ Σ named protocols+. Counts are resolved directly in SQL with
# COUNT(*) thresholds (no sampling bias from list-endpoint limits) and honor
# the node opt-out marker on every metric. +messages+ counts are forced to
# zero in private mode (SPEC S5 / Invariant II).
#
# @param now [Integer] reference unix timestamp in seconds.
# @param db [SQLite3::Database, nil] optional open database handle to reuse.
# @return [Hash{String => Hash}] scope → metric → window count tree.
def query_active_node_stats(now: Time.now.to_i, db: nil)
handle = db || open_database(readonly: true)
handle.results_as_hash = true
reference_now = coerce_integer(now) || Time.now.to_i
# The "month" bucket reuses the four-week cap so no stats endpoint can
# surface activity from beyond the 28-day API visibility floor.
cutoffs = {
"hour" => reference_now - 3600,
"day" => reference_now - 86_400,
"week" => reference_now - PotatoMesh::Config.week_seconds,
"month" => reference_now - PotatoMesh::Config.four_weeks_seconds,
}
metrics = {
"nodes" => node_activity_counts(handle, cutoffs),
"messages" => message_activity_counts(handle, cutoffs),
"telemetry" => telemetry_activity_counts(handle, cutoffs),
}
assemble_stats_scopes(metrics)
ensure
handle&.close unless db
end
# Per-protocol node-activity counts keyed on +nodes.last_heard+, honoring
# the opt-out self-filter and, in private mode, the CLIENT_HIDDEN exclusion.
#
# @param handle [SQLite3::Database] open database handle.
# @param cutoffs [Hash{String => Integer}] window => lower-bound timestamp.
# @return [Hash{String => Hash}] scope => window counts.
def node_activity_counts(handle, cutoffs)
private_clause = private_mode? ? " AND (role IS NULL OR role <> 'CLIENT_HIDDEN')" : ""
projection = "SELECT last_heard AS t, protocol AS p FROM nodes " \
"WHERE #{opt_out_self_filter}#{private_clause}"
windowed_protocol_counts(
handle,
projection_sql: projection,
projection_params: opt_out_marker_params,
cutoffs: cutoffs,
)
end
# Per-protocol message-activity counts keyed on +messages.rx_time+. Privacy
# mode forces every count to zero, mirroring the +PRIVATE=1+ message-API 404
# so /api/stats never leaks message volume that privacy hides
# (SPEC S5 / Invariant II).
#
# @param handle [SQLite3::Database] open database handle.
# @param cutoffs [Hash{String => Integer}] window => lower-bound timestamp.
# @return [Hash{String => Hash}] scope => window counts.
def message_activity_counts(handle, cutoffs)
return zero_scope_counts if private_mode?
fragments = [opt_out_node_id_filter("from_id"), opt_out_node_id_filter("to_id")]
projection = "SELECT rx_time AS t, protocol AS p FROM messages " \
"WHERE #{fragments.join(" AND ")}"
windowed_protocol_counts(
handle,
projection_sql: projection,
projection_params: opt_out_marker_params * fragments.length,
cutoffs: cutoffs,
)
end
# Per-protocol telemetry-activity counts. "Telemetry" is the umbrella over
# every non-message packet record — positions + telemetry + neighbors +
# traces — unioned on +(rx_time, protocol)+, each table honoring the same
# opt-out filter its list endpoint applies (SPEC S3).
#
# @param handle [SQLite3::Database] open database handle.
# @param cutoffs [Hash{String => Integer}] window => lower-bound timestamp.
# @return [Hash{String => Hash}] scope => window counts.
def telemetry_activity_counts(handle, cutoffs)
sources = [
["positions", [opt_out_node_id_filter("node_id")]],
["telemetry", [opt_out_node_id_filter("node_id")]],
["neighbors", [opt_out_node_id_filter("node_id"), opt_out_node_id_filter("neighbor_id")]],
["traces", [opt_out_node_num_filter("src"), opt_out_node_num_filter("dest")]],
]
projections = []
params = []
sources.each do |table, fragments|
projections << "SELECT rx_time AS t, protocol AS p FROM #{table} WHERE #{fragments.join(" AND ")}"
params.concat(opt_out_marker_params * fragments.length)
end
windowed_protocol_counts(
handle,
projection_sql: projections.join("\nUNION ALL\n"),
projection_params: params,
cutoffs: cutoffs,
)
end
# Count visible rows from a +(t, p)+ projection across every window, as an
# unfiltered +total+ plus one subset per protocol scope. The projection is
# materialised in a CTE so its opt-out predicate is evaluated once; each
# COUNT then runs over the pre-filtered set.
#
# @param handle [SQLite3::Database] open database handle.
# @param projection_sql [String] SELECT yielding +t+ (activity time) and
# +p+ (protocol) columns for the visible rows of one metric.
# @param projection_params [Array] bind parameters for +projection_sql+.
# @param cutoffs [Hash{String => Integer}] window => lower-bound timestamp.
# @return [Hash{String => Hash}] +total+/+meshcore+/+meshtastic+ => window
# counts.
def windowed_protocol_counts(handle, projection_sql:, projection_params:, cutoffs:)
selects = []
window_params = []
STATS_WINDOWS.each do |window|
selects << "(SELECT COUNT(*) FROM visible WHERE t >= ?) AS total_#{window}"
window_params << cutoffs.fetch(window)
end
STATS_PROTOCOL_SCOPES.each do |protocol, prefix|
STATS_WINDOWS.each do |window|
selects << "(SELECT COUNT(*) FROM visible WHERE t >= ? AND p = ?) AS #{prefix}_#{window}"
window_params << cutoffs.fetch(window)
window_params << protocol
end
end
sql = "WITH visible AS (#{projection_sql})\nSELECT #{selects.join(",\n ")}"
row = with_busy_retry { handle.get_first_row(sql, projection_params + window_params) } || {}
scopes = { "total" => stats_window_hash(row, "total") }
STATS_PROTOCOL_SCOPES.each { |protocol, prefix| scopes[protocol] = stats_window_hash(row, prefix) }
scopes
end
# Extract one alias prefix's window-count hash from a result row.
#
# @param row [Hash] row returned by {windowed_protocol_counts}.
# @param prefix [String] column-alias prefix (+total+, +mc+, +mt+).
# @return [Hash{String => Integer}] window => count.
def stats_window_hash(row, prefix)
STATS_WINDOWS.each_with_object({}) do |window, acc|
acc[window] = row["#{prefix}_#{window}"].to_i
end
end
# Transpose metric → scope counts into the scope → metric tree returned by
# /api/stats and append the always-zero +reticulum+ stub.
#
# @param metrics [Hash{String => Hash}] metric => (scope => window counts).
# @return [Hash{String => Hash}] scope => (metric => window counts).
def assemble_stats_scopes(metrics)
scopes = {}
(["total"] + STATS_PROTOCOL_SCOPES.map(&:first)).each do |scope|
scopes[scope] = metrics.transform_values { |by_scope| by_scope[scope] }
end
# reticulum is a forward-looking stub: PotatoMesh has no Reticulum
# ingestor yet, so every count is zero. Emitting the scope now lets the
# response shape absorb the protocol later without another breaking change
# (SPEC S6).
scopes["reticulum"] = metrics.keys.each_with_object({}) do |metric, acc|
acc[metric] = zero_window_counts
end
scopes
end
# A fresh zero-filled window hash (+{ "hour" => 0, … }+), returned by value
# so callers never alias a shared mutable hash.
#
# @return [Hash{String => Integer}] zeroed window counts.
def zero_window_counts
STATS_WINDOWS.each_with_object({}) { |window, acc| acc[window] = 0 }
end
# A scope → window hash with every count zero (total + each protocol). Used
# for metrics suppressed by privacy mode.
#
# @return [Hash{String => Hash}] zeroed per-scope window counts.
def zero_scope_counts
scopes = { "total" => zero_window_counts }
STATS_PROTOCOL_SCOPES.each { |protocol, _| scopes[protocol] = zero_window_counts }
scopes
end
end
end
end