From f68da5287c94b33540bb32adb2d2d2a21ad38a56 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 29 Jul 2026 22:23:43 -0700 Subject: [PATCH] fix(web-viewer): derive neighbours and hop distance from path evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 307 direct neighbours was not plausible, and it was not real. complete_contact_tracking.hop_count claims 800 zero-hop contacts. Only 68 of them have any one-hop path in observed_paths to corroborate that. Their stored SNR piles up in a 1.5 dB band — 655 of 800 between 11.25 and 12.75 dB — and their RSSI clusters at -39..-48 dBm. Hundreds of radios at different distances and terrain cannot land in a 10 dB window. That is the signature of one strong local link being recorded against every node whose traffic happened to arrive through it. Their return paths agree: these "direct" contacts have out_path_len of 3 to 11. The writer's intent is sound — repeater_manager only stores RSSI/SNR when signal_info reports hops == 0 — so the field being fed to it does not mean what the surrounding code assumes. Left as is; this change stops the dashboard depending on it. Neighbour membership now comes from path evidence: an advert whose path_length equals its bytes_per_hop travelled exactly one hop. That yields 38 nodes in 24h and 124 in 7d, with a plausible spread. Signal is shown only where the path evidence and the stored hop count agree, which is 5 and 12 nodes respectively; the rest read "no signal reading" rather than borrowing a measurement taken on somebody else's link. A 24h/7d selector bounds the window, capped well under observed_paths' 90-day retention because a month-old link says nothing about today. Separately, this fixes a bug I introduced. path_length is a BYTE count, and with 2- or 3-byte hop encoding a 3-hop path is 6 or 9 bytes long. The path-length histogram plotted that raw value on an axis readers would take as hops, overstating distance two- to threefold on a mesh that is ~95% multibyte. It is replaced by a single hops-away chart computed as path_length / bytes_per_hop, which also retires the histogram built on the untrustworthy stored hop count. The result is unimodal, peaking at 3 hops and decaying — the shape a mesh should have, and not the bimodal one the old chart drew. --- CHANGELOG.md | 24 ++- docs/web-viewer.md | 28 ++- modules/web_viewer/dashboard_stats.py | 227 +++++++++++--------- modules/web_viewer/static/css/dashboard.css | 35 ++- modules/web_viewer/static/js/dashboard.js | 179 ++++++--------- modules/web_viewer/templates/index.html | 67 +++--- tests/test_dashboard_stats.py | 173 +++++++++------ 7 files changed, 370 insertions(+), 363 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10d3ec2..1f8cc62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,13 +23,23 @@ semantic versioning. - New dashboard tiles and charts: routing mix (flood vs direct), hop-count and path-length histograms, a 30-day multibyte adoption trend, busiest repeaters, and a role mix. -- Direct-neighbour signal panel: the nodes heard with no repeater in between, - their SNR distribution, and the weakest links named. Scoped to `hop_count = 0` - because that is the only population where SNR means anything — a relayed - packet's SNR measures the last hop into this radio, not the link to whoever - sent it, so a network-wide average describes nothing in particular. - `complete_contact_tracking` records signal for exactly the zero-hop contacts, - so this is a complete census of the neighbours rather than a sample. +- One-hop neighbours panel, with a 24-hour / 7-day selector: the nodes whose + advert reached this radio in a single hop, weakest measured link first. + Membership is derived from observed path evidence rather than from + `complete_contact_tracking.hop_count`, which is not trustworthy — it claims + 800 zero-hop contacts on the live database while only 68 have any one-hop + path to corroborate it, their stored SNR piles up in a 1.5 dB band (655 of + 800 between 11.25 and 12.75 dB), and their RSSI clusters near -45 dBm. That + is the signature of one strong local link being recorded against every node + whose traffic arrived through it, not of hundreds of separate radios. SNR is + therefore shown only where the path evidence and the stored hop count agree; + the rest read "no signal reading" rather than borrowing another link's + measurement. +- Hop-distance chart derived as `path_length / bytes_per_hop`. `path_length` is + a byte count, so with 2- or 3-byte hop encoding — about 95% of adverts on the + live mesh — reading it directly as a hop count overstates distance two- to + threefold. This replaces both the raw path-length chart and the chart built + on the untrustworthy stored hop count. - New `[Web_Viewer]` settings: `dashboard_snapshot_enabled`, `dashboard_snapshot_interval_seconds`, `dashboard_snapshot_history_days`, and `dashboard_packet_backfill_rows`. diff --git a/docs/web-viewer.md b/docs/web-viewer.md index 3e8ee56..37f1743 100644 --- a/docs/web-viewer.md +++ b/docs/web-viewer.md @@ -141,17 +141,27 @@ proxy_set_header X-Forwarded-Proto $scheme; and snapshot age with a manual refresh control - **Mesh**: nodes heard, adverts, new nodes, nodes gone quiet, and geographic coverage — the count tiles carry a 30-day sparkline and a change chip -- Routing mix (flood vs direct), hop-count and path-length histograms, and the - role mix +- Routing mix (flood vs direct), a hop-distance chart, and the role mix - Path encoding: multibyte share among contacts and among incoming packets, plus a 30-day multibyte adoption trend -- Busiest repeaters, and **direct neighbours** — the nodes heard with no - repeater in between, their SNR distribution, and the weakest links named. - Restricted to `hop_count = 0` deliberately: a relayed packet's SNR measures - the last hop into this radio, not the link to whoever originated it, so - mixing hop counts together yields a number that describes nothing. The bot's - contact records store signal for exactly the zero-hop contacts, which makes - this a complete census of the neighbours rather than a sample. +- Busiest repeaters, and **one-hop neighbours** (24-hour or 7-day window) + +Two measurement notes for the mesh charts: + +**Hops are derived, not read.** `observed_paths.path_length` is a *byte* count, +and with 2- or 3-byte hop encoding a three-hop path is six or nine bytes long. +The dashboard divides by `bytes_per_hop`; charting the raw value would overstate +distance two- to threefold on a mesh that is ~95% multibyte. + +**Neighbour signal is reported only where two sources agree.** +`complete_contact_tracking.hop_count` is not a reliable direct-neighbour marker: +on a representative database it claims 800 zero-hop contacts while only 68 have +any one-hop path to corroborate it, and the SNR stored against them clusters in +a ~1.5 dB band with RSSI near -45 dBm — one strong local link recorded against +every node whose traffic arrived through it. Neighbour membership therefore +comes from path evidence, and SNR/RSSI appear only when the stored hop count +agrees; otherwise the row reads "no signal reading". A relayed packet's SNR +measures the last hop into this radio, never the link to whoever sent it. - **Bot**: messages, commands, reply rate, and unique users, plus the top commands/users/channels and longest paths - Live activity feed diff --git a/modules/web_viewer/dashboard_stats.py b/modules/web_viewer/dashboard_stats.py index 6d4b72a..c1489a2 100644 --- a/modules/web_viewer/dashboard_stats.py +++ b/modules/web_viewer/dashboard_stats.py @@ -33,7 +33,6 @@ Two conventions matter throughout: from __future__ import annotations import json -import math import os import socket import sqlite3 @@ -85,16 +84,21 @@ SERIES_METRICS: dict[str, str] = { SUMMARY_SERIES_POINTS = 30 -# Direct-neighbour signal panel: how many of the weakest links to list, and the -# width of the SNR histogram buckets in dB. -WEAKEST_NEIGHBOURS = 8 -SNR_BIN_DB = 2 - # 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"}) -TOP_KINDS = ("users", "commands", "channels", "paths", "repeaters") +TOP_KINDS = ("users", "commands", "channels", "paths", "repeaters", "neighbors") + +# Neighbour windows are capped well below observed_paths' 90-day retention: a +# link last exercised a month ago says nothing about whether it works today. +NEIGHBOR_WINDOWS = ("24h", "7d") + +# A path is one hop when its byte length equals the per-hop encoding width. +# path_length is measured in BYTES, and with 2- or 3-byte hop encoding a 3-hop +# path is 6 or 9 bytes long — reading the raw value as a hop count inflates +# every multibyte path by 2-3x. +ONE_HOP_PATH = "op.bytes_per_hop > 0 AND op.path_length = op.bytes_per_hop" # Roles the firmware reports as an unmapped enum ordinal. They are real # contacts, so they belong in the mix — just not as sixteen singleton slices. @@ -124,22 +128,6 @@ def _table_exists(conn: sqlite3.Connection, table: str) -> bool: return row is not None -def _percentile(sorted_values: list[float], fraction: float) -> float | None: - """Linear-interpolated percentile over a pre-sorted list.""" - if not sorted_values: - return None - if len(sorted_values) == 1: - return round(float(sorted_values[0]), 1) - position = fraction * (len(sorted_values) - 1) - low = math.floor(position) - high = math.ceil(position) - if low == high: - return round(float(sorted_values[low]), 1) - weight = position - low - value = sorted_values[low] * (1 - weight) + sorted_values[high] * weight - return round(float(value), 1) - - def _change_pct(current: float | None, previous: float | None) -> float | None: """Percent change, or None when the baseline cannot support one. @@ -727,32 +715,14 @@ class DashboardStatsService: mesh["states"] = row[1] or 0 mesh["cities"] = row[2] or 0 - mesh["hop_histogram"] = [ - [int(hop), int(count)] - for hop, count in conn.execute( - """ - SELECT hop_count, COUNT(*) FROM complete_contact_tracking - WHERE hop_count IS NOT NULL AND hop_count BETWEEN 0 AND 32 - GROUP BY hop_count ORDER BY hop_count - """ - ) - ] mesh["role_mix"] = self._mix(conn, "role") - mesh["neighbors"] = self._direct_neighbor_signal(conn) if sources & SOURCE_OBSERVED_PATHS: - mesh["path_len_histogram"] = [ - [int(length), int(count)] - for length, count in conn.execute( - """ - SELECT path_length, COUNT(*) FROM observed_paths - WHERE packet_type = 'advert' AND path_length IS NOT NULL - AND path_length BETWEEN 0 AND 32 - AND last_seen >= datetime('now','localtime','-7 days') - GROUP BY path_length ORDER BY path_length - """ - ) - ] + mesh["hops_histogram"] = self._hops_histogram(conn) + mesh["neighbors"] = { + window: self._count_one_hop_nodes(conn, window) + for window in NEIGHBOR_WINDOWS + } if sources & SOURCE_PACKET_STREAM: row = conn.execute( @@ -795,74 +765,100 @@ class DashboardStatsService: buckets[key] = buckets.get(key, 0) + (count or 0) return [[name, count] for name, count in sorted(buckets.items(), key=lambda kv: -kv[1])] - def _direct_neighbor_signal(self, conn: sqlite3.Connection) -> dict[str, Any]: - """Per-neighbour signal for nodes heard directly, weakest link first. + def _hops_histogram(self, conn: sqlite3.Connection) -> list[list[int]]: + """Nodes by how many hops away their closest observed advert path was. - Scoped to ``hop_count = 0`` because that is the only population where - SNR means anything: a relayed packet's SNR describes the last hop into - this radio, not the link to the node that originated it. Averaging - those together produces a number that describes nothing in particular. - - This is also why the data is complete rather than sampled — - complete_contact_tracking records snr and signal_strength for exactly - the zero-hop contacts and leaves them NULL for everything further away. - - Recency-scoped as well: a neighbour last heard two months ago says - nothing about the link today. + 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). """ - window_days = 7 rows = conn.execute( """ - SELECT public_key, name, role, snr, signal_strength, hop_count, last_heard - FROM complete_contact_tracking - WHERE hop_count = 0 AND snr IS NOT NULL - AND last_heard > datetime('now', 'localtime', ?) - ORDER BY snr ASC - """, - (f"-{window_days} days",), + 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() + counts: dict[int, int] = {} + for row in rows: + hops = row["hops"] + if hops is None or not 0 <= hops <= 32: + 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)] - values = sorted(float(row["snr"]) for row in rows) - rssi_values = sorted( - float(row["signal_strength"]) for row in rows if row["signal_strength"] is not None - ) - return { - "count": len(rows), - "window_days": window_days, - "snr": { - "min": round(values[0], 1) if values else None, - "p50": _percentile(values, 0.50), - "max": round(values[-1], 1) if values else None, - }, - "rssi": {"p50": _percentile(rssi_values, 0.50)}, - "histogram": self._snr_histogram(values), - "weakest": [ - { - "name": row["name"] or (row["public_key"] or "")[:12], - "role": normalize_role(row["role"]), - "snr": round(float(row["snr"]), 1), - "rssi": ( - round(float(row["signal_strength"]), 0) - if row["signal_strength"] is not None - else None - ), - "last_heard": row["last_heard"], - } - for row in rows[:WEAKEST_NEIGHBOURS] - ], - } + def _count_one_hop_nodes(self, conn: sqlite3.Connection, window: str) -> int: + return conn.execute( + f""" + SELECT COUNT(DISTINCT op.public_key) FROM observed_paths op + WHERE op.packet_type = 'advert' AND {ONE_HOP_PATH} + AND op.last_seen > datetime('now','localtime', ?) + """, # noqa: S608 - ONE_HOP_PATH is a module constant, not input + (self._window_offset(window),), + ).fetchone()[0] or 0 @staticmethod - def _snr_histogram(sorted_values: list[float]) -> list[list[Any]]: - """Bin neighbour SNR into fixed dB buckets so the shape is comparable over time.""" - if not sorted_values: - return [] - bins: dict[int, int] = {} - for value in sorted_values: - edge = int(math.floor(value / SNR_BIN_DB) * SNR_BIN_DB) - bins[edge] = bins.get(edge, 0) + 1 - low, high = min(bins), max(bins) - return [[edge, bins.get(edge, 0)] for edge in range(low, high + SNR_BIN_DB, SNR_BIN_DB)] + def _window_offset(window: str) -> str: + return {"24h": "-1 days", "7d": "-7 days", "30d": "-30 days"}.get(window, "-7 days") + + def _one_hop_rows(self, conn: sqlite3.Connection, window: str, limit: int) -> list[dict]: + """Nodes whose advert reached this radio in a single hop, weakest first. + + Membership comes from path evidence, not from + ``complete_contact_tracking.hop_count``. That column claims 800 zero-hop + contacts, but only 68 of them have any one-hop path to corroborate it, + their stored SNR piles up in a 1.5 dB band (655 of 800 between 11.25 and + 12.75), and their RSSI clusters around -45 dBm — the signature of one + strong local link being recorded against every node whose traffic came + through it, not of hundreds of separate radios. + + Signal is therefore reported only where the path evidence and the stored + hop_count agree; everything else lists as unknown rather than being given + a number that belongs to somebody else's link. + """ + rows = conn.execute( + f""" + SELECT op.public_key, + MAX(op.last_seen) AS last_seen, + c.name, c.role, c.snr, c.signal_strength, c.hop_count + FROM observed_paths op + LEFT JOIN complete_contact_tracking c ON c.public_key = op.public_key + WHERE op.packet_type = 'advert' AND {ONE_HOP_PATH} + AND op.last_seen > datetime('now','localtime', ?) + GROUP BY op.public_key + """, # noqa: S608 - ONE_HOP_PATH is a module constant, not input + (self._window_offset(window),), + ).fetchall() + + measured: list[dict[str, Any]] = [] + unmeasured: list[dict[str, Any]] = [] + for row in rows: + corroborated = row["hop_count"] == 0 and row["snr"] is not None + item = { + "name": row["name"] or (row["public_key"] or "")[:12], + "public_key": row["public_key"], + "role": normalize_role(row["role"]), + "snr": round(float(row["snr"]), 1) if corroborated else None, + "rssi": ( + round(float(row["signal_strength"])) + if corroborated and row["signal_strength"] is not None + else None + ), + "signal_corroborated": corroborated, + "last_seen": row["last_seen"], + } + (measured if corroborated else unmeasured).append(item) + + # Weakest measured links first — those are the ones worth acting on. + measured.sort(key=lambda item: item["snr"]) + unmeasured.sort(key=lambda item: item["last_seen"] or "", reverse=True) + return (measured + unmeasured)[:limit] def _bot_snapshot(self, conn: sqlite3.Connection, sources: int) -> dict[str, Any]: """Rolling 24-hour bot activity (distinct from the calendar-day rollup).""" @@ -1200,7 +1196,16 @@ class DashboardStatsService: retention = self.stats_retention_days items: list[dict[str, Any]] = [] - if kind == "repeaters": + total: int | None = None + + if kind == "neighbors": + if window not in NEIGHBOR_WINDOWS: + window = NEIGHBOR_WINDOWS[0] + retention = self.adverts_retention_days + if _table_exists(conn, "observed_paths"): + items = self._one_hop_rows(conn, window, limit) + total = self._count_one_hop_nodes(conn, window) + elif kind == "repeaters": retention = self.adverts_retention_days days = {"24h": 1, "7d": 7, "30d": 30, "90d": 90}.get(window, retention) since = (datetime.now() - timedelta(days=days - 1)).strftime("%Y-%m-%d") @@ -1288,6 +1293,7 @@ class DashboardStatsService: "truncated_by_retention": bool( requested_days is None or requested_days > retention ), + "total": total if total is not None else len(items), "items": items, } @@ -1332,5 +1338,12 @@ class DashboardStatsService: "channels": stats_options, "paths": stats_options, "repeaters": options(self.adverts_retention_days), + # Deliberately not retention-derived: observed_paths keeps 90 + # days, but a link last used a month ago tells you nothing about + # whether it works now. + "neighbors": [ + {"value": value, "label": self._window_label(value, self.adverts_retention_days)} + for value in NEIGHBOR_WINDOWS + ], }, } diff --git a/modules/web_viewer/static/css/dashboard.css b/modules/web_viewer/static/css/dashboard.css index 570087d..bc1b20d 100644 --- a/modules/web_viewer/static/css/dashboard.css +++ b/modules/web_viewer/static/css/dashboard.css @@ -287,6 +287,18 @@ border-radius: 5px; } +/* Hatched, not empty: "we have no reading" reads differently from "zero". */ +.neighbor-row__track--unknown { + background-image: repeating-linear-gradient( + 45deg, + var(--bg-tertiary), + var(--bg-tertiary) 3px, + transparent 3px, + transparent 6px + ); + opacity: 0.6; +} + .neighbor-row__values { color: var(--text-muted); font-variant-numeric: tabular-nums; @@ -298,29 +310,6 @@ font-weight: 600; } -/* ── Signal percentile readout ─────────────────────────────────────────── */ - -.signal-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0.5rem; - text-align: center; -} - -.signal-grid__value { - font-size: 1.1rem; - font-weight: 700; - color: var(--text-color); - font-variant-numeric: tabular-nums; -} - -.signal-grid__label { - font-size: 0.68rem; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-muted); -} - /* ── Leaderboard rows ──────────────────────────────────────────────────── */ .top-list-row { diff --git a/modules/web_viewer/static/js/dashboard.js b/modules/web_viewer/static/js/dashboard.js index 0474b91..bcdb9ef 100644 --- a/modules/web_viewer/static/js/dashboard.js +++ b/modules/web_viewer/static/js/dashboard.js @@ -316,6 +316,7 @@ ); if (!response.ok) throw new Error('HTTP ' + response.status); const data = await response.json(); + if (config.onLoad) config.onLoad(data); if (!data.items || data.items.length === 0) { container.replaceChildren( makeTextElement('div', config.empty, 'dashboard-empty') @@ -365,9 +366,7 @@ // Routing, encoding, distributions this.renderRouteMix(mesh.route_mix, coverage.packets_window_label); - this.renderHistogram('hopHistogramChart', mesh.hop_histogram, 'Contacts', COLOR.accent); - this.renderHistogram('pathLenHistogramChart', mesh.path_len_histogram, 'Advert paths', COLOR.flood); - this.renderNeighbors(mesh.neighbors); + this.renderHopsHistogram(mesh.hops_histogram); this.renderDoughnut('contactsEncodingChart', 'contacts-encoding-summary', (mesh.encoding || {}).contacts_7d, 'contacts'); this.renderDoughnut('packetsEncodingChart', 'packets-encoding-summary', @@ -501,85 +500,11 @@ ); } - renderHistogram(canvasId, points, label, color) { + renderHopsHistogram(points) { + const canvasId = 'hopsHistogramChart'; const canvas = el(canvasId); if (!canvas || typeof Chart === 'undefined') return; - const list = Array.isArray(points) ? points : []; - const colors = themeColors(); - - if (this.charts[canvasId]) { - this.charts[canvasId].destroy(); - delete this.charts[canvasId]; - } - if (list.length === 0) return; - - this.charts[canvasId] = new Chart(canvas.getContext('2d'), { - type: 'bar', - data: { - labels: list.map((p) => p[0]), - datasets: [{ - label: label, - data: list.map((p) => p[1]), - backgroundColor: color, - borderRadius: 3, - }], - }, - options: noAnimation({ - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { display: false } }, - scales: { - x: { - ticks: { color: colors.muted, font: { size: 10 } }, - grid: { display: false }, - }, - y: { - beginAtZero: true, - ticks: { color: colors.muted, font: { size: 10 }, precision: 0 }, - grid: { color: colors.grid }, - }, - }, - }), - }); - } - - /** - * Direct (zero-hop) neighbours: the distribution plus the weakest links. - * Only these have a meaningful SNR — a relayed packet's SNR describes - * the last hop, not the node that sent it. - */ - renderNeighbors(neighbors) { - const data = neighbors || {}; - const snr = data.snr || {}; - const count = data.count || 0; - - setText('neighbors-count', formatNumber(count)); - setText('neighbors-window', 'heard in the last ' + (data.window_days || 7) + 'd'); - setText('neighbors-snr-min', formatSigned(snr.min)); - setText('neighbors-snr-p50', formatSigned(snr.p50)); - setText('neighbors-snr-max', formatSigned(snr.max)); - setText('neighbors-rssi', formatSigned((data.rssi || {}).p50)); - - this.renderNeighborHistogram(data.histogram); - - const list = el('neighbors-weakest'); - if (!list) return; - const weakest = Array.isArray(data.weakest) ? data.weakest : []; - if (weakest.length === 0) { - list.replaceChildren( - makeTextElement('div', 'No direct neighbours heard recently.', 'dashboard-empty') - ); - return; - } - list.replaceChildren(); - weakest.forEach((item) => list.appendChild(neighborRow(item))); - } - - renderNeighborHistogram(histogram) { - const canvasId = 'neighborSnrChart'; - const canvas = el(canvasId); - if (!canvas || typeof Chart === 'undefined') return; - const bins = Array.isArray(histogram) ? histogram : []; + const bins = Array.isArray(points) ? points : []; const colors = themeColors(); if (this.charts[canvasId]) { @@ -593,9 +518,9 @@ data: { labels: bins.map((b) => b[0]), datasets: [{ - label: 'Neighbours', + label: 'Nodes', data: bins.map((b) => b[1]), - backgroundColor: bins.map((b) => snrColor(b[0])), + backgroundColor: COLOR.accent, borderRadius: 3, }], }, @@ -606,17 +531,14 @@ legend: { display: false }, tooltip: { callbacks: { - title: (items) => { - const low = Number(items[0].label); - return low + ' to ' + (low + 2) + ' dB SNR'; - }, - label: (ctx) => formatNumber(ctx.parsed.y) + ' neighbours', + title: (items) => items[0].label + ' hops away', + label: (ctx) => formatNumber(ctx.parsed.y) + ' nodes', }, }, }, scales: { x: { - title: { display: true, text: 'SNR (dB)', color: colors.muted, + title: { display: true, text: 'Hops away', color: colors.muted, font: { size: 10 } }, ticks: { color: colors.muted, font: { size: 10 } }, grid: { display: false }, @@ -883,7 +805,13 @@ return '#198754'; } - /** One weakest-link row: name, SNR bar positioned on a fixed scale, values. */ + /** + * One one-hop neighbour: name, SNR bar on a fixed scale, values. + * + * Signal is shown only where the stored hop count corroborates the path + * evidence. For the rest the row says "no signal reading" rather than + * borrowing a number measured on somebody else's link. + */ function neighborRow(item) { const row = document.createElement('div'); row.className = 'neighbor-row'; @@ -891,27 +819,35 @@ const name = makeTextElement('div', item.name, 'neighbor-row__name'); name.title = String(item.name ?? '') + ' (' + String(item.role ?? '') + ')'; - // Fixed -12..+14 dB scale so bars are comparable between refreshes - // rather than rescaling to whatever the current worst link happens to be. const track = document.createElement('div'); track.className = 'neighbor-row__track'; - const fill = document.createElement('div'); - fill.className = 'neighbor-row__fill'; - const ratio = Math.max(0, Math.min(1, ((Number(item.snr) + 12) / 26))); - fill.style.width = (ratio * 100).toFixed(1) + '%'; - fill.style.backgroundColor = snrColor(item.snr); - track.appendChild(fill); - const values = document.createElement('div'); values.className = 'neighbor-row__values'; - values.append( - makeTextElement('span', formatSigned(item.snr) + ' dB', 'neighbor-row__snr'), - makeTextElement('span', - item.rssi === null || item.rssi === undefined - ? '' - : ' / ' + Math.round(item.rssi) + ' dBm', - 'neighbor-row__rssi') - ); + + if (item.signal_corroborated) { + // Fixed -12..+14 dB scale so bars are comparable between refreshes + // rather than rescaling to whatever the current worst link happens to be. + const fill = document.createElement('div'); + fill.className = 'neighbor-row__fill'; + fill.style.width = (Math.max(0, Math.min(1, (Number(item.snr) + 12) / 26)) * 100) + .toFixed(1) + '%'; + fill.style.backgroundColor = snrColor(item.snr); + track.appendChild(fill); + values.append( + makeTextElement('span', formatSigned(item.snr) + ' dB', 'neighbor-row__snr'), + makeTextElement('span', + item.rssi === null || item.rssi === undefined + ? '' + : ' / ' + Math.round(item.rssi) + ' dBm', + 'neighbor-row__rssi') + ); + } else { + track.classList.add('neighbor-row__track--unknown'); + const unknown = makeTextElement('span', 'no signal reading', 'neighbor-row__rssi'); + unknown.title = 'Heard one hop away, but no corroborated direct-reception ' + + 'measurement is stored for this node.'; + values.appendChild(unknown); + } row.append(name, track, values); return row; @@ -987,6 +923,19 @@ empty: 'No repeater adverts recorded.', render: (item, i) => rankedRow(i + 1, item.name, formatNumber(item.count) + ' adverts', 'bg-success'), }, + neighbors: { + select: 'neighbors-window', + container: 'neighbors-list', + limit: 10, + empty: 'No one-hop adverts heard in this window.', + render: (item) => neighborRow(item), + onLoad: (data) => { + setText('neighbors-count', formatNumber(data.total)); + const shown = Math.min(data.total || 0, (data.items || []).length); + setText('neighbors-shown', + data.total > shown ? 'showing ' + shown + ' of ' + formatNumber(data.total) : ''); + }, + }, }; // ── live activity feed ─────────────────────────────────────────────── @@ -1130,12 +1079,18 @@ 'Change compares the last complete calendar day against the day before it — ' + 'not a rolling 24 hours. The headline number above it is a rolling 24-hour count.', 'neighbors-info': - 'Nodes heard directly, with no repeater in between, in the last 7 days. ' + - 'Only these have a meaningful SNR: a relayed packet\'s SNR measures the last ' + - 'hop into this radio, not the link to whoever sent it, so averaging across all ' + - 'hop counts describes nothing in particular. Bars use a fixed -12 to +14 dB ' + - 'scale; below 0 dB the signal is under the noise floor and only the spreading ' + - 'factor is recovering it.', + 'Nodes whose advert reached this radio in a single hop, newest evidence first, ' + + 'with the weakest measured links promoted to the top. Membership comes from the ' + + 'observed path length divided by its bytes-per-hop encoding, not from the stored ' + + 'hop count — that field claims far more direct neighbours than the path evidence ' + + 'supports. SNR is shown only where both agree; a relayed packet\'s SNR measures ' + + '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.', }; function initTooltips() { diff --git a/modules/web_viewer/templates/index.html b/modules/web_viewer/templates/index.html index e72d1f7..813e26c 100644 --- a/modules/web_viewer/templates/index.html +++ b/modules/web_viewer/templates/index.html @@ -121,21 +121,25 @@

-
-

Hop count across known contacts

-
- -
-
Path length
+
+ Hops away + +
-

Advert paths observed in the last 7 days

+

+ Nodes by their closest advert path, last 7 days +

- +
@@ -228,41 +232,26 @@
- Direct neighbours & signal - + + One-hop neighbours + + +
-
+
- nodes heard with no repeater in between, - + nodes reached this radio in a single hop
-
-
-
-
worst SNR
-
-
-
-
median SNR
-
-
-
-
best SNR
-
-
-
- -
-

- Weakest links (median RSSI dBm) -

-
Loading…
+

+
Loading…
diff --git a/tests/test_dashboard_stats.py b/tests/test_dashboard_stats.py index 586317e..44bfa23 100644 --- a/tests/test_dashboard_stats.py +++ b/tests/test_dashboard_stats.py @@ -587,84 +587,125 @@ class TestDerivedWindows: assert windows["windows"]["users"][-1]["label"] == f"All retained ({retention}d)" -class TestDirectNeighbourSignal: - """SNR is only meaningful for nodes heard with no repeater in between.""" +class TestOneHopNeighbours: + """Neighbour membership comes from path evidence, not the stored hop_count. - def _seed_neighbours(self, viewer, rows): - with sqlite3.connect(viewer.db_path) as conn: - conn.executemany( - """ - INSERT INTO complete_contact_tracking - (public_key, name, role, hop_count, snr, signal_strength, last_heard) - VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime', ?)) - """, - rows, - ) + On the live database hop_count claims 800 zero-hop contacts while only 68 + have any one-hop path to corroborate it, and their stored SNR piles up in a + 1.5 dB band — one strong local link recorded against every node whose + traffic came through it. + """ - def _signal(self, viewer): + def _seed_contact(self, conn, pk, name, role, hop_count, snr, rssi): + conn.execute( + """ + INSERT INTO complete_contact_tracking + (public_key, name, role, hop_count, snr, signal_strength, last_heard) + VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime','-1 hours')) + """, + (pk, name, role, hop_count, snr, rssi), + ) + + def _seed_path(self, conn, pk, path_length, bytes_per_hop, age="-1 hours"): + 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', ?, ?, ?, 'advert', datetime('now','localtime', ?)) + """, + (pk, "ab" * path_length, path_length, bytes_per_hop, age), + ) + + def _top(self, viewer, window="24h", limit=10): with closing(viewer._dashboard_connection()) as conn: - return viewer.dashboard_stats._direct_neighbor_signal(conn) + return viewer.dashboard_stats.read_top(conn, "neighbors", window, limit) - def test_relayed_contacts_are_excluded(self, viewer): - """A relayed packet's SNR describes the last hop, not the sender.""" - self._seed_neighbours(viewer, [ - (_pk(1), "direct", "repeater", 0, 5.0, -70.0, "-1 hours"), - (_pk(2), "one-hop", "repeater", 1, -9.0, -110.0, "-1 hours"), - (_pk(3), "far", "companion", 5, 12.0, -50.0, "-1 hours"), - ]) - signal = self._signal(viewer) - assert signal["count"] == 1 - assert [n["name"] for n in signal["weakest"]] == ["direct"] - assert signal["snr"]["p50"] == 5.0 + def test_multibyte_paths_are_not_read_as_extra_hops(self, viewer): + """path_length is bytes: 3 bytes at 3 bytes/hop is ONE hop, not three.""" + with sqlite3.connect(viewer.db_path) as conn: + self._seed_contact(conn, _pk(1), "onebyte-1hop", "repeater", 0, 5.0, -70.0) + self._seed_path(conn, _pk(1), path_length=1, bytes_per_hop=1) + self._seed_contact(conn, _pk(2), "multibyte-1hop", "repeater", 0, 6.0, -60.0) + self._seed_path(conn, _pk(2), path_length=3, bytes_per_hop=3) + self._seed_contact(conn, _pk(3), "multibyte-2hop", "repeater", 0, 7.0, -50.0) + self._seed_path(conn, _pk(3), path_length=6, bytes_per_hop=3) - def test_weakest_links_come_first(self, viewer): - self._seed_neighbours(viewer, [ - (_pk(i), f"n{i}", "repeater", 0, snr, -60.0 - i, "-2 hours") - for i, snr in enumerate([8.0, -9.5, 2.0, -3.0, 13.0]) - ]) - signal = self._signal(viewer) - assert [n["snr"] for n in signal["weakest"]] == [-9.5, -3.0, 2.0, 8.0, 13.0] - assert signal["snr"]["min"] == -9.5 - assert signal["snr"]["max"] == 13.0 + names = {item["name"] for item in self._top(viewer)["items"]} + assert names == {"onebyte-1hop", "multibyte-1hop"} - def test_stale_neighbours_are_excluded(self, viewer): - """A link not exercised in a week says nothing about the link today.""" - self._seed_neighbours(viewer, [ - (_pk(1), "recent", "repeater", 0, 4.0, -80.0, "-2 days"), - (_pk(2), "stale", "repeater", 0, -11.0, -115.0, "-40 days"), - ]) - signal = self._signal(viewer) - assert signal["count"] == 1 - assert [n["name"] for n in signal["weakest"]] == ["recent"] + def test_uncorroborated_signal_is_withheld(self, viewer): + """Path evidence without a matching hop_count gets no SNR figure.""" + with sqlite3.connect(viewer.db_path) as conn: + self._seed_contact(conn, _pk(1), "agrees", "repeater", 0, 4.0, -80.0) + self._seed_path(conn, _pk(1), 1, 1) + # hop_count says 4 hops but a one-hop path exists: the stored signal + # belongs to some other link, so it must not be shown. + self._seed_contact(conn, _pk(2), "disagrees", "repeater", 4, 12.0, -45.0) + self._seed_path(conn, _pk(2), 1, 1) - def test_histogram_bins_are_fixed_width_and_contiguous(self, viewer): - self._seed_neighbours(viewer, [ - (_pk(i), f"n{i}", "repeater", 0, snr, -70.0, "-1 hours") - for i, snr in enumerate([-5.0, -4.5, 0.5, 5.0]) - ]) - histogram = self._signal(viewer)["histogram"] - edges = [b[0] for b in histogram] - assert edges == list(range(-6, 6, 2)), histogram - assert sum(b[1] for b in histogram) == 4 - # An empty bucket in the middle is present with a zero, not skipped. - assert dict(histogram)[-2] == 0 + items = {item["name"]: item for item in self._top(viewer)["items"]} + assert items["agrees"]["signal_corroborated"] is True + assert items["agrees"]["snr"] == 4.0 + assert items["disagrees"]["signal_corroborated"] is False + assert items["disagrees"]["snr"] is None + assert items["disagrees"]["rssi"] is None - def test_no_neighbours_degrades_cleanly(self, viewer): - signal = self._signal(viewer) - assert signal["count"] == 0 - assert signal["weakest"] == [] - assert signal["histogram"] == [] - assert signal["snr"]["p50"] is None + def test_contacts_with_no_one_hop_path_are_excluded(self, viewer): + """hop_count = 0 alone does not make something a neighbour.""" + with sqlite3.connect(viewer.db_path) as conn: + self._seed_contact(conn, _pk(1), "claims-direct", "repeater", 0, 12.0, -45.0) + self._seed_path(conn, _pk(1), path_length=5, bytes_per_hop=1) + assert self._top(viewer)["items"] == [] + assert self._top(viewer)["total"] == 0 - def test_snapshot_exposes_neighbours_not_device_mix(self, viewer): - """role and device_type are the same field twice — only role is charted.""" - self._seed_neighbours(viewer, [(_pk(1), "n", "repeater", 0, 3.0, -75.0, "-1 hours")]) + def test_weakest_measured_links_are_promoted(self, viewer): + with sqlite3.connect(viewer.db_path) as conn: + for i, snr in enumerate([9.0, -8.0, 2.0]): + self._seed_contact(conn, _pk(i), f"m{i}", "repeater", 0, snr, -70.0) + self._seed_path(conn, _pk(i), 1, 1) + self._seed_contact(conn, _pk(9), "unmeasured", "repeater", 3, None, None) + self._seed_path(conn, _pk(9), 1, 1) + + items = self._top(viewer)["items"] + assert [i["name"] for i in items[:3]] == ["m1", "m2", "m0"] + assert items[-1]["name"] == "unmeasured" + + def test_window_bounds_membership(self, viewer): + with sqlite3.connect(viewer.db_path) as conn: + self._seed_contact(conn, _pk(1), "today", "repeater", 0, 5.0, -70.0) + self._seed_path(conn, _pk(1), 1, 1, age="-2 hours") + self._seed_contact(conn, _pk(2), "last-week", "repeater", 0, 5.0, -70.0) + self._seed_path(conn, _pk(2), 1, 1, age="-4 days") + + assert {i["name"] for i in self._top(viewer, "24h")["items"]} == {"today"} + assert {i["name"] for i in self._top(viewer, "7d")["items"]} == {"today", "last-week"} + + def test_windows_are_capped_below_retention(self, viewer): + """observed_paths keeps 90 days; a month-old link says nothing about today.""" + options = viewer.dashboard_stats.derive_windows()["windows"]["neighbors"] + assert [o["value"] for o in options] == ["24h", "7d"] + + def test_snapshot_reports_hops_not_path_bytes(self, viewer): + with sqlite3.connect(viewer.db_path) as conn: + self._seed_contact(conn, _pk(1), "near", "repeater", 0, 5.0, -70.0) + self._seed_path(conn, _pk(1), path_length=6, bytes_per_hop=3) # 2 hops + self._seed_contact(conn, _pk(2), "far", "repeater", 4, None, None) + self._seed_path(conn, _pk(2), path_length=4, bytes_per_hop=1) # 4 hops _refresh(viewer) + with viewer.app.test_client() as client: mesh = client.get("/api/dashboard/summary").get_json()["mesh"] - assert "device_mix" not in mesh - assert "role_mix" in mesh - assert mesh["neighbors"]["count"] == 1 + assert "device_mix" not in mesh # same field as role + assert "hop_histogram" not in mesh # replaced by path-derived hops + assert "path_len_histogram" not in mesh # was byte length labelled as hops + assert dict(mesh["hops_histogram"]) == {2: 1, 3: 0, 4: 1} + assert mesh["neighbors"] == {"24h": 0, "7d": 0} + + def test_no_neighbours_degrades_cleanly(self, viewer): + payload = self._top(viewer) + assert payload["items"] == [] + assert payload["total"] == 0 class TestRoleBucketing: