From d5170606c18e1bdb091fbee461f93ab9c8ddd53f Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 29 Jul 2026 22:56:06 -0700 Subject: [PATCH] fix(web-viewer): plot hop distance to the full 64-hop protocol range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 7 ++++ modules/web_viewer/dashboard_stats.py | 13 +++++- modules/web_viewer/static/js/dashboard.js | 20 ++++++++-- tests/test_dashboard_stats.py | 48 ++++++++++++++++++++++- 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78cfcdd..66d242c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/modules/web_viewer/dashboard_stats.py b/modules/web_viewer/dashboard_stats.py index 2fdb88f..fbec82f 100644 --- a/modules/web_viewer/dashboard_stats.py +++ b/modules/web_viewer/dashboard_stats.py @@ -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. diff --git a/modules/web_viewer/static/js/dashboard.js b/modules/web_viewer/static/js/dashboard.js index ece14f2..09f2fbb 100644 --- a/modules/web_viewer/static/js/dashboard.js +++ b/modules/web_viewer/static/js/dashboard.js @@ -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: { diff --git a/tests/test_dashboard_stats.py b/tests/test_dashboard_stats.py index 2726c83..1eca7b1 100644 --- a/tests/test_dashboard_stats.py +++ b/tests/test_dashboard_stats.py @@ -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}