mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-22 02:19:48 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user