feat(web-viewer): add flood-packet distance to the hops chart

Keeps the advert series — nodes by their closest observed path — and
adds arriving flood packets by how far they had already travelled.

The two answer different questions and, on the live mesh, disagree
usefully: nodes peak at 2-3 hops and fall away quickly, while flood
traffic peaks at 5 and holds a long tail past 16. A close-in
neighbourhood absorbing flood from well beyond it.

One series counts nodes (2.8k) and the other packets (156k), so raw
counts on a shared axis would flatten the node series into the baseline.
Both are drawn as a share of their own total, with absolute counts in
the tooltip, and padded onto one contiguous hop range so the bars line
up. They also cover different spans — 7 days of adverts against
whatever packet_stream retains — so each is labelled with its own
window instead of being presented as one period.

Watch the units. observed_paths.path_length is a BYTE count, so hops are
path_length / bytes_per_hop. packet_stream.path_len is already a HOP
count, with the byte length carried separately as path_byte_length. A
17-hop 3-byte path is path_length 51 in one table and path_len 17 in the
other. Applying either rule to the other silently rescales the axis and
the only symptom is a chart that looks a bit off, so both conventions
are now pinned by tests against the shapes real rows take.

Flood packets carry no sender identity and observed_paths holds only
adverts, so the flood series cannot be reduced to a shortest path per
node the way the advert series is. It is a per-packet distribution, and
the tooltip says so.
This commit is contained in:
agessaman
2026-07-29 22:45:21 -07:00
parent 0296c2e93e
commit 3a4d7aab33
6 changed files with 251 additions and 53 deletions
+66 -22
View File
@@ -87,6 +87,9 @@ 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
# 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.
RATIO_METRICS = frozenset({"multibyte_share"})
@@ -733,8 +736,9 @@ class DashboardStatsService:
mesh["role_mix"] = self._mix(conn, "role")
mesh["hops"] = self._hops_distribution(conn, sources)
if sources & SOURCE_OBSERVED_PATHS:
mesh["hops_histogram"] = self._hops_histogram(conn)
mesh["neighbors"] = {
window: self._count_one_hop_nodes(conn, window)
for window in NEIGHBOR_WINDOWS
@@ -795,33 +799,73 @@ class DashboardStatsService:
buckets[key] = buckets.get(key, 0) + (count or 0)
return _top_n_with_other([[name, count] for name, count in buckets.items()])
def _hops_histogram(self, conn: sqlite3.Connection) -> list[list[int]]:
"""Nodes by how many hops away their closest observed advert path was.
def _hops_distribution(self, conn: sqlite3.Connection, sources: int) -> dict[str, Any]:
"""Two views of distance: where nodes are, and where flood traffic comes from.
Derived from ``path_length / bytes_per_hop`` rather than read from
``complete_contact_tracking.hop_count``. Two reasons: path_length is a
byte count, so with multibyte encoding the raw value overstates hops by
2-3x; and the stored hop_count disagrees with the path evidence badly
enough at the low end to be unusable (see _one_hop_rows).
Beware that the two tables count paths in different units, and the
conversion is not symmetric:
* ``observed_paths.path_length`` is a BYTE count, so hops are
``path_length / bytes_per_hop`` — a 3-hop multibyte path is 6 or 9.
* ``packet_stream.path_len`` is already a HOP count, with the byte
length carried separately as ``path_byte_length``.
Dividing the second by bytes_per_hop, or failing to divide the first,
silently rescales a whole axis. Verified against live rows in
tests/test_dashboard_stats.py::TestHopConventions.
The two series also cover different spans — adverts over 7 days,
packets over whatever packet_stream retains (typically 3) — so each is
labelled with its own window rather than being presented as one period.
"""
rows = conn.execute(
"""
SELECT MIN(path_length / bytes_per_hop) AS hops
FROM observed_paths
WHERE packet_type = 'advert' AND bytes_per_hop > 0
AND last_seen >= datetime('now','localtime','-7 days')
GROUP BY public_key
"""
).fetchall()
distribution: dict[str, Any] = {"nodes": [], "flood_packets": []}
if sources & SOURCE_OBSERVED_PATHS:
distribution["nodes"] = self._bucket_hops(
conn.execute(
"""
SELECT MIN(path_length / bytes_per_hop) AS hops, COUNT(*) AS n
FROM observed_paths
WHERE packet_type = 'advert' AND bytes_per_hop > 0
AND last_seen >= datetime('now','localtime','-7 days')
GROUP BY public_key
"""
),
per_row=True,
)
if sources & SOURCE_PACKET_STREAM:
# path_len is already hops here — do NOT divide by bytes_per_hop.
distribution["flood_packets"] = self._bucket_hops(
conn.execute(
"""
SELECT path_len AS hops, COUNT(*) AS n FROM packet_stream
WHERE type = 'packet' AND route_type_name LIKE '%FLOOD'
AND path_len IS NOT NULL
GROUP BY path_len
"""
)
)
# Pad both onto one contiguous axis so the bars line up.
edges = [hop for series in distribution.values() for hop, _ in series]
if edges:
low, high = min(edges), max(edges)
for key, series in distribution.items():
counts = dict(series)
distribution[key] = [[hop, counts.get(hop, 0)] for hop in range(low, high + 1)]
return distribution
@staticmethod
def _bucket_hops(rows, per_row: bool = False) -> list[list[int]]:
"""Fold (hops, n) rows into a hop -> count mapping, dropping absurd hops."""
counts: dict[int, int] = {}
for row in rows:
hops = row["hops"]
if hops is None or not 0 <= hops <= 32:
if hops is None or not 0 <= hops <= MAX_PLOTTED_HOPS:
continue
counts[int(hops)] = counts.get(int(hops), 0) + 1
if not counts:
return []
return [[hop, counts.get(hop, 0)] for hop in range(min(counts), max(counts) + 1)]
counts[int(hops)] = counts.get(int(hops), 0) + (1 if per_row else (row["n"] or 0))
return [[hop, count] for hop, count in sorted(counts.items())]
def _count_one_hop_nodes(self, conn: sqlite3.Connection, window: str) -> int:
return conn.execute(
+68 -20
View File
@@ -367,7 +367,7 @@
// Routing, encoding, distributions
this.renderRouteMix(mesh.route_mix, coverage.packets_window_label);
this.renderMix('payload-mix', mesh.payload_mix);
this.renderHopsHistogram(mesh.hops_histogram);
this.renderHopsHistogram(mesh.hops, coverage.packets_window_label);
this.renderDoughnut('contactsEncodingChart', 'contacts-encoding-summary',
(mesh.encoding || {}).contacts_7d, 'contacts');
this.renderDoughnut('packetsEncodingChart', 'packets-encoding-summary',
@@ -501,39 +501,78 @@
);
}
renderHopsHistogram(points) {
/**
* Hop distance, two ways: where nodes sit, and where flood traffic
* comes from.
*
* The series count different things 2.8k nodes against 74k packets
* so plotting raw counts together would flatten the node series into
* the axis. Both are shown as a share of their own total, which is what
* makes the two shapes comparable; absolute counts stay in the tooltip.
*/
renderHopsHistogram(hops, packetWindowLabel) {
const canvasId = 'hopsHistogramChart';
const canvas = el(canvasId);
if (!canvas || typeof Chart === 'undefined') return;
const bins = Array.isArray(points) ? points : [];
const nodes = Array.isArray((hops || {}).nodes) ? hops.nodes : [];
const flood = Array.isArray((hops || {}).flood_packets) ? hops.flood_packets : [];
const colors = themeColors();
if (this.charts[canvasId]) {
this.charts[canvasId].destroy();
delete this.charts[canvasId];
}
if (bins.length === 0) return;
if (nodes.length === 0 && flood.length === 0) return;
const labels = (nodes.length ? nodes : flood).map((b) => b[0]);
const share = (series) => {
const total = series.reduce((sum, b) => sum + b[1], 0);
return { total, values: series.map((b) => (total ? (b[1] / total) * 100 : 0)) };
};
const nodeShare = share(nodes);
const floodShare = share(flood);
const datasets = [];
if (nodes.length) {
datasets.push({
label: 'Nodes (adverts, 7d)',
data: nodeShare.values,
counts: nodes.map((b) => b[1]),
unit: 'nodes',
backgroundColor: COLOR.accent,
borderRadius: 3,
});
}
if (flood.length) {
datasets.push({
label: 'Flood packets (' + (packetWindowLabel || 'retained') + ')',
data: floodShare.values,
counts: flood.map((b) => b[1]),
unit: 'packets',
backgroundColor: COLOR.flood,
borderRadius: 3,
});
}
this.charts[canvasId] = new Chart(canvas.getContext('2d'), {
type: 'bar',
data: {
labels: bins.map((b) => b[0]),
datasets: [{
label: 'Nodes',
data: bins.map((b) => b[1]),
backgroundColor: COLOR.accent,
borderRadius: 3,
}],
},
data: { labels, datasets },
options: noAnimation({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
legend: {
position: 'bottom',
labels: { color: colors.muted, boxWidth: 12, font: { size: 10 } },
},
tooltip: {
callbacks: {
title: (items) => items[0].label + ' hops away',
label: (ctx) => formatNumber(ctx.parsed.y) + ' nodes',
label: (ctx) => {
const count = ctx.dataset.counts[ctx.dataIndex];
return ctx.dataset.label + ': ' + formatNumber(count) + ' ' +
ctx.dataset.unit + ' (' + ctx.parsed.y.toFixed(1) + '%)';
},
},
},
},
@@ -546,7 +585,13 @@
},
y: {
beginAtZero: true,
ticks: { color: colors.muted, font: { size: 10 }, precision: 0 },
title: { display: true, text: '% of series', color: colors.muted,
font: { size: 10 } },
ticks: {
color: colors.muted,
font: { size: 10 },
callback: (v) => v + '%',
},
grid: { color: colors.grid },
},
},
@@ -1091,10 +1136,13 @@
'the last hop into this radio rather than the link to whoever sent it, so the ' +
'rest are left blank instead of borrowing another link\'s number.',
'hops-info':
'Nodes by the fewest hops any of their adverts took to reach this radio, over the ' +
'last 7 days. Hops are derived as path length divided by bytes per hop: path ' +
'length is a byte count, so with 2- or 3-byte hop encoding the raw value would ' +
'overstate the distance by two to three times.',
'Two distributions on one axis. Nodes: the fewest hops any of a node\'s adverts ' +
'took to reach this radio, over 7 days. Flood packets: how far each flood packet ' +
'had already travelled when it arrived, over whatever the packet stream retains ' +
'(typically 3 days). Because one counts nodes and the other counts packets, both ' +
'are drawn as a share of their own total; the tooltip gives the raw counts. ' +
'Flood packets carry no sender identity, so they cannot be reduced to a shortest ' +
'path per node the way adverts can.',
};
function initTooltips() {
+4 -2
View File
@@ -139,10 +139,12 @@
</div>
<div class="card-body">
<p class="dashboard-note mb-2">
Nodes by their closest advert path, last 7 days
Nodes by their closest advert path, and how far arriving
flood packets had travelled
</p>
<div class="chart-box chart-box--tall">
<canvas id="hopsHistogramChart" aria-label="Nodes by hop distance"></canvas>
<canvas id="hopsHistogramChart"
aria-label="Nodes and flood packets by hop distance"></canvas>
</div>
</div>
</div>