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}