fix(web-viewer): plot hop distance to the full 64-hop protocol range

MeshCore carries up to a 64-byte path, so a route can be 64 hops long at
one byte per hop — and proportionally fewer with 2- or 3-byte hashes,
which is where the observed ceilings of 32 and 21 come from.

The chart capped at 32. That number was inherited from the old
dashboard's `BETWEEN 0 AND 32` filters and carried forward without
checking it against the protocol. On the live database it silently
discarded 5,654 flood packets arriving from as far as 63 hops.

Worse for the node series: the cap runs after the per-node MIN(), so a
node whose *closest* route exceeded 32 hops disappeared from the chart
altogether instead of appearing at the far end. The live history holds
one such node, reachable only by a 48-hop path.

The axis can now span 64 grouped categories, so bars drop their rounded
corners and padding above 24 buckets and the x ticks auto-skip. 64 is
kept as a hard ceiling: beyond it the path field could not have held the
route, so the value is corrupt rather than distant.
This commit is contained in:
agessaman committed 2026-07-29 22:56:06 -07:00
1 parent 3a4d7aab33
commit d5170606c1
4 files changed
+81 -7

No files matched your search

+7
View File
@@ -54,6 +54,13 @@ semantic versioning.
silently rescales an axis; both are pinned by tests. This replaces the
earlier raw path-length chart, which read bytes as hops, and the chart built
on the untrustworthy stored hop count.
The chart plots the full protocol range: a 64-byte path is 64 hops at one
byte per hop. The old dashboard's `BETWEEN 0 AND 32` filters, carried forward
at first, discarded 5,654 flood packets arriving from as far as 63 hops, and
because that limit applies after the per-node minimum it would erase a node
whose closest path was longer than 32 hops rather than plotting it at the far
end.
- New `[Web_Viewer]` settings: `dashboard_snapshot_enabled`,
`dashboard_snapshot_interval_seconds`, `dashboard_snapshot_history_days`, and
`dashboard_packet_backfill_rows`.
+11 -2
View File
@@ -87,8 +87,17 @@ SUMMARY_SERIES_POINTS = 30
# Categories to show in a role/payload mix before the tail is rolled into "Other".
MIX_ROWS = 8
# Hop counts beyond this are corrupt path data rather than real distance.
MAX_PLOTTED_HOPS = 32
# MeshCore carries up to a 64-byte path, so a route can be 64 hops long at one
# byte per hop (and proportionally fewer with 2- or 3-byte hashes: 32 and 21).
# Beyond that the path field could not have held it, so the value is corrupt.
#
# Not a display convenience. The earlier limit of 32 was inherited from the old
# dashboard's `BETWEEN 0 AND 32` filters and silently discarded 5,654 flood
# packets on the live database — genuine traffic arriving from as far as 63
# hops. It also applies after the per-node MIN(), so any node whose *closest*
# path exceeded 32 hops vanished from the chart entirely rather than appearing
# at the far end; one such node exists in the live history.
MAX_PLOTTED_HOPS = 64
# Metrics that are already a ratio: a period "total" has to be the mean of the
# daily values, not their sum — adding percentages together means nothing.
+17 -3
View File
@@ -532,6 +532,10 @@
const nodeShare = share(nodes);
const floodShare = share(flood);
// A path can be 64 hops at one byte per hop, so the axis may carry
// 64 grouped categories. Rounded corners and default bar padding
// both disappear at that width, so drop them and let the bars touch.
const dense = labels.length > 24;
const datasets = [];
if (nodes.length) {
datasets.push({
@@ -540,7 +544,9 @@
counts: nodes.map((b) => b[1]),
unit: 'nodes',
backgroundColor: COLOR.accent,
borderRadius: 3,
borderRadius: dense ? 0 : 3,
barPercentage: dense ? 1 : 0.9,
categoryPercentage: dense ? 1 : 0.8,
});
}
if (flood.length) {
@@ -550,7 +556,9 @@
counts: flood.map((b) => b[1]),
unit: 'packets',
backgroundColor: COLOR.flood,
borderRadius: 3,
borderRadius: dense ? 0 : 3,
barPercentage: dense ? 1 : 0.9,
categoryPercentage: dense ? 1 : 0.8,
});
}
@@ -580,7 +588,13 @@
x: {
title: { display: true, text: 'Hops away', color: colors.muted,
font: { size: 10 } },
ticks: { color: colors.muted, font: { size: 10 } },
ticks: {
color: colors.muted,
font: { size: 10 },
autoSkip: true,
maxTicksLimit: 12,
maxRotation: 0,
},
grid: { display: false },
},
y: {
+46 -2
View File
@@ -781,12 +781,56 @@ class TestHopConventions:
assert dict(hops["nodes"]) == {1: 1, 2: 0, 3: 0, 4: 0}
assert dict(hops["flood_packets"]) == {1: 0, 2: 0, 3: 0, 4: 1}
def test_absurd_hop_counts_are_dropped(self, viewer):
def test_long_paths_are_kept_up_to_the_protocol_ceiling(self, viewer):
"""64 hops fit in a 64-byte path at one byte per hop; all of it is real.
An earlier limit of 32 quietly dropped 5,654 flood packets on the live
database, and — because the filter runs after the per-node MIN() — would
erase any node whose closest path was longer than 32 hops rather than
placing it at the far end of the axis.
"""
with sqlite3.connect(viewer.db_path) as conn:
conn.executemany(
"INSERT INTO packet_stream (timestamp, data, type, route_type_name, path_len, "
"bytes_per_hop) VALUES (?, '{}', 'packet', 'FLOOD', ?, 1)",
[(time.time(), 3), (time.time(), 900), (time.time(), -1)],
[(time.time(), hops) for hops in (33, 47, 63, 64)],
)
conn.execute(
"""
INSERT INTO observed_paths (public_key, from_prefix, to_prefix, path_hex,
path_length, bytes_per_hop, packet_type, last_seen)
VALUES (?, 'aa', 'bb', 'ab', 63, 1, 'advert', datetime('now','localtime'))
""",
(_pk(1),),
)
hops = self._hops(viewer)
assert dict(hops["flood_packets"])[63] == 1
assert dict(hops["flood_packets"])[64] == 1
assert sum(c for _, c in hops["flood_packets"]) == 4
assert dict(hops["nodes"])[63] == 1
def test_a_node_reachable_only_by_a_long_path_still_appears(self, viewer):
"""The cap runs after the per-node MIN(), so it can erase a node outright."""
with sqlite3.connect(viewer.db_path) as conn:
conn.executemany(
"""
INSERT INTO observed_paths (public_key, from_prefix, to_prefix, path_hex,
path_length, bytes_per_hop, packet_type, last_seen)
VALUES (?, 'aa', 'bb', ?, ?, 1, 'advert', datetime('now','localtime'))
""",
# Distinct path_hex: observed_paths dedups on (public_key,
# path_hex, packet_type), so two routes to one node need two.
[(_pk(1), "ab" * 48, 48), (_pk(1), "cd" * 55, 55)],
)
assert dict(self._hops(viewer)["nodes"]) == {48: 1}
def test_impossible_hop_counts_are_dropped(self, viewer):
"""Beyond 64 the path field cannot hold it, so the value is corrupt."""
with sqlite3.connect(viewer.db_path) as conn:
conn.executemany(
"INSERT INTO packet_stream (timestamp, data, type, route_type_name, path_len, "
"bytes_per_hop) VALUES (?, '{}', 'packet', 'FLOOD', ?, 1)",
[(time.time(), 3), (time.time(), 65), (time.time(), 900), (time.time(), -1)],
)
assert dict(self._hops(viewer)["flood_packets"]) == {3: 1}