From 8286fb1e65178ca70a3923e8b994b98ecf539961 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sun, 19 Jul 2026 17:02:18 +0200 Subject: [PATCH 01/21] feat(pathanalyzer): bulk messages API with batched echoes (stage 1) - New GET /api/path-analyzer/messages?days=N endpoint: returns channel messages from ALL channels for the last N days (clamped 1..30) with echoes attached per message (path, snr, hash_size, direction, received_at), channel names and packet hashes included. - New db.get_echoes_for_payloads(): chunked IN-clause batch fetch (<=500 params per query) instead of one query per message. - Extracted _build_channel_secrets() and _get_row_pkt_payload() helpers from get_messages(); /api/messages behavior unchanged (verified live: field set identical, packet hashes and echo paths match the new endpoint for the same messages). - Legacy rows with uncomputable pkt_payload degrade to packet_hash null + empty echoes instead of being dropped. Co-Authored-By: Claude Fable 5 --- app/database.py | 21 ++++++ app/routes/api.py | 184 +++++++++++++++++++++++++++++++++++++--------- 2 files changed, 171 insertions(+), 34 deletions(-) diff --git a/app/database.py b/app/database.py index 25bae37..a682dbc 100644 --- a/app/database.py +++ b/app/database.py @@ -1120,6 +1120,27 @@ class Database: ).fetchall() return [dict(r) for r in rows] + def get_echoes_for_payloads(self, payloads: List[str]) -> Dict[str, List[Dict]]: + """Batch-fetch echoes for many pkt_payloads with chunked IN queries. + + Returns {pkt_payload: [echo dicts ordered by received_at]}. + Chunked at 500 to stay under SQLite's host-parameter limit.""" + result: Dict[str, List[Dict]] = {} + if not payloads: + return result + with self._connect() as conn: + for i in range(0, len(payloads), 500): + chunk = payloads[i:i + 500] + placeholders = ",".join("?" * len(chunk)) + rows = conn.execute( + f"""SELECT * FROM echoes WHERE pkt_payload IN ({placeholders}) + ORDER BY received_at ASC""", + chunk + ).fetchall() + for r in rows: + result.setdefault(r['pkt_payload'], []).append(dict(r)) + return result + def update_message_pkt_payload(self, msg_id: int, pkt_payload: str) -> None: """Set pkt_payload on a channel message (used for sent message echo correlation).""" with self._connect() as conn: diff --git a/app/routes/api.py b/app/routes/api.py index ca6c435..3ed89bb 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -390,6 +390,57 @@ def save_ui_settings(settings: dict) -> bool: return False +def _build_channel_secrets(db) -> dict: + """Build {channel_idx: secret_hex} lookup for pkt_payload computation. + + Uses DB channels (fast) instead of get_channels_cached() which can block + on device communication when cache is cold.""" + channel_secrets = {} + for ch_info in (db.get_channels() if db else []): + ch_key = ch_info.get('secret', ch_info.get('key', '')) + ch_idx = ch_info.get('idx', ch_info.get('index')) + if ch_key and ch_idx is not None: + channel_secrets[ch_idx] = ch_key + return channel_secrets + + +def _get_row_pkt_payload(row: dict, channel_secrets: dict): + """Return pkt_payload for a channel message row, computing it when not + stored (v2: meshcore doesn't provide it). Returns None when it cannot + be computed (legacy row or missing channel secret).""" + pkt_payload = row.get('pkt_payload') + if pkt_payload: + return pkt_payload + + ch_idx = row.get('channel_idx', 0) + sender_ts = row.get('sender_timestamp') + txt_type = row.get('txt_type', 0) + if not sender_ts or ch_idx not in channel_secrets: + return None + + # Use original text from raw_json (preserves trailing whitespace) + raw_text = None + raw_json_str = row.get('raw_json') + if raw_json_str: + try: + raw_text = json.loads(raw_json_str).get('text') + except (json.JSONDecodeError, TypeError): + pass + # Fallback: reconstruct from sender + content + if not raw_text: + is_own = bool(row.get('is_own', 0)) + if is_own: + device_name = runtime_config.get_device_name() or '' + raw_text = f"{device_name}: {row.get('content', '')}" if device_name else row.get('content', '') + else: + sender = row.get('sender', '') + raw_text = f"{sender}: {row.get('content', '')}" if sender else row.get('content', '') + + return compute_pkt_payload( + channel_secrets[ch_idx], sender_ts, txt_type, raw_text + ) + + @api_bp.route('/messages', methods=['GET']) def get_messages(): """ @@ -441,47 +492,15 @@ def get_messages(): days=days, ) - # Build channel secret lookup for pkt_payload computation - # Use DB channels (fast) instead of get_channels_cached() which - # can block on device communication when cache is cold - channel_secrets = {} - db_channels = db.get_channels() if db else [] - for ch_info in db_channels: - ch_key = ch_info.get('secret', ch_info.get('key', '')) - ch_idx = ch_info.get('idx', ch_info.get('index')) - if ch_key and ch_idx is not None: - channel_secrets[ch_idx] = ch_key + channel_secrets = _build_channel_secrets(db) # Convert DB rows to frontend-compatible format messages = [] for row in db_messages: - pkt_payload = row.get('pkt_payload') ch_idx = row.get('channel_idx', 0) sender_ts = row.get('sender_timestamp') txt_type = row.get('txt_type', 0) - - # Compute pkt_payload if not stored (v2: meshcore doesn't provide it) - if not pkt_payload and sender_ts and ch_idx in channel_secrets: - # Use original text from raw_json (preserves trailing whitespace) - raw_text = None - raw_json_str = row.get('raw_json') - if raw_json_str: - try: - raw_text = json.loads(raw_json_str).get('text') - except (json.JSONDecodeError, TypeError): - pass - # Fallback: reconstruct from sender + content - if not raw_text: - is_own = bool(row.get('is_own', 0)) - if is_own: - device_name = runtime_config.get_device_name() or '' - raw_text = f"{device_name}: {row.get('content', '')}" if device_name else row.get('content', '') - else: - sender = row.get('sender', '') - raw_text = f"{sender}: {row.get('content', '')}" if sender else row.get('content', '') - pkt_payload = compute_pkt_payload( - channel_secrets[ch_idx], sender_ts, txt_type, raw_text - ) + pkt_payload = _get_row_pkt_payload(row, channel_secrets) # Decode path_len into hop_count and path_hash_size path_len_raw = row.get('path_len') @@ -550,6 +569,103 @@ def get_messages(): }), 500 +@api_bp.route('/path-analyzer/messages', methods=['GET']) +def get_path_analyzer_messages(): + """ + Bulk channel messages across ALL channels with batched echo data. + + Used by the Path Analyzer panel. Unlike /api/messages this returns all + channels at once and fetches echoes in chunked batch queries instead of + one query per message. + + Query parameters: + days (int): Time window in days (default 3, clamped to 1..30) + + Returns: + JSON with messages list; each message carries an echoes[] array of + {path, snr, hash_size, direction, received_at}. Messages whose + pkt_payload cannot be computed (legacy rows, missing channel secret) + are returned with packet_hash null and empty echoes. + RSSI is not included — it is not persisted for channel messages; + it would require an Observer-backed capture store (future work). + """ + try: + days = request.args.get('days', default=3, type=int) + days = max(1, min(days, 30)) + + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 500 + + db_messages = db.get_channel_messages(channel_idx=None, limit=None, days=days) + + channel_secrets = _build_channel_secrets(db) + channel_names = { + ch.get('idx'): ch.get('name', '') + for ch in db.get_channels() + } + blocked_names = db.get_blocked_contact_names() + + # First pass: compute payloads so echoes can be fetched in one batch + prepared = [] + payloads = [] + for row in db_messages: + if blocked_names and row.get('sender', '') in blocked_names: + continue + pkt_payload = _get_row_pkt_payload(row, channel_secrets) + if pkt_payload: + payloads.append(pkt_payload) + prepared.append((row, pkt_payload)) + + echoes_by_payload = db.get_echoes_for_payloads(payloads) + + messages = [] + for row, pkt_payload in prepared: + path_len_raw = row.get('path_len') + hop_count = None + path_hash_size = 1 + if path_len_raw is not None: + hop_count, path_hash_size, _ = decode_path_len(path_len_raw) + + ch_idx = row.get('channel_idx', 0) + messages.append({ + 'id': row.get('id'), + 'channel_idx': ch_idx, + 'channel_name': channel_names.get(ch_idx, ''), + 'sender': row.get('sender', ''), + 'content': row.get('content', ''), + 'timestamp': row.get('timestamp', 0), + 'datetime': datetime.fromtimestamp(row['timestamp']).isoformat() if row.get('timestamp') else None, + 'is_own': bool(row.get('is_own', 0)), + 'snr': row.get('snr'), + 'hop_count': hop_count, + 'path_hash_size': path_hash_size, + 'packet_hash': compute_packet_hash(pkt_payload) if pkt_payload else None, + 'pkt_payload': pkt_payload, + 'echoes': [ + { + 'path': e.get('path', ''), + 'snr': e.get('snr'), + 'hash_size': e.get('hash_size', 1), + 'direction': e.get('direction', 'incoming'), + 'received_at': e.get('received_at'), + } + for e in echoes_by_payload.get(pkt_payload, []) + ] if pkt_payload else [], + }) + + return jsonify({ + 'success': True, + 'count': len(messages), + 'days': days, + 'messages': messages, + }), 200 + + except Exception as e: + logger.error(f"Error fetching path analyzer messages: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @api_bp.route('/messages//meta', methods=['GET']) def get_message_meta(msg_id): """Return metadata (SNR, hops, route, analyzer URL) for a single channel message.""" From 9668651a6102eb7360791ee5d26c4c6e90adac37 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sun, 19 Jul 2026 17:14:20 +0200 Subject: [PATCH 02/21] feat(pathanalyzer): Path Analyzer panel skeleton (stage 2) Full-screen iframe panel wired per the add-fullscreen-panel checklist: - /path-analyzer Flask view + standalone template (theme bootstrap before CSS, local vendor assets, Leaflet included for the stage-5 map view) - path-analyzer.js: days selector (1/3/5/7), message table (time, channel, sender, preview, copyable packet hash, hops, echo count), spinner/empty states, local toast helpers fed from /api/ui/settings - #pathAnalyzerModal in index.html, iframe src set on show.bs.modal for fresh data each open; FAB button + purple gradient class - menu entry in #mainMenu (Tools) + placement radio row in Settings -> Appearance; registered in ITEM_PLACEMENT_DEFS/DEFAULTS (default: menu) - style.css: #pathAnalyzerModal added to all three narrow-viewport fullscreen-override selector lists Verified live via Playwright at 1280px and 390px, light + dark: modal fills the viewport exactly, placement radios move the entry both ways, iframe reloads on every open. Co-Authored-By: Claude Fable 5 --- app/routes/views.py | 9 ++ app/static/css/style.css | 16 ++- app/static/js/app.js | 3 +- app/static/js/path-analyzer.js | 202 +++++++++++++++++++++++++++++++ app/templates/base.html | 18 +++ app/templates/index.html | 31 +++++ app/templates/path-analyzer.html | 183 ++++++++++++++++++++++++++++ 7 files changed, 458 insertions(+), 4 deletions(-) create mode 100644 app/static/js/path-analyzer.js create mode 100644 app/templates/path-analyzer.html diff --git a/app/routes/views.py b/app/routes/views.py index 76e309e..bb61f6d 100644 --- a/app/routes/views.py +++ b/app/routes/views.py @@ -103,6 +103,15 @@ def console(): ) +@views_bp.route('/path-analyzer') +def path_analyzer(): + """Path Analyzer - routing path analysis for channel messages.""" + return render_template( + 'path-analyzer.html', + device_name=runtime_config.get_device_name() + ) + + @views_bp.route('/repeaters') def repeaters(): """My Repeaters - repeater administration panel (list + login).""" diff --git a/app/static/css/style.css b/app/static/css/style.css index 0caa26e..56059ca 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1637,6 +1637,13 @@ main { color: white; } +/* Path Analyzer FAB button + modal header (purple) */ +.fab-pathanalyzer, +.pathanalyzer-header { + background: linear-gradient(135deg, #6f42c1 0%, #59359a 100%); + color: white; +} + /* Filter bar overlay - slides down from top of chat area */ .filter-bar { position: absolute; @@ -1883,7 +1890,8 @@ emoji-picker { #contactsModal .modal-dialog.modal-fullscreen, #logsModal .modal-dialog.modal-fullscreen, #consoleModal .modal-dialog.modal-fullscreen, -#repeatersModal .modal-dialog.modal-fullscreen { +#repeatersModal .modal-dialog.modal-fullscreen, +#pathAnalyzerModal .modal-dialog.modal-fullscreen { margin: 0 !important; width: 100vw !important; max-width: 100vw !important; @@ -1895,7 +1903,8 @@ emoji-picker { #contactsModal .modal-content, #logsModal .modal-content, #consoleModal .modal-content, -#repeatersModal .modal-content { +#repeatersModal .modal-content, +#pathAnalyzerModal .modal-content { border: none !important; border-radius: 0 !important; height: 100vh !important; @@ -1905,7 +1914,8 @@ emoji-picker { #contactsModal .modal-body, #logsModal .modal-body, #consoleModal .modal-body, -#repeatersModal .modal-body { +#repeatersModal .modal-body, +#pathAnalyzerModal .modal-body { overflow: hidden !important; } diff --git a/app/static/js/app.js b/app/static/js/app.js index 9530d37..15d2844 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -6030,6 +6030,7 @@ const ITEM_PLACEMENT_DEFS = { map: { fab: '#fab-map', menu: '#mapBtn' }, console: { fab: '#fab-console', menu: '#consoleBtn' }, repeaters: { fab: '#fab-repeaters', menu: '#repeatersBtn' }, + pathanalyzer: { fab: '#fab-pathanalyzer', menu: '#pathAnalyzerBtn' }, deviceinfo: { fab: '#fab-deviceinfo', menu: '#deviceInfoBtn' }, syslog: { fab: '#fab-syslog', menu: '#logsBtn' }, }; @@ -6037,7 +6038,7 @@ const ITEM_PLACEMENT_DEFS = { const ITEM_PLACEMENT_DEFAULTS = { filter: 'fab', search: 'fab', dm: 'fab', contacts: 'fab', settings: 'fab', advert: 'menu', floodadvert: 'menu', map: 'menu', - console: 'menu', repeaters: 'menu', deviceinfo: 'menu', syslog: 'menu' + console: 'menu', repeaters: 'menu', pathanalyzer: 'menu', deviceinfo: 'menu', syslog: 'menu' }; function readItemPlacements() { diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js new file mode 100644 index 0000000..e7b70a5 --- /dev/null +++ b/app/static/js/path-analyzer.js @@ -0,0 +1,202 @@ +// Path Analyzer panel +// - bulk channel messages across all channels (GET /api/path-analyzer/messages) +// - stage 2: days selector + flat message table +// - later stages: path detail rows, filters, per-repeater stats, map view + +// ================================================================ +// UI settings + toast (same behavior as repeaters.js) +// ================================================================ + +const PA_UI_SETTINGS_DEFAULTS = { + toast_timeout_sec: 2, + toast_no_autoclose: false, + toast_position: 'top-left' +}; + +const PA_TOAST_POSITION_CLASSES = { + 'top-left': ['top-0', 'start-0'], + 'top-right': ['top-0', 'end-0'], + 'bottom-left': ['bottom-0', 'start-0'], + 'bottom-right': ['bottom-0', 'end-0'], + 'center': ['top-50', 'start-50', 'translate-middle'] +}; +const PA_ALL_POSITION_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle']; + +window.uiSettingsCache = window.uiSettingsCache || { ...PA_UI_SETTINGS_DEFAULTS }; + +function applyToastPosition(position) { + const classes = PA_TOAST_POSITION_CLASSES[position] || PA_TOAST_POSITION_CLASSES['top-left']; + document.querySelectorAll('[data-toast-container]').forEach(el => { + PA_ALL_POSITION_CLASSES.forEach(c => el.classList.remove(c)); + classes.forEach(c => el.classList.add(c)); + }); +} + +async function loadUiSettings() { + try { + const resp = await fetch('/api/ui/settings'); + if (resp.ok) { + const data = await resp.json(); + window.uiSettingsCache = { ...PA_UI_SETTINGS_DEFAULTS, ...data }; + applyToastPosition(window.uiSettingsCache.toast_position); + } + } catch (e) { + console.error('Failed to load UI settings:', e); + } +} + +function showNotification(message, type = 'info') { + const toastEl = document.getElementById('notificationToast'); + if (!toastEl) return; + + const toastBody = toastEl.querySelector('.toast-body'); + if (toastBody) { + toastBody.textContent = message; + } + + const toastHeader = toastEl.querySelector('.toast-header'); + if (toastHeader) { + toastHeader.className = 'toast-header'; + if (type === 'success') { + toastHeader.classList.add('bg-success', 'text-white'); + } else if (type === 'danger') { + toastHeader.classList.add('bg-danger', 'text-white'); + } else if (type === 'warning') { + toastHeader.classList.add('bg-warning'); + } + } + + const cfg = window.uiSettingsCache || {}; + const noAutoclose = !!cfg.toast_no_autoclose; + const timeoutSec = parseFloat(cfg.toast_timeout_sec); + const delay = isFinite(timeoutSec) && timeoutSec > 0 ? Math.round(timeoutSec * 1000) : 2000; + + const toast = new bootstrap.Toast(toastEl, { + autohide: !noAutoclose, + delay: delay + }); + toast.show(); +} + +// ================================================================ +// Data loading + table rendering +// ================================================================ + +let paMessages = []; + +function paFormatTime(msg) { + if (!msg.timestamp) return '—'; + const d = new Date(msg.timestamp * 1000); + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +function paCopyText(text, label) { + navigator.clipboard.writeText(text).then( + () => showNotification(`${label} copied to clipboard`, 'success'), + () => showNotification(`Failed to copy ${label}`, 'danger') + ); +} + +function paRenderTable() { + const body = document.getElementById('paTableBody'); + body.innerHTML = ''; + + for (const msg of paMessages) { + const tr = document.createElement('tr'); + + const tdTime = document.createElement('td'); + tdTime.className = 'pa-time'; + tdTime.textContent = paFormatTime(msg); + tr.appendChild(tdTime); + + const tdChannel = document.createElement('td'); + tdChannel.textContent = msg.channel_name || `#${msg.channel_idx}`; + tr.appendChild(tdChannel); + + const tdSender = document.createElement('td'); + tdSender.textContent = msg.is_own ? `${msg.sender || 'Me'} (own)` : (msg.sender || '—'); + tr.appendChild(tdSender); + + const tdContent = document.createElement('td'); + const preview = document.createElement('div'); + preview.className = 'pa-content-preview'; + preview.textContent = msg.content || ''; + preview.title = msg.content || ''; + tdContent.appendChild(preview); + tr.appendChild(tdContent); + + const tdHash = document.createElement('td'); + if (msg.packet_hash) { + const span = document.createElement('span'); + span.className = 'pa-hash'; + span.textContent = msg.packet_hash; + span.title = 'Click to copy'; + span.addEventListener('click', () => paCopyText(msg.packet_hash, 'Packet hash')); + tdHash.appendChild(span); + } else { + tdHash.innerHTML = 'no path data'; + } + tr.appendChild(tdHash); + + const tdHops = document.createElement('td'); + tdHops.className = 'text-end'; + tdHops.textContent = (msg.hop_count === null || msg.hop_count === undefined) ? '—' : msg.hop_count; + tr.appendChild(tdHops); + + const tdEchoes = document.createElement('td'); + tdEchoes.className = 'text-end'; + tdEchoes.textContent = msg.echoes.length; + tr.appendChild(tdEchoes); + + body.appendChild(tr); + } + + document.getElementById('paCounter').textContent = + `${paMessages.length} message${paMessages.length === 1 ? '' : 's'}`; +} + +function paSetView(state) { + document.getElementById('paLoading').classList.toggle('d-none', state !== 'loading'); + document.getElementById('paEmpty').classList.toggle('d-none', state !== 'empty'); + document.getElementById('paTableWrap').classList.toggle('d-none', state !== 'table'); +} + +async function paLoadMessages() { + const days = document.getElementById('paDaysSelect').value; + paSetView('loading'); + document.getElementById('paCounter').textContent = ''; + + try { + const resp = await fetch(`/api/path-analyzer/messages?days=${encodeURIComponent(days)}`); + const data = await resp.json(); + if (!resp.ok || !data.success) { + throw new Error(data.error || `HTTP ${resp.status}`); + } + // Newest first for the analysis table (API returns ascending) + paMessages = (data.messages || []).slice().reverse(); + } catch (e) { + console.error('Failed to load messages:', e); + showNotification(`Failed to load messages: ${e.message}`, 'danger'); + paMessages = []; + } + + if (paMessages.length === 0) { + paSetView('empty'); + } else { + paRenderTable(); + paSetView('table'); + } +} + +// ================================================================ +// Init +// ================================================================ + +document.addEventListener('DOMContentLoaded', () => { + loadUiSettings(); + document.getElementById('paDaysSelect').addEventListener('change', paLoadMessages); + document.getElementById('paRefreshBtn').addEventListener('click', paLoadMessages); + paLoadMessages(); +}); diff --git a/app/templates/base.html b/app/templates/base.html index ecc0ff2..b2ab716 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -205,6 +205,13 @@ Repeater administration +
@@ -894,6 +901,17 @@
+ + Path Analyzer + +
+ + + + +
+ + Device Info diff --git a/app/templates/index.html b/app/templates/index.html index 3595839..40dfeaf 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -148,6 +148,9 @@ + @@ -224,6 +227,23 @@ + + +
- No messages in the selected time range. + No messages in the selected time range.
+ From ca24d62ca6f3c591852e175a5c3ae2d3e5e2365e Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sun, 19 Jul 2026 19:12:34 +0200 Subject: [PATCH 04/21] feat(pathanalyzer): per-repeater statistics (stage 4) - Messages / Repeaters view toggle in the toolbar; stats aggregate over the currently filtered messages so both views share one filter state - Per exact hash token: Relayed (echoes containing the token anywhere), distinct Messages, As-last-hop count, and Avg SNR counted only over echoes where the token is the final hop (SNR is measured at our receiver, so it is never attributed to intermediate hops) - Sortable numeric columns (default Relayed desc, nulls always last) - Token -> contact enrichment via /api/contacts/cached?format=full pubkey prefix match: single candidate shows the name, collisions show 'ambiguous (n)' with candidate names in the tooltip (matcher shared with the stage-5 map) - Clicking a stats row applies that token as the message filter and switches back to the Messages view Verified live via Playwright: aggregates match an in-page manual recount exactly, sort orders correct, row-click cross-navigation works, 1-byte and 2-byte tokens kept distinct. Co-Authored-By: Claude Fable 5 --- app/static/js/path-analyzer.js | 174 ++++++++++++++++++++++++++++++- app/templates/path-analyzer.html | 46 ++++++++ 2 files changed, 218 insertions(+), 2 deletions(-) diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index faf694f..94a7ab0 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -84,6 +84,28 @@ function showNotification(message, type = 'info') { let paMessages = []; let paFilters = { hops: 'any', token: '', sender: '' }; +let paCurrentView = 'messages'; // 'messages' | 'stats' +let paContacts = []; // /api/contacts/cached?format=full +let paStatsSort = { key: 'relayed', dir: -1 }; + +async function paLoadContacts() { + try { + const resp = await fetch('/api/contacts/cached?format=full'); + if (resp.ok) { + const data = await resp.json(); + if (data.success) paContacts = data.contacts || []; + } + } catch (e) { + console.error('Failed to load contacts:', e); + } +} + +// Match a repeater hash token against contacts by public key prefix. +// 1-byte hashes can collide - callers must handle multiple candidates. +function paMatchContacts(token) { + const prefix = token.toLowerCase(); + return paContacts.filter(c => (c.public_key || '').toLowerCase().startsWith(prefix)); +} // Split an echo's path hex into per-hop tokens using that echo's own // hash_size (same logic as showPathsPopup in app.js; trailing partial kept) @@ -282,6 +304,139 @@ function paSetView(state) { document.getElementById('paLoading').classList.toggle('d-none', state !== 'loading'); document.getElementById('paEmpty').classList.toggle('d-none', state !== 'empty'); document.getElementById('paTableWrap').classList.toggle('d-none', state !== 'table'); + document.getElementById('paStatsWrap').classList.toggle('d-none', state !== 'stats'); +} + +// ================================================================ +// Per-repeater statistics +// ================================================================ + +function paComputeStats(filtered) { + const stats = new Map(); // exact token -> aggregate + for (const msg of filtered) { + for (const e of msg.echoView) { + e.tokens.forEach((tok, i) => { + let s = stats.get(tok); + if (!s) { + s = { token: tok, relayed: 0, msgIds: new Set(), lastHop: 0, snrSum: 0, snrCount: 0 }; + stats.set(tok, s); + } + s.relayed++; + s.msgIds.add(msg.id); + if (i === e.tokens.length - 1) { + // SNR is measured at our receiver - attribute it to the + // final hop only, never to intermediate hops + s.lastHop++; + if (e.snr !== null && e.snr !== undefined) { + s.snrSum += Number(e.snr); + s.snrCount++; + } + } + }); + } + } + return [...stats.values()].map(s => ({ + token: s.token, + relayed: s.relayed, + messages: s.msgIds.size, + lastHop: s.lastHop, + avgSnr: s.snrCount > 0 ? s.snrSum / s.snrCount : null, + })); +} + +function paRenderStats() { + const body = document.getElementById('paStatsBody'); + body.innerHTML = ''; + + const filtered = paMessages.filter(paMessageMatchesFilters); + const rows = paComputeStats(filtered); + + const { key, dir } = paStatsSort; + rows.sort((a, b) => { + const av = a[key], bv = b[key]; + if (av === null && bv === null) return 0; + if (av === null) return 1; // nulls (never last hop) always last + if (bv === null) return -1; + return (av < bv ? -1 : av > bv ? 1 : 0) * dir; + }); + + // Show sort direction on the active header + document.querySelectorAll('.pa-sortable').forEach(th => { + th.textContent = th.textContent.replace(/ [▲▼]$/, '') + + (th.dataset.sort === key ? (dir === -1 ? ' ▼' : ' ▲') : ''); + }); + + for (const s of rows) { + const tr = document.createElement('tr'); + tr.className = 'pa-stats-row'; + tr.title = `Filter messages by ${s.token}`; + + const tdToken = document.createElement('td'); + tdToken.innerHTML = `${s.token}`; + tr.appendChild(tdToken); + + const tdContact = document.createElement('td'); + const candidates = paMatchContacts(s.token); + if (candidates.length === 1) { + tdContact.textContent = candidates[0].name || '—'; + } else if (candidates.length > 1) { + tdContact.innerHTML = `ambiguous (${candidates.length})`; + } else { + tdContact.textContent = '—'; + } + tr.appendChild(tdContact); + + for (const val of [s.relayed, s.messages, s.lastHop]) { + const td = document.createElement('td'); + td.className = 'text-end'; + td.textContent = val; + tr.appendChild(td); + } + + const tdSnr = document.createElement('td'); + tdSnr.className = 'text-end'; + tdSnr.textContent = s.avgSnr === null ? '—' : `${s.avgSnr.toFixed(1)} dB`; + tr.appendChild(tdSnr); + + // Click -> apply this token as the filter and jump to the message list + tr.addEventListener('click', () => { + document.getElementById('paTokenFilter').value = s.token; + paSwitchView('messages'); + }); + + body.appendChild(tr); + } + + const counter = document.getElementById('paCounter'); + counter.textContent = paFiltersActive() + ? `${rows.length} repeaters (${filtered.length} of ${paMessages.length} messages)` + : `${rows.length} repeaters (${paMessages.length} messages)`; + + if (rows.length === 0) { + document.getElementById('paEmptyText').textContent = + filtered.length === 0 ? 'No messages match the current filters.' + : 'No routed echoes in the current selection.'; + paSetView('empty'); + } else { + paSetView('stats'); + } +} + +function paRender() { + if (paCurrentView === 'stats') { + paRenderStats(); + } else { + paRenderTable(); + } +} + +function paSwitchView(view) { + paCurrentView = view; + const msgBtn = document.getElementById('paViewMessagesBtn'); + const statsBtn = document.getElementById('paViewStatsBtn'); + msgBtn.className = 'btn ' + (view === 'messages' ? 'btn-primary' : 'btn-outline-primary'); + statsBtn.className = 'btn ' + (view === 'stats' ? 'btn-primary' : 'btn-outline-primary'); + paApplyFilters(); } async function paLoadMessages() { @@ -310,7 +465,7 @@ async function paLoadMessages() { document.getElementById('paEmptyText').textContent = 'No messages in the selected time range.'; paSetView('empty'); } else { - paRenderTable(); + paRender(); } } @@ -330,7 +485,7 @@ function paReadFilters() { function paApplyFilters() { paReadFilters(); if (paMessages.length > 0) { - paRenderTable(); + paRender(); } } @@ -360,5 +515,20 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('paSenderFilter').addEventListener('input', debouncedApply); document.getElementById('paClearFiltersBtn').addEventListener('click', paClearFilters); + document.getElementById('paViewMessagesBtn').addEventListener('click', () => paSwitchView('messages')); + document.getElementById('paViewStatsBtn').addEventListener('click', () => paSwitchView('stats')); + document.querySelectorAll('.pa-sortable').forEach(th => { + th.addEventListener('click', () => { + const k = th.dataset.sort; + if (paStatsSort.key === k) { + paStatsSort.dir = -paStatsSort.dir; + } else { + paStatsSort = { key: k, dir: -1 }; + } + paRenderStats(); + }); + }); + + paLoadContacts(); paLoadMessages(); }); diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index e426a8a..4cc653b 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -178,6 +178,24 @@ color: var(--text-secondary, #6c757d); } + .pa-sortable { + cursor: pointer; + user-select: none; + } + + .pa-sortable:hover { + text-decoration: underline; + } + + .pa-stats-row { + cursor: pointer; + } + + .pa-ambiguous { + color: var(--text-secondary, #6c757d); + font-style: italic; + } + .empty-state { text-align: center; color: var(--text-secondary, #6c757d); @@ -194,6 +212,15 @@
+
+ + +
+
Time Channel Sender
+
+ + + + + + + + + + + + +
RepeaterContactRelayedMessagesAs last hopAvg SNR (last hop)
+
From dca3d582329573f4663ada7e9454613dc408ca9a Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sun, 19 Jul 2026 19:15:59 +0200 Subject: [PATCH 05/21] feat(pathanalyzer): map view with path drawing (stage 5) - Third view toggle: Map. Leaflet lazy-init on first activation with invalidateSize() once the container is visible; OSM tiles as in the main app map - Base layer: repeater contacts with geo plotted as purple circle markers (name + pubkey prefix popup), from /api/contacts/cached - Sidebar lists filtered messages that have routed echoes; click a message to see its echoes, click an echo to draw it - Hop resolution by pubkey prefix: single geo candidate draws a solid hop point; collisions render all candidates as amber markers plus a legend row with clickable pick-chips (pick resolves the hop and redraws); unknown hops are listed as 'unknown / no position' - Polyline follows path order with the sender (name-matched contact) as origin when available; segments bridging unresolved hops are dashed; map fits bounds to the drawn path - Clear button erases the drawn path; stale selections are dropped when filters change underneath Verified live via Playwright: 252 repeater markers, path + legend render, ambiguous-hop candidate pick adds a polyline segment, clear empties the layer, view toggling stays stable with no page errors. Co-Authored-By: Claude Fable 5 --- app/static/js/path-analyzer.js | 234 ++++++++++++++++++++++++++++++- app/templates/path-analyzer.html | 116 +++++++++++++++ 2 files changed, 346 insertions(+), 4 deletions(-) diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index 94a7ab0..10686a5 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -305,6 +305,7 @@ function paSetView(state) { document.getElementById('paEmpty').classList.toggle('d-none', state !== 'empty'); document.getElementById('paTableWrap').classList.toggle('d-none', state !== 'table'); document.getElementById('paStatsWrap').classList.toggle('d-none', state !== 'stats'); + document.getElementById('paMapWrap').classList.toggle('d-none', state !== 'map'); } // ================================================================ @@ -422,9 +423,230 @@ function paRenderStats() { } } +// ================================================================ +// Map view +// ================================================================ + +let paMap = null; +let paBaseLayer = null; // repeater contact markers +let paPathLayer = null; // drawn path for the selected echo +let paMapSelection = { msgId: null, echoIdx: null }; +let paPicks = {}; // token -> public_key chosen by the user (collision disambiguation) + +function paGeoContact(c) { + return c.adv_lat !== null && c.adv_lat !== undefined && + c.adv_lon !== null && c.adv_lon !== undefined && + !(Number(c.adv_lat) === 0 && Number(c.adv_lon) === 0); +} + +function paInitMap() { + if (paMap) return; + paMap = L.map('paMap').setView([52.0, 19.0], 6); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap' + }).addTo(paMap); + paBaseLayer = L.layerGroup().addTo(paMap); + paPathLayer = L.layerGroup().addTo(paMap); +} + +function paPlotBaseMarkers() { + paBaseLayer.clearLayers(); + paContacts.filter(c => c.type_label === 'REP' && paGeoContact(c)).forEach(c => { + L.circleMarker([c.adv_lat, c.adv_lon], { + radius: 5, color: '#6f42c1', weight: 1.5, fillOpacity: 0.5 + }).bindPopup(`${c.name || '?'}
${(c.public_key || '').slice(0, 12)}`) + .addTo(paBaseLayer); + }); +} + +// Resolve a hop token to a contact: manual pick wins, else the single +// geo-located candidate. Returns {contact, candidates} - contact null +// when unknown or ambiguous. +function paResolveHop(token) { + const candidates = paMatchContacts(token); + const geoCandidates = candidates.filter(paGeoContact); + if (paPicks[token]) { + const picked = geoCandidates.find(c => c.public_key === paPicks[token]); + if (picked) return { contact: picked, candidates: geoCandidates }; + } + if (geoCandidates.length === 1) return { contact: geoCandidates[0], candidates: geoCandidates }; + return { contact: null, candidates: geoCandidates }; +} + +function paDrawSelectedEcho() { + paPathLayer.clearLayers(); + const msg = paMessages.find(m => m.id === paMapSelection.msgId); + if (!msg || paMapSelection.echoIdx === null) return; + const echo = msg.echoView[paMapSelection.echoIdx]; + if (!echo) return; + + const points = []; // {latlng, gapBefore} + let gapPending = false; + + // Origin: sender name -> contact with geo (channel messages carry no sender pubkey) + const origin = paContacts.find(c => c.name === msg.sender && paGeoContact(c)); + if (origin) { + L.circleMarker([origin.adv_lat, origin.adv_lon], { + radius: 7, color: '#198754', weight: 2, fillOpacity: 0.8 + }).bindPopup(`${origin.name}
Origin (sender)`).addTo(paPathLayer); + points.push({ latlng: [origin.adv_lat, origin.adv_lon], gapBefore: false }); + } + + echo.tokens.forEach((tok, i) => { + const { contact, candidates } = paResolveHop(tok); + if (contact) { + L.circleMarker([contact.adv_lat, contact.adv_lon], { + radius: 7, color: '#6f42c1', weight: 2, fillOpacity: 0.9 + }).bindPopup(`${contact.name}
Hop ${i + 1}: ${tok}`).addTo(paPathLayer); + points.push({ latlng: [contact.adv_lat, contact.adv_lon], gapBefore: gapPending }); + gapPending = false; + } else { + // Ambiguous: show all geo candidates as amber markers, excluded from the line + candidates.forEach(c => { + L.circleMarker([c.adv_lat, c.adv_lon], { + radius: 6, color: '#d39e00', weight: 2, fillOpacity: 0.5, dashArray: '3' + }).bindPopup(`${c.name}
Candidate for hop ${i + 1}: ${tok}`).addTo(paPathLayer); + }); + gapPending = true; + } + }); + + // Polyline: solid between consecutive resolved hops, dashed across skipped ones + for (let i = 1; i < points.length; i++) { + L.polyline([points[i - 1].latlng, points[i].latlng], { + color: '#6f42c1', weight: 3, opacity: 0.8, + dashArray: points[i].gapBefore ? '6 8' : null + }).addTo(paPathLayer); + } + + const allLatLngs = points.map(p => p.latlng); + if (allLatLngs.length > 0) { + paMap.fitBounds(L.latLngBounds(allLatLngs).pad(0.25), { maxZoom: 13 }); + } + + document.getElementById('paMapClearBtn').classList.remove('d-none'); +} + +function paBuildLegend(echo) { + const legend = document.createElement('div'); + legend.className = 'pa-legend'; + echo.tokens.forEach((tok, i) => { + const row = document.createElement('div'); + row.className = 'pa-legend-hop'; + const { contact, candidates } = paResolveHop(tok); + const label = document.createElement('span'); + label.innerHTML = `${i + 1}. ${tok}`; + row.appendChild(label); + if (contact) { + const name = document.createElement('span'); + name.textContent = '— ' + contact.name; + row.appendChild(name); + } else if (candidates.length > 1) { + const amb = document.createElement('span'); + amb.className = 'pa-ambiguous'; + amb.textContent = `— ${candidates.length} candidates:`; + row.appendChild(amb); + candidates.forEach(c => { + const chip = document.createElement('span'); + chip.className = 'pa-pick-chip' + (paPicks[tok] === c.public_key ? ' pa-picked' : ''); + chip.textContent = c.name || c.public_key.slice(0, 8); + chip.title = 'Use this contact for the hop'; + chip.addEventListener('click', (e) => { + e.stopPropagation(); + paPicks[tok] = c.public_key; + paRenderMapView(); + }); + row.appendChild(chip); + }); + } else { + const unk = document.createElement('span'); + unk.className = 'pa-ambiguous'; + unk.textContent = '— unknown / no position'; + row.appendChild(unk); + } + legend.appendChild(row); + }); + return legend; +} + +function paRenderMapView() { + paInitMap(); + paPlotBaseMarkers(); + + const list = document.getElementById('paMapMsgList'); + list.innerHTML = ''; + const filtered = paMessages.filter(paMessageMatchesFilters) + .filter(m => m.echoView.some(e => e.hops > 0)); + + // Drop a stale selection (filters changed underneath it) + if (paMapSelection.msgId !== null && !filtered.some(m => m.id === paMapSelection.msgId)) { + paMapSelection = { msgId: null, echoIdx: null }; + paPathLayer.clearLayers(); + document.getElementById('paMapClearBtn').classList.add('d-none'); + } + + if (filtered.length === 0) { + list.innerHTML = '
No messages with routed echoes match the current filters.
'; + } + + for (const msg of filtered) { + const entry = document.createElement('div'); + entry.className = 'pa-map-msg' + (msg.id === paMapSelection.msgId ? ' pa-selected' : ''); + entry.innerHTML = + `
+ ${msg.sender || '—'} + ${paFormatTime(msg).slice(5, 16)} +
`; + entry.addEventListener('click', () => { + paMapSelection = { msgId: msg.id, echoIdx: null }; + paRenderMapView(); + }); + + if (msg.id === paMapSelection.msgId) { + msg.echoView.forEach((echo, idx) => { + if (echo.hops === 0) return; + const eEl = document.createElement('div'); + eEl.className = 'pa-map-echo' + (idx === paMapSelection.echoIdx ? ' pa-selected' : ''); + const snr = (echo.snr === null || echo.snr === undefined) ? '?' : `${Number(echo.snr).toFixed(1)} dB`; + eEl.textContent = `${echo.tokens.join(' → ')} (${snr})`; + eEl.addEventListener('click', (e) => { + e.stopPropagation(); + paMapSelection.echoIdx = idx; + paRenderMapView(); + }); + entry.appendChild(eEl); + + if (idx === paMapSelection.echoIdx) { + entry.appendChild(paBuildLegend(echo)); + } + }); + } + list.appendChild(entry); + } + + paSetView('map'); + // Leaflet cannot size itself while the container was display:none + setTimeout(() => paMap.invalidateSize(), 60); + paDrawSelectedEcho(); + + const counter = document.getElementById('paCounter'); + counter.textContent = paFiltersActive() + ? `${filtered.length} of ${paMessages.length} messages` + : `${filtered.length} routed message${filtered.length === 1 ? '' : 's'}`; +} + +function paClearMapSelection() { + paMapSelection = { msgId: null, echoIdx: null }; + paPathLayer.clearLayers(); + document.getElementById('paMapClearBtn').classList.add('d-none'); + paRenderMapView(); +} + function paRender() { if (paCurrentView === 'stats') { paRenderStats(); + } else if (paCurrentView === 'map') { + paRenderMapView(); } else { paRenderTable(); } @@ -432,10 +654,12 @@ function paRender() { function paSwitchView(view) { paCurrentView = view; - const msgBtn = document.getElementById('paViewMessagesBtn'); - const statsBtn = document.getElementById('paViewStatsBtn'); - msgBtn.className = 'btn ' + (view === 'messages' ? 'btn-primary' : 'btn-outline-primary'); - statsBtn.className = 'btn ' + (view === 'stats' ? 'btn-primary' : 'btn-outline-primary'); + for (const [btnId, v] of [['paViewMessagesBtn', 'messages'], + ['paViewStatsBtn', 'stats'], + ['paViewMapBtn', 'map']]) { + document.getElementById(btnId).className = + 'btn ' + (view === v ? 'btn-primary' : 'btn-outline-primary'); + } paApplyFilters(); } @@ -517,6 +741,8 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('paViewMessagesBtn').addEventListener('click', () => paSwitchView('messages')); document.getElementById('paViewStatsBtn').addEventListener('click', () => paSwitchView('stats')); + document.getElementById('paViewMapBtn').addEventListener('click', () => paSwitchView('map')); + document.getElementById('paMapClearBtn').addEventListener('click', paClearMapSelection); document.querySelectorAll('.pa-sortable').forEach(th => { th.addEventListener('click', () => { const k = th.dataset.sort; diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index 4cc653b..4f69e0d 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -60,11 +60,112 @@ .pa-content { flex: 1 1 0; min-height: 0; + display: flex; + flex-direction: column; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 0.75rem 1rem; } + #paMapWrap { + flex: 1 1 0; + min-height: 0; + display: flex; + gap: 0.75rem; + } + + #paMapSidebar { + width: 340px; + min-width: 280px; + overflow-y: auto; + flex-shrink: 0; + } + + #paMap { + flex: 1 1 0; + min-height: 300px; + border-radius: 0.5rem; + border: 1px solid var(--border-color); + } + + @media (max-width: 768px) { + #paMapWrap { + flex-direction: column; + } + + #paMapSidebar { + width: auto; + min-width: 0; + max-height: 40%; + flex-shrink: 1; + } + } + + .pa-map-msg { + border: 1px solid var(--border-color); + border-radius: 0.4rem; + padding: 0.4rem 0.6rem; + margin-bottom: 0.4rem; + cursor: pointer; + font-size: 0.82rem; + } + + .pa-map-msg:hover { + background-color: var(--hover-bg, rgba(0, 0, 0, 0.05)); + } + + .pa-map-msg.pa-selected { + border-color: #6f42c1; + } + + .pa-map-echo { + border-top: 1px dashed var(--border-color); + margin-top: 0.35rem; + padding: 0.25rem 0.15rem 0; + cursor: pointer; + font-size: 0.78rem; + } + + .pa-map-echo:hover { + background-color: var(--hover-bg, rgba(0, 0, 0, 0.05)); + } + + .pa-map-echo.pa-selected { + color: #6f42c1; + font-weight: 600; + } + + .pa-legend { + margin-top: 0.4rem; + padding: 0.4rem 0.5rem; + background-color: var(--bg-surface, rgba(0, 0, 0, 0.03)); + border-radius: 0.4rem; + font-size: 0.78rem; + } + + .pa-legend-hop { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.3rem; + padding: 0.15rem 0; + } + + .pa-pick-chip { + border: 1px solid #d39e00; + color: #b58900; + border-radius: 0.25rem; + padding: 0 0.3rem; + cursor: pointer; + font-size: 0.72rem; + } + + .pa-pick-chip:hover, + .pa-pick-chip.pa-picked { + background-color: #ffc107; + color: #212529; + } + .pa-table-wrap { overflow-x: auto; } @@ -219,6 +320,9 @@ +
@@ -296,6 +400,18 @@ +
+
+
+ Click a message, then an echo to draw its path. + +
+
+
+
+
From 67b405251beb903ac026dcf0d20f1e4b3c3dbf2b Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sun, 19 Jul 2026 19:19:00 +0200 Subject: [PATCH 06/21] feat(pathanalyzer): path hash size (HB) column and filter - HB column in the message table: distinct hash sizes (bytes per hop) across the message's routed echoes, e.g. '1' or '1,2'; em dash when no routed echo - HB filter (Any/1-byte/2-byte/3-byte): matches when any routed echo uses that hash size; 0-hop echoes are excluded since their stored hash_size is meaningless; combines with the other filters and is reset by Clear Verified live via Playwright: 1B/2B/3B filters each return only messages with a matching routed echo (183/166/9 of 606, zero false positives), combined hops+HB filtering works, Clear resets. Co-Authored-By: Claude Fable 5 --- app/static/js/path-analyzer.js | 22 +++++++++++++++++++--- app/templates/path-analyzer.html | 8 ++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index 10686a5..d7458c5 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -83,7 +83,7 @@ function showNotification(message, type = 'info') { // ================================================================ let paMessages = []; -let paFilters = { hops: 'any', token: '', sender: '' }; +let paFilters = { hops: 'any', hashSize: 'any', token: '', sender: '' }; let paCurrentView = 'messages'; // 'messages' | 'stats' let paContacts = []; // /api/contacts/cached?format=full let paStatsSort = { key: 'relayed', dir: -1 }; @@ -126,6 +126,12 @@ function paMessageMatchesFilters(msg) { want === '4+' ? e.hops >= 4 : e.hops === parseInt(want, 10)); if (!ok) return false; } + if (paFilters.hashSize !== 'any') { + const want = parseInt(paFilters.hashSize, 10); + // Only routed echoes carry a meaningful hash size + const ok = msg.echoView.some(e => e.hops > 0 && (e.hash_size || 1) === want); + if (!ok) return false; + } if (paFilters.token) { const t = paFilters.token; const ok = msg.echoView.some(e => e.tokens.some(tok => tok.startsWith(t))); @@ -138,7 +144,8 @@ function paMessageMatchesFilters(msg) { } function paFiltersActive() { - return paFilters.hops !== 'any' || paFilters.token !== '' || paFilters.sender !== ''; + return paFilters.hops !== 'any' || paFilters.hashSize !== 'any' + || paFilters.token !== '' || paFilters.sender !== ''; } function paFormatTime(msg) { @@ -258,6 +265,12 @@ function paRenderTable() { tdHops.textContent = (msg.hop_count === null || msg.hop_count === undefined) ? '—' : msg.hop_count; tr.appendChild(tdHops); + const tdHb = document.createElement('td'); + tdHb.className = 'text-end'; + const hbSizes = [...new Set(msg.echoView.filter(e => e.hops > 0).map(e => e.hash_size || 1))].sort(); + tdHb.textContent = hbSizes.length > 0 ? hbSizes.join(',') : '—'; + tr.appendChild(tdHb); + const tdEchoes = document.createElement('td'); tdEchoes.className = 'text-end'; tdEchoes.textContent = msg.echoView.length; @@ -270,7 +283,7 @@ function paRenderTable() { detailTr.className = 'd-none'; const detailTd = document.createElement('td'); detailTd.className = 'pa-echo-cell'; - detailTd.colSpan = 8; + detailTd.colSpan = 9; for (const echo of msg.echoView) { detailTd.appendChild(paBuildEchoLine(echo)); } @@ -699,6 +712,7 @@ async function paLoadMessages() { function paReadFilters() { paFilters.hops = document.getElementById('paHopsFilter').value; + paFilters.hashSize = document.getElementById('paHashSizeFilter').value; // Normalize token input to hex characters only (matches chip display casing) paFilters.token = document.getElementById('paTokenFilter').value .replace(/[^0-9a-fA-F]/g, '').toUpperCase(); @@ -715,6 +729,7 @@ function paApplyFilters() { function paClearFilters() { document.getElementById('paHopsFilter').value = 'any'; + document.getElementById('paHashSizeFilter').value = 'any'; document.getElementById('paTokenFilter').value = ''; document.getElementById('paSenderFilter').value = ''; paApplyFilters(); @@ -735,6 +750,7 @@ document.addEventListener('DOMContentLoaded', () => { filterDebounce = setTimeout(paApplyFilters, 150); }; document.getElementById('paHopsFilter').addEventListener('change', paApplyFilters); + document.getElementById('paHashSizeFilter').addEventListener('change', paApplyFilters); document.getElementById('paTokenFilter').addEventListener('input', debouncedApply); document.getElementById('paSenderFilter').addEventListener('input', debouncedApply); document.getElementById('paClearFiltersBtn').addEventListener('click', paClearFilters); diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index 4f69e0d..e2a1c04 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -344,6 +344,13 @@ + Message Hash Hops + HB Echoes From 9574a7ebee2637e35b4b397fb8aad84be05a6255 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sun, 19 Jul 2026 19:36:11 +0200 Subject: [PATCH 07/21] feat(pathanalyzer): jump-to-map from echo rows, message preview in map sidebar - Each routed echo line in the Messages table gets a map icon that switches to the Map view with that message + echo pre-selected and its path drawn; the sidebar scrolls the selection into view - Map sidebar entries show the message content under the sender: one truncated line normally, full text when the message is selected; sidebar entry markup switched from innerHTML to DOM building so network-supplied sender/content strings are never parsed as HTML - Fix: path drawing (fitBounds) now runs after invalidateSize in the same deferred step - drawing immediately after unhiding the map container computed bounds against a stale size and left the map at world zoom when jumping from the table Verified live via Playwright: jump selects the right echo (route chips match the sidebar entry), path draws at zoom 10, previews truncate on unselected entries and expand on the selected one. Co-Authored-By: Claude Fable 5 --- app/static/js/path-analyzer.js | 66 +++++++++++++++++++++++++------- app/templates/path-analyzer.html | 16 ++++++++ 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index d7458c5..c224c26 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -163,7 +163,7 @@ function paCopyText(text, label) { ); } -function paBuildEchoLine(echo) { +function paBuildEchoLine(msg, echo, echoIdx) { const line = document.createElement('div'); line.className = 'pa-echo-line'; line.title = 'Click to copy route'; @@ -203,6 +203,18 @@ function paBuildEchoLine(echo) { meta.textContent = `SNR: ${snr} | ${echo.received_at || ''}`; line.appendChild(meta); + if (echo.hops > 0) { + const mapBtn = document.createElement('i'); + mapBtn.className = 'bi bi-map pa-echo-mapbtn'; + mapBtn.title = 'Show this path on the map'; + mapBtn.addEventListener('click', (e) => { + e.stopPropagation(); + paMapSelection = { msgId: msg.id, echoIdx: echoIdx }; + paSwitchView('map'); + }); + line.appendChild(mapBtn); + } + line.addEventListener('click', () => { paCopyText(echo.tokens.join(','), 'Route'); }); @@ -284,9 +296,9 @@ function paRenderTable() { const detailTd = document.createElement('td'); detailTd.className = 'pa-echo-cell'; detailTd.colSpan = 9; - for (const echo of msg.echoView) { - detailTd.appendChild(paBuildEchoLine(echo)); - } + msg.echoView.forEach((echo, echoIdx) => { + detailTd.appendChild(paBuildEchoLine(msg, echo, echoIdx)); + }); detailTr.appendChild(detailTd); body.appendChild(detailTr); @@ -603,19 +615,37 @@ function paRenderMapView() { } for (const msg of filtered) { + const selected = msg.id === paMapSelection.msgId; const entry = document.createElement('div'); - entry.className = 'pa-map-msg' + (msg.id === paMapSelection.msgId ? ' pa-selected' : ''); - entry.innerHTML = - `
- ${msg.sender || '—'} - ${paFormatTime(msg).slice(5, 16)} -
`; + entry.className = 'pa-map-msg' + (selected ? ' pa-selected' : ''); + + const head = document.createElement('div'); + head.className = 'd-flex justify-content-between gap-2'; + const senderEl = document.createElement('strong'); + senderEl.className = 'text-truncate'; + senderEl.textContent = msg.sender || '—'; + const timeEl = document.createElement('span'); + timeEl.className = 'text-muted text-nowrap'; + timeEl.textContent = paFormatTime(msg).slice(5, 16); + head.appendChild(senderEl); + head.appendChild(timeEl); + entry.appendChild(head); + + // Content preview: one truncated line; full text when selected + if (msg.content) { + const preview = document.createElement('div'); + preview.className = 'pa-map-msg-preview' + (selected ? '' : ' text-truncate'); + preview.textContent = msg.content; + preview.title = msg.content; + entry.appendChild(preview); + } + entry.addEventListener('click', () => { paMapSelection = { msgId: msg.id, echoIdx: null }; paRenderMapView(); }); - if (msg.id === paMapSelection.msgId) { + if (selected) { msg.echoView.forEach((echo, idx) => { if (echo.hops === 0) return; const eEl = document.createElement('div'); @@ -638,9 +668,17 @@ function paRenderMapView() { } paSetView('map'); - // Leaflet cannot size itself while the container was display:none - setTimeout(() => paMap.invalidateSize(), 60); - paDrawSelectedEcho(); + // Leaflet cannot size itself while the container was display:none - + // and fitBounds in paDrawSelectedEcho needs the corrected size, so + // drawing must happen after invalidateSize in the same deferred step + setTimeout(() => { + paMap.invalidateSize(); + paDrawSelectedEcho(); + }, 60); + + // Keep the selected message visible (e.g. after jumping from the table) + const selectedEl = list.querySelector('.pa-map-msg.pa-selected'); + if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' }); const counter = document.getElementById('paCounter'); counter.textContent = paFiltersActive() diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index e2a1c04..8662f87 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -273,6 +273,22 @@ color: var(--text-secondary, #6c757d); } + .pa-echo-mapbtn { + cursor: pointer; + padding: 0 0.3rem; + color: var(--text-secondary, #6c757d); + } + + .pa-echo-mapbtn:hover { + color: #6f42c1; + } + + .pa-map-msg-preview { + font-size: 0.75rem; + color: var(--text-secondary, #6c757d); + margin-top: 0.1rem; + } + .pa-direct { font-size: 0.75rem; font-style: italic; From 3522dbabef8168cdb7290e227a8cbaba6bf52eb7 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sun, 19 Jul 2026 19:42:42 +0200 Subject: [PATCH 08/21] feat(pathanalyzer): message text filter Free-text case-insensitive substring filter on message content, next to the other toolbar filters; combines with them and is reset by Clear. Verified live via Playwright: content match returns only matching rows (5/606), combines with the sender filter, Clear restores the full set. Co-Authored-By: Claude Fable 5 --- app/static/js/path-analyzer.js | 10 ++++++++-- app/templates/path-analyzer.html | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index c224c26..56eb573 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -83,7 +83,7 @@ function showNotification(message, type = 'info') { // ================================================================ let paMessages = []; -let paFilters = { hops: 'any', hashSize: 'any', token: '', sender: '' }; +let paFilters = { hops: 'any', hashSize: 'any', token: '', sender: '', content: '' }; let paCurrentView = 'messages'; // 'messages' | 'stats' let paContacts = []; // /api/contacts/cached?format=full let paStatsSort = { key: 'relayed', dir: -1 }; @@ -140,12 +140,15 @@ function paMessageMatchesFilters(msg) { if (paFilters.sender) { if (!(msg.sender || '').toLowerCase().includes(paFilters.sender)) return false; } + if (paFilters.content) { + if (!(msg.content || '').toLowerCase().includes(paFilters.content)) return false; + } return true; } function paFiltersActive() { return paFilters.hops !== 'any' || paFilters.hashSize !== 'any' - || paFilters.token !== '' || paFilters.sender !== ''; + || paFilters.token !== '' || paFilters.sender !== '' || paFilters.content !== ''; } function paFormatTime(msg) { @@ -755,6 +758,7 @@ function paReadFilters() { paFilters.token = document.getElementById('paTokenFilter').value .replace(/[^0-9a-fA-F]/g, '').toUpperCase(); paFilters.sender = document.getElementById('paSenderFilter').value.trim().toLowerCase(); + paFilters.content = document.getElementById('paContentFilter').value.trim().toLowerCase(); document.getElementById('paClearFiltersBtn').classList.toggle('d-none', !paFiltersActive()); } @@ -770,6 +774,7 @@ function paClearFilters() { document.getElementById('paHashSizeFilter').value = 'any'; document.getElementById('paTokenFilter').value = ''; document.getElementById('paSenderFilter').value = ''; + document.getElementById('paContentFilter').value = ''; paApplyFilters(); } @@ -791,6 +796,7 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('paHashSizeFilter').addEventListener('change', paApplyFilters); document.getElementById('paTokenFilter').addEventListener('input', debouncedApply); document.getElementById('paSenderFilter').addEventListener('input', debouncedApply); + document.getElementById('paContentFilter').addEventListener('input', debouncedApply); document.getElementById('paClearFiltersBtn').addEventListener('click', paClearFilters); document.getElementById('paViewMessagesBtn').addEventListener('click', () => paSwitchView('messages')); diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index 8662f87..ae36ad6 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -371,6 +371,8 @@ placeholder="Repeater hash (3B)" title="Match a repeater hash anywhere in a path (prefix match)"> + From 0ba0503547082ef83f144a4e0d22a464c035b7a5 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sun, 19 Jul 2026 19:44:32 +0200 Subject: [PATCH 09/21] feat(pathanalyzer): repeater filter matches by name as well as hash The repeater filter input now accepts either form: pure-hex input still prefix-matches hop hash tokens, and any input also matches the names of contacts each token resolves to (pubkey-prefix candidates, memoized per token). With 1-byte hashes a name can match via a colliding candidate, so the filter is intentionally inclusive: it keeps messages where the named repeater could be on the path. Verified live via Playwright: hex prefix regression OK, name search returns only messages with a token resolving to the named contact, memoized pass over 606 messages takes <1 ms. Co-Authored-By: Claude Fable 5 --- app/static/js/path-analyzer.js | 33 ++++++++++++++++++++++++++------ app/templates/path-analyzer.html | 5 +++-- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index 56eb573..3f4eb50 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -93,7 +93,10 @@ async function paLoadContacts() { const resp = await fetch('/api/contacts/cached?format=full'); if (resp.ok) { const data = await resp.json(); - if (data.success) paContacts = data.contacts || []; + if (data.success) { + paContacts = data.contacts || []; + paTokenNameCache = new Map(); + } } } catch (e) { console.error('Failed to load contacts:', e); @@ -107,6 +110,19 @@ function paMatchContacts(token) { return paContacts.filter(c => (c.public_key || '').toLowerCase().startsWith(prefix)); } +// token -> lowercase candidate contact names, memoized (contacts are +// static per page load; cache is rebuilt when they arrive) +let paTokenNameCache = new Map(); + +function paTokenNames(token) { + let names = paTokenNameCache.get(token); + if (!names) { + names = paMatchContacts(token).map(c => (c.name || '').toLowerCase()); + paTokenNameCache.set(token, names); + } + return names; +} + // Split an echo's path hex into per-hop tokens using that echo's own // hash_size (same logic as showPathsPopup in app.js; trailing partial kept) function paDecodeEcho(echo) { @@ -133,8 +149,15 @@ function paMessageMatchesFilters(msg) { if (!ok) return false; } if (paFilters.token) { - const t = paFilters.token; - const ok = msg.echoView.some(e => e.tokens.some(tok => tok.startsWith(t))); + const raw = paFilters.token; // lowercase + const isHex = /^[0-9a-f]+$/.test(raw); + const hexPrefix = raw.toUpperCase(); + // Hex input matches hash tokens by prefix; any input also matches + // the names of contacts the token resolves to + const ok = msg.echoView.some(e => e.tokens.some(tok => + (isHex && tok.startsWith(hexPrefix)) || + paTokenNames(tok).some(n => n.includes(raw)) + )); if (!ok) return false; } if (paFilters.sender) { @@ -754,9 +777,7 @@ async function paLoadMessages() { function paReadFilters() { paFilters.hops = document.getElementById('paHopsFilter').value; paFilters.hashSize = document.getElementById('paHashSizeFilter').value; - // Normalize token input to hex characters only (matches chip display casing) - paFilters.token = document.getElementById('paTokenFilter').value - .replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + paFilters.token = document.getElementById('paTokenFilter').value.trim().toLowerCase(); paFilters.sender = document.getElementById('paSenderFilter').value.trim().toLowerCase(); paFilters.content = document.getElementById('paContentFilter').value.trim().toLowerCase(); document.getElementById('paClearFiltersBtn').classList.toggle('d-none', !paFiltersActive()); diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index ae36ad6..ecde3ef 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -367,8 +367,9 @@ - + Date: Sun, 19 Jul 2026 21:53:57 +0200 Subject: [PATCH 10/21] fix(pathanalyzer): Messages and Repeaters views fit narrow screens Rather than a card-per-row transform (too much scrolling for 400+ messages), narrow viewports (<=576px) keep the table but shed secondary width: - Hash, HB and Echoes columns hidden; packet hash + HB surface as a line at the top of the expanded detail row instead (still copyable) - time renders as short date/time stacked on two lines (full timestamp on wide screens) - sender/channel/message-preview columns capped with ellipsis, tighter cell padding, headers allowed to wrap - stats view: Messages and As-last-hop columns hidden on phones (Repeater, Contact, Relayed, Avg SNR remain), contact names truncated - expanded echo paths now wrap fully within the viewport, keeping every hop chip and the jump-to-map button reachable Root cause of the first attempt failing: the media block sat mid- stylesheet, so later equal-specificity base rules overrode it - it now sits last, with a comment pinning it there. Verified live via Playwright at 390px: zero horizontal overflow in Messages (incl. an expanded 20-hop path, map button on-screen) and Repeaters; desktop 1280px layout unchanged. Co-Authored-By: Claude Fable 5 --- app/static/js/path-analyzer.js | 31 +++++++-- app/templates/path-analyzer.html | 107 +++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 10 deletions(-) diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index 3f4eb50..a8dd7be 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -263,7 +263,10 @@ function paRenderTable() { const tdTime = document.createElement('td'); tdTime.className = 'pa-time'; - tdTime.textContent = paFormatTime(msg); + const full = paFormatTime(msg); + tdTime.innerHTML = ``; + tdTime.querySelector('.pa-time-full').textContent = full; + tdTime.querySelector('.pa-time-short').textContent = full === '—' ? '—' : full.slice(5, 16); tr.appendChild(tdTime); const tdChannel = document.createElement('td'); @@ -283,6 +286,7 @@ function paRenderTable() { tr.appendChild(tdContent); const tdHash = document.createElement('td'); + tdHash.className = 'pa-col-hash'; if (msg.packet_hash) { const span = document.createElement('span'); span.className = 'pa-hash'; @@ -304,13 +308,13 @@ function paRenderTable() { tr.appendChild(tdHops); const tdHb = document.createElement('td'); - tdHb.className = 'text-end'; + tdHb.className = 'text-end pa-col-hb'; const hbSizes = [...new Set(msg.echoView.filter(e => e.hops > 0).map(e => e.hash_size || 1))].sort(); tdHb.textContent = hbSizes.length > 0 ? hbSizes.join(',') : '—'; tr.appendChild(tdHb); const tdEchoes = document.createElement('td'); - tdEchoes.className = 'text-end'; + tdEchoes.className = 'text-end pa-col-echoes'; tdEchoes.textContent = msg.echoView.length; tr.appendChild(tdEchoes); @@ -322,6 +326,23 @@ function paRenderTable() { const detailTd = document.createElement('td'); detailTd.className = 'pa-echo-cell'; detailTd.colSpan = 9; + if (msg.packet_hash) { + // Narrow screens hide the Hash/HB columns - surface both here + const hashLine = document.createElement('div'); + hashLine.className = 'pa-detail-hash'; + const hashSpan = document.createElement('span'); + hashSpan.className = 'pa-hash'; + hashSpan.textContent = msg.packet_hash; + hashSpan.title = 'Click to copy'; + hashSpan.addEventListener('click', (e) => { + e.stopPropagation(); + paCopyText(msg.packet_hash, 'Packet hash'); + }); + hashLine.append('Hash: '); + hashLine.appendChild(hashSpan); + if (hbSizes.length > 0) hashLine.append(` | HB: ${hbSizes.join(',')}`); + detailTd.appendChild(hashLine); + } msg.echoView.forEach((echo, echoIdx) => { detailTd.appendChild(paBuildEchoLine(msg, echo, echoIdx)); }); @@ -438,9 +459,9 @@ function paRenderStats() { } tr.appendChild(tdContact); - for (const val of [s.relayed, s.messages, s.lastHop]) { + for (const [val, cls] of [[s.relayed, ''], [s.messages, ' pa-col-msgs'], [s.lastHop, ' pa-col-lasthop']]) { const td = document.createElement('td'); - td.className = 'text-end'; + td.className = 'text-end' + cls; td.textContent = val; tr.appendChild(td); } diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index ecde3ef..ffc72bf 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -166,6 +166,18 @@ color: #212529; } + /* Packet hash inside the expanded detail row - narrow screens only, + where the Hash column is hidden */ + .pa-detail-hash { + display: none; + font-size: 0.75rem; + padding-bottom: 0.2rem; + } + + .pa-time-short { + display: none; + } + .pa-table-wrap { overflow-x: auto; } @@ -324,6 +336,91 @@ display: block; margin-bottom: 0.75rem; } + + /* Narrow screens: drop secondary columns so rows fit the viewport + and echo paths can wrap (keeps the jump-to-map button reachable). + Hash and HB move into the expanded detail row. Must stay LAST in + this stylesheet so it overrides the base rules above. */ + @media (max-width: 576px) { + .pa-col-hash, + .pa-col-hb, + .pa-col-msgs, + .pa-col-lasthop { + display: none; + } + + .pa-content-preview { + max-width: 9rem; + } + + .pa-time-full { + display: none; + } + + .pa-time-short { + display: inline; + } + + .pa-detail-hash { + display: block; + } + + .pa-echo-cell { + padding-left: 0.75rem; + } + + /* Long contact names must not push the SNR column off-screen */ + #paStatsBody td:nth-child(2) { + max-width: 8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .pa-col-echoes { + display: none; + } + + .pa-table td, + .pa-table th { + padding: 0.3rem 0.2rem; + } + + /* Truncate long channel names */ + #paTableBody td:nth-child(3) { + max-width: 3.8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* Let long headers (Avg SNR...) wrap instead of widening columns */ + .pa-table th { + white-space: normal; + } + + /* Truncate long sender names */ + #paTableBody td:nth-child(4) { + max-width: 5.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .pa-content-preview { + max-width: 5.2rem; + } + + /* Date and time stack into two lines */ + .pa-time { + white-space: normal; + } + + .pa-time-short { + display: inline-block; + width: 3.2rem; + } + } @@ -399,10 +496,10 @@ Channel Sender Message - Hash + Hash Hops - HB - Echoes + HB + Echoes @@ -416,9 +513,9 @@ Contact Relayed - Messages - As last hop Avg SNR (last hop) From 7bcd0ef46b2e3facc833baf9ba46927ced886632 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sun, 19 Jul 2026 22:00:53 +0200 Subject: [PATCH 11/21] docs: cover the Path Analyzer feature - user-guide.md: new Path Analyzer section (opening, filters, the Messages/Repeaters/Map views, phone layout notes, local-view caveat); TOC entry; per-item placement action count 12 -> 13 with Path Analyzer added to the enumeration - architecture.md: /api/path-analyzer/messages added to the Messages endpoint table plus a Path Analyzer design section (batched echo fetch, client-side filtering rationale, SNR attribution, hash -> contact resolution, legacy-row degradation) - whatsnew.md: three user-facing bullets under Unreleased (the tool, the combined filters, phone support) No deploy notes needed: no new dependencies, no schema changes. Co-Authored-By: Claude Fable 5 --- docs/architecture.md | 11 ++++++++ docs/user-guide.md | 64 ++++++++++++++++++++++++++++++++++++++++++-- docs/whatsnew.md | 3 +++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b3f4b55..ae5322b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,6 +105,16 @@ The `/repeaters` (list) and `/repeaters/manage` (per-repeater tools) panels are - **Role gating** — the firmware silently drops text CLI from non-admin logins (which would surface as a timeout), so the CLI/Settings/Actions endpoints reject guest sessions with 403 up front instead - **Actions whitelist** — `advert.zerohop`, `advert` (flood), `clock sync`, `reboot`. `reboot` never replies (the firmware restarts immediately without building one), so a clean send followed by silence is reported as success. Text `erase` is firmware-gated to the USB serial console (`sender_timestamp == 0`), hence no erase in the UI +### Path Analyzer + +The `/path-analyzer` panel (standalone iframe page, `path-analyzer.js`) is a read-only analysis view over data the app already collects — no new tables, no background work: + +- **Data source** — `GET /api/path-analyzer/messages?days=N` joins `channel_messages` with `echoes` (path hex + SNR + per-echo `hash_size`, keyed by `pkt_payload`). Echoes are fetched with `db.get_echoes_for_payloads()` — chunked `IN` queries (≤500 params, under SQLite's host-parameter limit) via `idx_echoes_pkt` — deliberately avoiding the per-message echo query the older `/api/messages` path still does. The pkt_payload reconstruction (raw_json text → channel-secret AES/HMAC compute) is shared with `/api/messages` via the `_get_row_pkt_payload()` helper +- **All filtering/stats/map logic is client-side** over the bulk payload (hundreds of KB for 7 days — fine on a LAN): filters operate on per-hop tokens split with each echo's own `hash_size` (mixed 1/2/3-byte networks are real), so SQL-side token filtering was rejected. Stats and map always reflect the active filters for free +- **SNR attribution** — echo SNR is measured at our receiver, so stats credit it to the *final* hop only; intermediate hops get relay counts but never SNR +- **Hash→contact resolution** — pubkey-prefix match against `/api/contacts/cached?format=full` (memoized per token). 1-byte hashes collide by design; the map renders unresolved hops as amber candidate markers with manual pick, and the repeater-name filter is intentionally inclusive over candidates +- Legacy rows whose `pkt_payload` cannot be recomputed (missing channel secret) are returned with `packet_hash: null` and no echoes rather than dropped + --- ## Project Structure @@ -197,6 +207,7 @@ The channels API reads from the `channels` DB table rather than iterating device | GET | `/api/messages//meta` | Get message metadata (echoes, paths) | | POST | `/api/messages//resend` | Re-broadcast an own channel message verbatim via `CMD_SEND_RAW_PACKET` (same packet hash, so unreached repeaters pick it up). 400 for not-own / missing `raw_packet` snapshot / disconnected / firmware < 1.16, 404 for unknown id | | GET | `/api/messages/search` | Full-text search (`?q=`, `?channel_idx=`, `?limit=`) | +| GET | `/api/path-analyzer/messages` | Bulk channel messages across **all** channels with batched echo data (`?days=1..30`, default 3); powers the Path Analyzer panel (`GET /path-analyzer`) | ### Contacts diff --git a/docs/user-guide.md b/docs/user-guide.md index d7ca1ec..235f3dc 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -17,6 +17,7 @@ This guide covers all features and functionality of mc-webui. For installation i - [DM Path Management](#dm-path-management) - [Interactive Console](#interactive-console) - [My Repeaters (Repeater Administration)](#my-repeaters-repeater-administration) +- [Path Analyzer](#path-analyzer) - [Device Dashboard](#device-dashboard) - [Quick-Access FAB Buttons](#quick-access-fab-buttons) - [Settings](#settings) @@ -613,6 +614,65 @@ Erasing the repeater's file system is **not** available over the mesh — the fi --- +## Path Analyzer + +A full-screen tool for analyzing how channel messages travel through the mesh: which repeaters relayed them, how strong the signal was, and what the routes look like on a map. + +To open: +1. Tap the hamburger menu (☰) +2. Select **Path Analyzer** from the menu + +Pick a time range at the top (last 1, 3, 5, or 7 days) — the tool loads every channel message from **all** your channels in that window, together with every copy (echo) of each message your node overheard. Everything below works on that data set; no extra loading. + +### Filters + +The filter bar applies to all three views at once and updates as you type: + +- **Hops** - Only messages that arrived over exactly that many hops (0 = heard directly, 4+ = long routes). A message matches when *any* of its echoes has that hop count +- **HB (path hash size)** - Only messages whose route was recorded with 1-, 2-, or 3-byte repeater hashes +- **Repeater** - Type a repeater hash (e.g. `3B`) or part of a repeater's **name** (e.g. `wegrzce`); matches messages whose route passed through it. Note that with short 1-byte hashes two repeaters can share a hash, so a name search includes routes where the named repeater *might* be one of the candidates +- **Sender** - Part of the sender's name +- **Message text** - Part of the message content + +A counter shows how much of the data set matches ("38 of 412 messages"), and **Clear** resets everything. + +### Messages view + +A table of messages: time, channel, sender, text, packet hash (click to copy — the same hash analyzer services use), hop count, hash size, and echo count. Click a row to expand its routes: + +- Every echo is shown as a chain of repeater hashes (`5A → F0 → 90`) with its SNR and receive time +- Click a single hash chip to copy that hash; click the rest of the line to copy the whole route +- The small map icon at the end of each route jumps straight to the **Map** view with that route drawn +- Echoes with an empty route show as "Direct (flood, 0 hops)" + +On phones the Hash, HB, and Echoes columns fold away to keep the table readable — the packet hash and hash size appear at the top of the expanded row instead, and long routes wrap across multiple lines. + +### Repeaters view + +Per-repeater statistics computed from the currently filtered messages: + +- **Relayed** - How many echoes passed through this repeater hash (anywhere in the route) +- **Messages** - How many distinct messages that was +- **As last hop** - How often this repeater was the final hop, i.e. the one your node heard directly +- **Avg SNR (last hop)** - Average signal quality, counted *only* when the repeater was the final hop. Your node can only measure the radio link it actually receives on, so SNR is never attributed to repeaters in the middle of a route — that's why some rows show "—" + +Columns are sortable, and the **Contact** column matches each hash to your contact list (showing "ambiguous (n)" when several contacts share a short hash). Click any row to jump back to the Messages view filtered to that repeater. + +### Map view + +Repeaters from your contact list that have a position are plotted as dots. Pick a message in the side list (it shows sender, time, and the message text), then pick one of its routes — the path is drawn hop by hop: + +- Hops that resolve to exactly one known repeater become solid points connected by a line +- When a short hash matches several contacts, all candidates are marked in amber and the legend lists them — tap the right one and the path redraws with your choice +- Unknown hops (no matching contact, or no position) are listed in the legend and the line is drawn dashed across the gap, so you can see which parts of the route are certain +- If the sender is in your contacts with a position, it is added as a green origin point + +The eraser button clears the drawn path. On phones the message list moves above the map. + +Everything in the Path Analyzer is based on what **your node** overheard — it's a local view of the mesh, not a global one. A route you don't see here may still exist; it just never reached your radio. + +--- + ## Device Dashboard Access device information and statistics: @@ -665,7 +725,7 @@ Tap the toggle button (short click, no drag) to hide or show the rest of the FAB Open Settings → **Appearance** tab to adjust: - **Hide Quick Access** - Master switch: hides the FAB cluster entirely and moves all actions to the Main Menu -- **Per-item placement** - Choose whether each of the 12 actions appears in the FAB or in the Main Menu. Changes take effect immediately +- **Per-item placement** - Choose whether each of the 13 actions appears in the FAB or in the Main Menu. Changes take effect immediately - **Button size** - 28 to 72 pixels (default: 56) - **Spacing** - 2 to 24 pixels between buttons (default: 12) - **Reset position** - Reset both main chat and DM FAB positions to their defaults @@ -751,7 +811,7 @@ Controls small notification toasts shown after actions (e.g. "Advert Sent", erro **Quick Access Buttons:** - **Hide Quick Access** - Master switch that hides the entire floating FAB cluster and moves all items to the Main Menu (slide-out) -- **Per-item placement** - A table of all 12 configurable actions (Filter messages, Search messages, Direct Messages, Contact Management, Settings, Send Advert, Flood Advert, Map, Console, My Repeaters, Device Info, System Log). Each row has two radio buttons: **Quick Access** (shows in FAB) and **Main Menu** (shows in the slide-out). Changes take effect immediately +- **Per-item placement** - A table of all 13 configurable actions (Filter messages, Search messages, Direct Messages, Contact Management, Settings, Send Advert, Flood Advert, Map, Console, My Repeaters, Path Analyzer, Device Info, System Log). Each row has two radio buttons: **Quick Access** (shows in FAB) and **Main Menu** (shows in the slide-out). Changes take effect immediately - **Button size (px)** - Adjust the size of FAB buttons (default: 56) - **Spacing (px)** - Space between FAB buttons (default: 12) - **Position** - Reset FAB position to default (top-right) diff --git a/docs/whatsnew.md b/docs/whatsnew.md index cc807aa..e16dfb5 100644 --- a/docs/whatsnew.md +++ b/docs/whatsnew.md @@ -15,6 +15,9 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g - **Remote CLI, Settings, and Actions (admin logins).** **CLI** is a real terminal to the repeater — quick-command chips, per-repeater history, round-trip times. **Settings** edits the repeater configuration in collapsible sections (Basic, Radio, Location, Features, Network health, Advertisement, Operator info, Advanced): values load live from the repeater, only the fields you change are sent, and every field reports back individually — including a "reboot required" badge for radio parameters and the firmware's own error text for rejected values. **Actions** covers zero-hop advert, flood advert (marked "not recommended" — high network load), clock sync, and a confirmation-guarded reboot in a Danger zone. Erasing the file system stays USB-serial-only by firmware design, and the panel says so. - **Observer mode — feed packet analyzers straight from mc-webui.** The new **Settings → Observer** tab turns your node into a MeshCore observer: every packet the device overhears is published to one or more MQTT brokers in the standard `meshcore-packet-capture` format, so analyzer services (a self-hosted Corescope, letsmesh-style maps) see your local mesh traffic without a separate capture script or a dedicated second node. Configure brokers with host/port, optional username/password and TLS; each row shows a live connected/error badge, and the tab counts packets captured vs published in real time. Capture is completely passive — chat and direct messages are unaffected — and all changes apply immediately, no restart needed. (LetsMesh token-authenticated brokers are not supported yet.) - **Scheduled flood adverts.** The Observer tab includes an optional advert interval in hours: the app sends a flood advert on that schedule so your observer stays visible on analyzer maps. The timer survives restarts, so a redeploy won't send an extra advert early. Set it to 0 to keep adverts fully manual. +- **Path Analyzer — see how your messages travel the mesh.** A new full-screen tool (main menu → **Path Analyzer**) loads every channel message from all your channels for a chosen window (1–7 days) together with every copy your node overheard, and lets you dig in three ways. **Messages**: expand any message to see all its routes hop by hop with SNR — copy a repeater hash, copy the whole route, or jump straight to the map. **Repeaters**: per-repeater statistics (how many packets each hash relayed, over how many messages, and average SNR when it was the hop you actually heard), sortable and clickable to filter. **Map**: pick a message, pick a route, and it's drawn on the map point by point — when a short 1-byte hash matches several repeaters, the candidates are shown in amber and you pick the right one instead of the app guessing, and uncertain segments are drawn dashed so you always know which part of a route is confirmed. +- **Path Analyzer filters.** Everything is filterable live and in combination: hop count, path-hash size (1/2/3-byte), repeater — by hash **or by name** — sender, and message text. All three views (including the statistics and the map's message list) follow the active filters. +- **Path Analyzer works on phones.** On narrow screens the tables shed their secondary columns (packet hash and hash size move into the expanded row), long routes wrap instead of scrolling sideways, and the map's message list stacks above the map. ### Reliability & polish From 743969291ed97a3bd611ed3fe77a6beaea5dc844 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sun, 19 Jul 2026 22:37:29 +0200 Subject: [PATCH 12/21] perf(api): batch echo enrichment in /api/messages Replace per-message get_echoes_for_message() calls (N+1, up to 500 queries per request) with a single get_echoes_for_payloads() batch query. With connection-per-call over a Windows bind mount each query cost ~66 ms, making the endpoint take 33 s and time out in the UI; now it completes in ~0.5 s. Co-Authored-By: Claude Fable 5 --- app/routes/api.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/app/routes/api.py b/app/routes/api.py index 3ed89bb..6e131ab 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -527,18 +527,23 @@ def get_messages(): 'pkt_payload': pkt_payload, } - # Enrich with echo data and packet hash (frontend builds analyzer URL) if pkt_payload: msg['packet_hash'] = compute_packet_hash(pkt_payload) - echoes = db.get_echoes_for_message(pkt_payload) - if echoes: - msg['echo_count'] = len(echoes) - msg['echo_paths'] = [e.get('path', '') for e in echoes if e.get('path')] - msg['echo_snrs'] = [e.get('snr') for e in echoes if e.get('snr') is not None] - msg['echo_hash_sizes'] = [e.get('hash_size', 1) for e in echoes if e.get('path')] messages.append(msg) + # Enrich with echo data in one batch query (per-message queries are + # prohibitively slow on connection-per-call over bind mounts) + payloads = [m['pkt_payload'] for m in messages if m.get('pkt_payload')] + echoes_by_payload = db.get_echoes_for_payloads(payloads) + for msg in messages: + echoes = echoes_by_payload.get(msg.get('pkt_payload')) + if echoes: + msg['echo_count'] = len(echoes) + msg['echo_paths'] = [e.get('path', '') for e in echoes if e.get('path')] + msg['echo_snrs'] = [e.get('snr') for e in echoes if e.get('snr') is not None] + msg['echo_hash_sizes'] = [e.get('hash_size', 1) for e in echoes if e.get('path')] + # Filter out blocked contacts' messages blocked_names = db.get_blocked_contact_names() if blocked_names: From c1853fe8d7e61d3c7ee37be230404cc6d3af513b Mon Sep 17 00:00:00 2001 From: MarekWo Date: Mon, 20 Jul 2026 11:46:32 +0200 Subject: [PATCH 13/21] feat(pathanalyzer): map path styling and manual-pick undo - Drawn paths now use red (#dc3545) for hop markers and polylines, clearly distinct from the purple base repeater dots (origin stays green, ambiguous candidates amber) - Resolved hops render as numbered badges matching the legend order, with a permanent name label next to each point (origin included) - Manual candidate assignments are reversible: a picked hop shows an undo icon in the legend, and a 'Reset picks' button clears every manual assignment on the current path Verified live via Playwright: line/marker colors, badge numbers and name labels, pick -> per-hop undo -> re-pick -> reset-all flow, no page errors. Co-Authored-By: Claude Fable 5 --- app/static/js/path-analyzer.js | 56 +++++++++++++++++++++++++++++--- app/templates/path-analyzer.html | 37 +++++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index a8dd7be..a548610 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -502,6 +502,19 @@ function paRenderStats() { let paMap = null; let paBaseLayer = null; // repeater contact markers let paPathLayer = null; // drawn path for the selected echo + +// Path drawing color - distinct from the purple base markers so the +// route stands out (origin stays green, ambiguous candidates amber) +const PA_PATH_COLOR = '#dc3545'; + +function paHopIcon(n) { + return L.divIcon({ + className: 'pa-hop-icon', + html: `
${n}
`, + iconSize: [22, 22], + iconAnchor: [11, 11], + }); +} let paMapSelection = { msgId: null, echoIdx: null }; let paPicks = {}; // token -> public_key chosen by the user (collision disambiguation) @@ -560,16 +573,19 @@ function paDrawSelectedEcho() { if (origin) { L.circleMarker([origin.adv_lat, origin.adv_lon], { radius: 7, color: '#198754', weight: 2, fillOpacity: 0.8 - }).bindPopup(`${origin.name}
Origin (sender)`).addTo(paPathLayer); + }).bindPopup(`${origin.name}
Origin (sender)`) + .bindTooltip(origin.name, { permanent: true, direction: 'right', offset: [8, 0], className: 'pa-hop-label' }) + .addTo(paPathLayer); points.push({ latlng: [origin.adv_lat, origin.adv_lon], gapBefore: false }); } echo.tokens.forEach((tok, i) => { const { contact, candidates } = paResolveHop(tok); if (contact) { - L.circleMarker([contact.adv_lat, contact.adv_lon], { - radius: 7, color: '#6f42c1', weight: 2, fillOpacity: 0.9 - }).bindPopup(`${contact.name}
Hop ${i + 1}: ${tok}`).addTo(paPathLayer); + L.marker([contact.adv_lat, contact.adv_lon], { icon: paHopIcon(i + 1) }) + .bindPopup(`${contact.name}
Hop ${i + 1}: ${tok}`) + .bindTooltip(contact.name, { permanent: true, direction: 'right', offset: [13, 0], className: 'pa-hop-label' }) + .addTo(paPathLayer); points.push({ latlng: [contact.adv_lat, contact.adv_lon], gapBefore: gapPending }); gapPending = false; } else { @@ -586,7 +602,7 @@ function paDrawSelectedEcho() { // Polyline: solid between consecutive resolved hops, dashed across skipped ones for (let i = 1; i < points.length; i++) { L.polyline([points[i - 1].latlng, points[i].latlng], { - color: '#6f42c1', weight: 3, opacity: 0.8, + color: PA_PATH_COLOR, weight: 3, opacity: 0.85, dashArray: points[i].gapBefore ? '6 8' : null }).addTo(paPathLayer); } @@ -602,6 +618,24 @@ function paDrawSelectedEcho() { function paBuildLegend(echo) { const legend = document.createElement('div'); legend.className = 'pa-legend'; + + // Path-level reset for manual candidate assignments + if (echo.tokens.some(tok => paPicks[tok])) { + const resetRow = document.createElement('div'); + resetRow.className = 'text-end'; + const resetBtn = document.createElement('button'); + resetBtn.className = 'btn btn-sm btn-outline-warning py-0 pa-reset-picks'; + resetBtn.innerHTML = ' Reset picks'; + resetBtn.title = 'Clear all manual repeater assignments on this path'; + resetBtn.addEventListener('click', (e) => { + e.stopPropagation(); + echo.tokens.forEach(tok => { delete paPicks[tok]; }); + paRenderMapView(); + }); + resetRow.appendChild(resetBtn); + legend.appendChild(resetRow); + } + echo.tokens.forEach((tok, i) => { const row = document.createElement('div'); row.className = 'pa-legend-hop'; @@ -613,6 +647,18 @@ function paBuildLegend(echo) { const name = document.createElement('span'); name.textContent = '— ' + contact.name; row.appendChild(name); + if (paPicks[tok]) { + // Manually assigned - allow undoing just this hop + const undo = document.createElement('i'); + undo.className = 'bi bi-x-circle pa-unpick'; + undo.title = 'Undo this manual assignment'; + undo.addEventListener('click', (e) => { + e.stopPropagation(); + delete paPicks[tok]; + paRenderMapView(); + }); + row.appendChild(undo); + } } else if (candidates.length > 1) { const amb = document.createElement('span'); amb.className = 'pa-ambiguous'; diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index ffc72bf..01ed5db 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -166,6 +166,43 @@ color: #212529; } + .pa-unpick { + cursor: pointer; + color: var(--text-secondary, #6c757d); + } + + .pa-unpick:hover { + color: #dc3545; + } + + .pa-reset-picks { + font-size: 0.72rem; + } + + /* Numbered hop markers + name labels on the drawn path */ + .pa-hop-badge { + width: 22px; + height: 22px; + border-radius: 50%; + background-color: #dc3545; + color: #fff; + border: 2px solid #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 700; + } + + .pa-hop-label { + font-size: 0.7rem; + padding: 0 4px; + background: rgba(255, 255, 255, 0.85); + border: 1px solid rgba(0, 0, 0, 0.2); + box-shadow: none; + } + /* Packet hash inside the expanded detail row - narrow screens only, where the Hash column is hidden */ .pa-detail-hash { From 91b959aae4b0091ede0de27cb51be051e6503877 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Mon, 20 Jul 2026 11:47:06 +0200 Subject: [PATCH 14/21] docs: map path colors, numbered hop points, pick undo Update the Path Analyzer map descriptions in user-guide.md and whatsnew.md for the red numbered/name-labeled path points and the reversible candidate assignments (per-hop undo + Reset picks). Co-Authored-By: Claude Fable 5 --- docs/user-guide.md | 4 ++-- docs/whatsnew.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 235f3dc..6f87b54 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -662,8 +662,8 @@ Columns are sortable, and the **Contact** column matches each hash to your conta Repeaters from your contact list that have a position are plotted as dots. Pick a message in the side list (it shows sender, time, and the message text), then pick one of its routes — the path is drawn hop by hop: -- Hops that resolve to exactly one known repeater become solid points connected by a line -- When a short hash matches several contacts, all candidates are marked in amber and the legend lists them — tap the right one and the path redraws with your choice +- Hops that resolve to exactly one known repeater become red numbered points (the number matches the legend order) labeled with the repeater's name, connected by a red line — clearly distinct from the purple background dots of uninvolved repeaters +- When a short hash matches several contacts, all candidates are marked in amber and the legend lists them — tap the right one and the path redraws with your choice. Assignments are reversible: an undo icon next to a manually assigned hop reverts just that hop, and **Reset picks** clears every manual assignment on the current path - Unknown hops (no matching contact, or no position) are listed in the legend and the line is drawn dashed across the gap, so you can see which parts of the route are certain - If the sender is in your contacts with a position, it is added as a green origin point diff --git a/docs/whatsnew.md b/docs/whatsnew.md index e16dfb5..afd38ba 100644 --- a/docs/whatsnew.md +++ b/docs/whatsnew.md @@ -15,7 +15,7 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g - **Remote CLI, Settings, and Actions (admin logins).** **CLI** is a real terminal to the repeater — quick-command chips, per-repeater history, round-trip times. **Settings** edits the repeater configuration in collapsible sections (Basic, Radio, Location, Features, Network health, Advertisement, Operator info, Advanced): values load live from the repeater, only the fields you change are sent, and every field reports back individually — including a "reboot required" badge for radio parameters and the firmware's own error text for rejected values. **Actions** covers zero-hop advert, flood advert (marked "not recommended" — high network load), clock sync, and a confirmation-guarded reboot in a Danger zone. Erasing the file system stays USB-serial-only by firmware design, and the panel says so. - **Observer mode — feed packet analyzers straight from mc-webui.** The new **Settings → Observer** tab turns your node into a MeshCore observer: every packet the device overhears is published to one or more MQTT brokers in the standard `meshcore-packet-capture` format, so analyzer services (a self-hosted Corescope, letsmesh-style maps) see your local mesh traffic without a separate capture script or a dedicated second node. Configure brokers with host/port, optional username/password and TLS; each row shows a live connected/error badge, and the tab counts packets captured vs published in real time. Capture is completely passive — chat and direct messages are unaffected — and all changes apply immediately, no restart needed. (LetsMesh token-authenticated brokers are not supported yet.) - **Scheduled flood adverts.** The Observer tab includes an optional advert interval in hours: the app sends a flood advert on that schedule so your observer stays visible on analyzer maps. The timer survives restarts, so a redeploy won't send an extra advert early. Set it to 0 to keep adverts fully manual. -- **Path Analyzer — see how your messages travel the mesh.** A new full-screen tool (main menu → **Path Analyzer**) loads every channel message from all your channels for a chosen window (1–7 days) together with every copy your node overheard, and lets you dig in three ways. **Messages**: expand any message to see all its routes hop by hop with SNR — copy a repeater hash, copy the whole route, or jump straight to the map. **Repeaters**: per-repeater statistics (how many packets each hash relayed, over how many messages, and average SNR when it was the hop you actually heard), sortable and clickable to filter. **Map**: pick a message, pick a route, and it's drawn on the map point by point — when a short 1-byte hash matches several repeaters, the candidates are shown in amber and you pick the right one instead of the app guessing, and uncertain segments are drawn dashed so you always know which part of a route is confirmed. +- **Path Analyzer — see how your messages travel the mesh.** A new full-screen tool (main menu → **Path Analyzer**) loads every channel message from all your channels for a chosen window (1–7 days) together with every copy your node overheard, and lets you dig in three ways. **Messages**: expand any message to see all its routes hop by hop with SNR — copy a repeater hash, copy the whole route, or jump straight to the map. **Repeaters**: per-repeater statistics (how many packets each hash relayed, over how many messages, and average SNR when it was the hop you actually heard), sortable and clickable to filter. **Map**: pick a message, pick a route, and it's drawn on the map as a red line with numbered, name-labeled points — when a short 1-byte hash matches several repeaters, the candidates are shown in amber and you pick the right one instead of the app guessing (a mistaken pick can be undone per hop or reset for the whole path), and uncertain segments are drawn dashed so you always know which part of a route is confirmed. - **Path Analyzer filters.** Everything is filterable live and in combination: hop count, path-hash size (1/2/3-byte), repeater — by hash **or by name** — sender, and message text. All three views (including the statistics and the map's message list) follow the active filters. - **Path Analyzer works on phones.** On narrow screens the tables shed their secondary columns (packet hash and hash size move into the expanded row), long routes wrap instead of scrolling sideways, and the map's message list stacks above the map. From 19c35f4a2bccd6057b86c1f8d84ae0f374e359a9 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Mon, 20 Jul 2026 12:14:50 +0200 Subject: [PATCH 15/21] feat(pathanalyzer): combined 2/3-byte option in the HB filter The path-hash-size filter accepts a comma list of sizes; a new '2/3-byte' option matches messages with any routed echo using a 2- or 3-byte hash. Verified live: 2/3-byte returns exactly the union of the 2-byte and 3-byte sets (220 = 208 + 12 with zero overlap misses on live data). Co-Authored-By: Claude Fable 5 --- app/static/js/path-analyzer.js | 7 ++++--- app/templates/path-analyzer.html | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index a548610..824b5a1 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -143,9 +143,10 @@ function paMessageMatchesFilters(msg) { if (!ok) return false; } if (paFilters.hashSize !== 'any') { - const want = parseInt(paFilters.hashSize, 10); - // Only routed echoes carry a meaningful hash size - const ok = msg.echoView.some(e => e.hops > 0 && (e.hash_size || 1) === want); + // Value is a comma list of accepted sizes (e.g. "2" or "2,3"); + // only routed echoes carry a meaningful hash size + const wanted = paFilters.hashSize.split(',').map(Number); + const ok = msg.echoView.some(e => e.hops > 0 && wanted.includes(e.hash_size || 1)); if (!ok) return false; } if (paFilters.token) { diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index 01ed5db..17b6c05 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -500,6 +500,7 @@ + Date: Mon, 20 Jul 2026 12:15:12 +0200 Subject: [PATCH 16/21] docs: mention the combined 2/3-byte HB filter option Co-Authored-By: Claude Fable 5 --- docs/user-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 6f87b54..41c4509 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -629,7 +629,7 @@ Pick a time range at the top (last 1, 3, 5, or 7 days) — the tool loads every The filter bar applies to all three views at once and updates as you type: - **Hops** - Only messages that arrived over exactly that many hops (0 = heard directly, 4+ = long routes). A message matches when *any* of its echoes has that hop count -- **HB (path hash size)** - Only messages whose route was recorded with 1-, 2-, or 3-byte repeater hashes +- **HB (path hash size)** - Only messages whose route was recorded with 1-, 2-, or 3-byte repeater hashes; a combined **2/3-byte** option covers both larger sizes at once - **Repeater** - Type a repeater hash (e.g. `3B`) or part of a repeater's **name** (e.g. `wegrzce`); matches messages whose route passed through it. Note that with short 1-byte hashes two repeaters can share a hash, so a name search includes routes where the named repeater *might* be one of the candidates - **Sender** - Part of the sender's name - **Message text** - Part of the message content From f54dc1d4810fef16a28d2c64726e1792e26c0d04 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Tue, 21 Jul 2026 20:27:13 +0200 Subject: [PATCH 17/21] feat(repeaters): prefill saved password on login retry, map location picker, tidy list - Login retry prompt now prefills the saved password (a wrong stored password and an unreachable repeater are indistinguishable, so a connection-caused failure no longer forces retyping). Adds GET /api/repeaters//password for the trusted local UI. - Repeater list: "last login" moves to its own line so a long path keeps the full row width on narrow phones. - Settings -> Location: "Pick from map" button opens a Leaflet picker; clicking the map fills lat/lon and marks the section dirty. Co-Authored-By: Claude Opus 4.8 --- app/routes/api.py | 22 +++++++ app/static/js/repeater-manage.js | 98 ++++++++++++++++++++++++++++++ app/static/js/repeaters.js | 28 +++++++-- app/templates/repeater-manage.html | 23 +++++++ 4 files changed, 167 insertions(+), 4 deletions(-) diff --git a/app/routes/api.py b/app/routes/api.py index 6e131ab..d61f92a 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -6395,6 +6395,28 @@ def delete_my_repeater(public_key): return jsonify({'success': False, 'error': str(e)}), 500 +@api_bp.route('/repeaters//password', methods=['GET']) +def get_my_repeater_password(public_key): + """Return the saved password for a repeater (empty string when none). + + Used to prefill the login-retry prompt: a wrong stored password and an + unreachable repeater are indistinguishable, so on a failed auto-login we + hand the (correct) saved password back to the same trusted local UI rather + than force the user to retype it. Passwords are already stored so the app + can log in on the user's behalf, so this stays within the local-app scope. + """ + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 503 + pk = _normalize_repeater_key(public_key) + if not pk: + return jsonify({'success': False, 'error': 'Invalid public_key'}), 400 + row = db.get_repeater(pk) + if not row: + return jsonify({'success': False, 'error': 'Repeater not in list'}), 404 + return jsonify({'success': True, 'password': row.get('password') or ''}), 200 + + @api_bp.route('/repeaters//login', methods=['POST']) def login_my_repeater(public_key): """Log into a repeater using the provided or saved password. diff --git a/app/static/js/repeater-manage.js b/app/static/js/repeater-manage.js index 7288e60..e75257e 100644 --- a/app/static/js/repeater-manage.js +++ b/app/static/js/repeater-manage.js @@ -1130,6 +1130,11 @@ function renderSettingsPane(body) { Some changes take effect after a reboot — use Actions → Reboot.
${sec.fields.map(settingsFieldHtml).join('')}
+ ${sec.key === 'location' ? ` + ` : ''}
+ + +
-
- - - -
- - - - - - +
+
+ + + +
+ + + + + + +
From 0d9d9ecd574fde1220ae12d8c6c75cbe2b6cf539 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Tue, 21 Jul 2026 21:08:19 +0200 Subject: [PATCH 19/21] feat(pathanalyzer): map view polish - channel label, auto path, 45vh map - Show the channel name (dimmed, next to the sender) on map-view tiles - Clicking a message now auto-selects and draws its shortest routed echo (fewest hops, ties -> first); re-clicking keeps the user's echo choice - Mobile: map takes a fixed 45% of the viewport height and the path list gets the remaining space (was min 300px map / max 40% list) Co-Authored-By: Claude Fable 5 --- app/static/js/path-analyzer.js | 26 ++++++++++++++++++++++++-- app/templates/path-analyzer.html | 18 ++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index c8f1212..33efe97 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -688,6 +688,18 @@ function paBuildLegend(echo) { return legend; } +// Default echo selection: the routed echo with the fewest hops +// (ties -> first in arrival order) +function paShortestEchoIdx(msg) { + let best = null; + msg.echoView.forEach((echo, idx) => { + if (echo.hops > 0 && (best === null || echo.hops < msg.echoView[best].hops)) { + best = idx; + } + }); + return best; +} + function paRenderMapView() { paInitMap(); paPlotBaseMarkers(); @@ -715,13 +727,21 @@ function paRenderMapView() { const head = document.createElement('div'); head.className = 'd-flex justify-content-between gap-2'; + const left = document.createElement('div'); + left.className = 'd-flex align-items-baseline gap-1'; + left.style.minWidth = '0'; const senderEl = document.createElement('strong'); senderEl.className = 'text-truncate'; senderEl.textContent = msg.sender || '—'; + const chanEl = document.createElement('span'); + chanEl.className = 'pa-map-msg-chan'; + chanEl.textContent = msg.channel_name || `#${msg.channel_idx}`; + left.appendChild(senderEl); + left.appendChild(chanEl); const timeEl = document.createElement('span'); timeEl.className = 'text-muted text-nowrap'; timeEl.textContent = paFormatTime(msg).slice(5, 16); - head.appendChild(senderEl); + head.appendChild(left); head.appendChild(timeEl); entry.appendChild(head); @@ -735,7 +755,9 @@ function paRenderMapView() { } entry.addEventListener('click', () => { - paMapSelection = { msgId: msg.id, echoIdx: null }; + // Re-clicking the selected message keeps the user's echo choice + if (paMapSelection.msgId === msg.id) return; + paMapSelection = { msgId: msg.id, echoIdx: paShortestEchoIdx(msg) }; paRenderMapView(); }); diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index 0af29d5..06fde7c 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -125,11 +125,19 @@ flex-direction: column; } + /* Message/path list gets the remaining space, the map keeps a + fixed ~45% of the viewport height */ #paMapSidebar { width: auto; min-width: 0; - max-height: 40%; - flex-shrink: 1; + max-height: none; + flex: 1 1 0; + min-height: 0; + } + + #paMap { + flex: 0 0 45vh; + min-height: 0; } } @@ -364,6 +372,12 @@ color: #6f42c1; } + .pa-map-msg-chan { + font-size: 0.72rem; + color: var(--text-secondary, #6c757d); + white-space: nowrap; + } + .pa-map-msg-preview { font-size: 0.75rem; color: var(--text-secondary, #6c757d); From afb305a61e9bebeae997bb98aebc4ba0fed31e0e Mon Sep 17 00:00:00 2001 From: MarekWo Date: Tue, 21 Jul 2026 21:38:47 +0200 Subject: [PATCH 20/21] feat(pathanalyzer): Routes view - hop segment statistics + sequence filter New 4th view counting consecutive hop segments (n-grams) across all routed echo paths, regardless of position in the path: - segment length selector (2/3/4 hops), table sorted by echo count by default; columns: Echoes, distinct Messages, As path end (how often the segment is the final part of the path reaching us) - resolved contact names shown under the hash chips (ambiguous/unknown marked like elsewhere) - row click fills the repeater filter with the segment and jumps to the message list - the repeater filter now accepts consecutive sequences chained with > or an arrow (e.g. AFE6>6E9A or hash>name); single values behave exactly as before, spaces stay usable inside contact names - view switcher buttons show icons only on xs screens so 4 buttons fit Idea by Daniel - group paths by recurring hop sequences to see which routes carry the most traffic. Co-Authored-By: Claude Fable 5 --- app/static/js/path-analyzer.js | 157 ++++++++++++++++++++++++++++--- app/templates/path-analyzer.html | 38 +++++++- 2 files changed, 179 insertions(+), 16 deletions(-) diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index 33efe97..1a692d6 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -84,9 +84,10 @@ function showNotification(message, type = 'info') { let paMessages = []; let paFilters = { hops: 'any', hashSize: 'any', token: '', sender: '', content: '' }; -let paCurrentView = 'messages'; // 'messages' | 'stats' +let paCurrentView = 'messages'; // 'messages' | 'stats' | 'routes' | 'map' let paContacts = []; // /api/contacts/cached?format=full let paStatsSort = { key: 'relayed', dir: -1 }; +let paRoutesSort = { key: 'echoes', dir: -1 }; async function paLoadContacts() { try { @@ -135,6 +136,13 @@ function paDecodeEcho(echo) { return { ...echo, tokens: tokens, hops: tokens.length }; } +// One filter element (lowercase) vs one path token: hex input matches the +// hash by prefix; any input also matches resolved contact names +function paTokenMatchesElement(tok, el) { + return (/^[0-9a-f]+$/.test(el) && tok.startsWith(el.toUpperCase())) + || paTokenNames(tok).some(n => n.includes(el)); +} + function paMessageMatchesFilters(msg) { if (paFilters.hops !== 'any') { const want = paFilters.hops; @@ -150,15 +158,16 @@ function paMessageMatchesFilters(msg) { if (!ok) return false; } if (paFilters.token) { - const raw = paFilters.token; // lowercase - const isHex = /^[0-9a-f]+$/.test(raw); - const hexPrefix = raw.toUpperCase(); - // Hex input matches hash tokens by prefix; any input also matches - // the names of contacts the token resolves to - const ok = msg.echoView.some(e => e.tokens.some(tok => - (isHex && tok.startsWith(hexPrefix)) || - paTokenNames(tok).some(n => n.includes(raw)) - )); + // '>' (or '→') chains elements into a consecutive-sequence match; + // a single element behaves as before. Spaces are NOT separators - + // contact names may contain them. + const els = paFilters.token.split(/[>→]/).map(s => s.trim()).filter(Boolean); + const ok = els.length > 0 && msg.echoView.some(e => { + for (let i = 0; i + els.length <= e.tokens.length; i++) { + if (els.every((el, j) => paTokenMatchesElement(e.tokens[i + j], el))) return true; + } + return false; + }); if (!ok) return false; } if (paFilters.sender) { @@ -378,6 +387,7 @@ function paSetView(state) { document.getElementById('paEmpty').classList.toggle('d-none', state !== 'empty'); document.getElementById('paTableWrap').classList.toggle('d-none', state !== 'table'); document.getElementById('paStatsWrap').classList.toggle('d-none', state !== 'stats'); + document.getElementById('paRoutesWrap').classList.toggle('d-none', state !== 'routes'); document.getElementById('paMapWrap').classList.toggle('d-none', state !== 'map'); } @@ -435,7 +445,7 @@ function paRenderStats() { }); // Show sort direction on the active header - document.querySelectorAll('.pa-sortable').forEach(th => { + document.querySelectorAll('#paStatsWrap .pa-sortable').forEach(th => { th.textContent = th.textContent.replace(/ [▲▼]$/, '') + (th.dataset.sort === key ? (dir === -1 ? ' ▼' : ' ▲') : ''); }); @@ -496,6 +506,113 @@ function paRenderStats() { } } +// ================================================================ +// Route segment statistics (consecutive hop n-grams) +// ================================================================ + +function paComputeRoutes(filtered, n) { + const routes = new Map(); // 'A→B→...' -> aggregate + for (const msg of filtered) { + for (const e of msg.echoView) { + const seen = new Set(); // count each segment once per echo + for (let i = 0; i + n <= e.tokens.length; i++) { + const key = e.tokens.slice(i, i + n).join('→'); + let r = routes.get(key); + if (!r) { + r = { tokens: e.tokens.slice(i, i + n), echoes: 0, msgIds: new Set(), pathEnd: 0 }; + routes.set(key, r); + } + if (!seen.has(key)) { + seen.add(key); + r.echoes++; + r.msgIds.add(msg.id); + } + if (i + n === e.tokens.length) r.pathEnd++; + } + } + } + return [...routes.values()].map(r => ({ + tokens: r.tokens, + echoes: r.echoes, + messages: r.msgIds.size, + pathEnd: r.pathEnd, + })); +} + +function paRenderRoutes() { + const body = document.getElementById('paRoutesBody'); + body.innerHTML = ''; + + const n = parseInt(document.getElementById('paSegLenSelect').value, 10); + const filtered = paMessages.filter(paMessageMatchesFilters); + const rows = paComputeRoutes(filtered, n); + + const { key, dir } = paRoutesSort; + rows.sort((a, b) => (a[key] - b[key]) * dir || b.echoes - a.echoes); + + // Show sort direction on the active header + document.querySelectorAll('#paRoutesWrap .pa-sortable').forEach(th => { + th.textContent = th.textContent.replace(/ [▲▼]$/, '') + + (th.dataset.sort === key ? (dir === -1 ? ' ▼' : ' ▲') : ''); + }); + + for (const r of rows) { + const tr = document.createElement('tr'); + tr.className = 'pa-stats-row'; + tr.title = 'Filter messages by this segment'; + + const tdRoute = document.createElement('td'); + const hashLine = document.createElement('div'); + hashLine.innerHTML = r.tokens + .map(t => `${t}`) + .join(' '); + tdRoute.appendChild(hashLine); + + // Resolved contact names beneath the hashes (skipped when nothing resolves) + const names = r.tokens.map(tok => { + const cands = paMatchContacts(tok); + if (cands.length === 1) return cands[0].name || '—'; + return cands.length > 1 ? `ambiguous (${cands.length})` : '—'; + }); + if (names.some(nm => nm !== '—')) { + const nameLine = document.createElement('div'); + nameLine.className = 'small text-muted'; + nameLine.textContent = names.join(' → '); + tdRoute.appendChild(nameLine); + } + tr.appendChild(tdRoute); + + for (const [val, cls] of [[r.echoes, ''], [r.messages, ' pa-col-msgs'], [r.pathEnd, '']]) { + const td = document.createElement('td'); + td.className = 'text-end' + cls; + td.textContent = val; + tr.appendChild(td); + } + + // Click -> apply this segment as a sequence filter and jump to the list + tr.addEventListener('click', () => { + document.getElementById('paTokenFilter').value = r.tokens.join('>'); + paSwitchView('messages'); + }); + + body.appendChild(tr); + } + + const counter = document.getElementById('paCounter'); + counter.textContent = paFiltersActive() + ? `${rows.length} segments (${filtered.length} of ${paMessages.length} messages)` + : `${rows.length} segments (${paMessages.length} messages)`; + + if (rows.length === 0) { + document.getElementById('paEmptyText').textContent = + filtered.length === 0 ? 'No messages match the current filters.' + : `No paths with at least ${n} hops in the current selection.`; + paSetView('empty'); + } else { + paSetView('routes'); + } +} + // ================================================================ // Map view // ================================================================ @@ -812,6 +929,8 @@ function paClearMapSelection() { function paRender() { if (paCurrentView === 'stats') { paRenderStats(); + } else if (paCurrentView === 'routes') { + paRenderRoutes(); } else if (paCurrentView === 'map') { paRenderMapView(); } else { @@ -823,6 +942,7 @@ function paSwitchView(view) { paCurrentView = view; for (const [btnId, v] of [['paViewMessagesBtn', 'messages'], ['paViewStatsBtn', 'stats'], + ['paViewRoutesBtn', 'routes'], ['paViewMapBtn', 'map']]) { document.getElementById(btnId).className = 'btn ' + (view === v ? 'btn-primary' : 'btn-outline-primary'); @@ -921,9 +1041,11 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('paViewMessagesBtn').addEventListener('click', () => paSwitchView('messages')); document.getElementById('paViewStatsBtn').addEventListener('click', () => paSwitchView('stats')); + document.getElementById('paViewRoutesBtn').addEventListener('click', () => paSwitchView('routes')); document.getElementById('paViewMapBtn').addEventListener('click', () => paSwitchView('map')); document.getElementById('paMapClearBtn').addEventListener('click', paClearMapSelection); - document.querySelectorAll('.pa-sortable').forEach(th => { + document.getElementById('paSegLenSelect').addEventListener('change', paApplyFilters); + document.querySelectorAll('#paStatsWrap .pa-sortable').forEach(th => { th.addEventListener('click', () => { const k = th.dataset.sort; if (paStatsSort.key === k) { @@ -934,6 +1056,17 @@ document.addEventListener('DOMContentLoaded', () => { paRenderStats(); }); }); + document.querySelectorAll('#paRoutesWrap .pa-sortable').forEach(th => { + th.addEventListener('click', () => { + const k = th.dataset.sort; + if (paRoutesSort.key === k) { + paRoutesSort.dir = -paRoutesSort.dir; + } else { + paRoutesSort = { key: k, dir: -1 }; + } + paRenderRoutes(); + }); + }); paLoadContacts(); paLoadMessages(); diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index 06fde7c..9f87b51 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -511,13 +511,16 @@
+
+
+
+ + +
+
+ + + + + + + + + + +
RouteEchoesMessagesAs path end
+
+
From c5a0fa8fd17a77b4d17147ae8bfa73f8e7f8d8bd Mon Sep 17 00:00:00 2001 From: MarekWo Date: Wed, 22 Jul 2026 06:35:15 +0200 Subject: [PATCH 21/21] docs: Path Analyzer Routes view + mobile polish, repeater login/location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path Analyzer now documents its fourth view (Routes — consecutive hop-segment stats with an "as path end" count) and the `>`-chained sequence filter, the map's instant shortest-route draw and channel label, and the mobile collapsible filter bar / 45vh map. My Repeaters gains the Settings -> Location "Pick from map" picker and the saved-password prefill on login retry; architecture.md adds the new GET /api/repeaters//password endpoint and a four-views note. Co-Authored-By: Claude Opus 4.8 --- docs/architecture.md | 4 +++- docs/user-guide.md | 23 ++++++++++++++++++----- docs/whatsnew.md | 9 ++++++--- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index ae5322b..8606f5f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,7 +110,8 @@ The `/repeaters` (list) and `/repeaters/manage` (per-repeater tools) panels are The `/path-analyzer` panel (standalone iframe page, `path-analyzer.js`) is a read-only analysis view over data the app already collects — no new tables, no background work: - **Data source** — `GET /api/path-analyzer/messages?days=N` joins `channel_messages` with `echoes` (path hex + SNR + per-echo `hash_size`, keyed by `pkt_payload`). Echoes are fetched with `db.get_echoes_for_payloads()` — chunked `IN` queries (≤500 params, under SQLite's host-parameter limit) via `idx_echoes_pkt` — deliberately avoiding the per-message echo query the older `/api/messages` path still does. The pkt_payload reconstruction (raw_json text → channel-secret AES/HMAC compute) is shared with `/api/messages` via the `_get_row_pkt_payload()` helper -- **All filtering/stats/map logic is client-side** over the bulk payload (hundreds of KB for 7 days — fine on a LAN): filters operate on per-hop tokens split with each echo's own `hash_size` (mixed 1/2/3-byte networks are real), so SQL-side token filtering was rejected. Stats and map always reflect the active filters for free +- **All filtering/stats/map/routes logic is client-side** over the bulk payload (hundreds of KB for 7 days — fine on a LAN): filters operate on per-hop tokens split with each echo's own `hash_size` (mixed 1/2/3-byte networks are real), so SQL-side token filtering was rejected. Every view always reflects the active filters for free +- **Four views** share the one payload: Messages (hop-by-hop echo detail), Repeaters (per-hash relay/SNR stats), Routes (consecutive hop-segment n-grams — user-selectable length 2–4, counted anywhere in a path), and Map (Leaflet path drawing). The repeater filter accepts a `>`-chained sequence (each element a hash prefix or contact name) matched as consecutive hops; Routes rows write such a sequence into that filter on click - **SNR attribution** — echo SNR is measured at our receiver, so stats credit it to the *final* hop only; intermediate hops get relay counts but never SNR - **Hash→contact resolution** — pubkey-prefix match against `/api/contacts/cached?format=full` (memoized per token). 1-byte hashes collide by design; the map renders unresolved hops as amber candidate markers with manual pick, and the repeater-name filter is intentionally inclusive over candidates - Legacy rows whose `pkt_payload` cannot be recomputed (missing channel secret) are returned with `packet_hash: null` and no echoes rather than dropped @@ -312,6 +313,7 @@ Every mutating endpoint hot-reloads the `ObserverManager`, so broker and setting | GET | `/api/repeaters/` | Single merged entry + login session state | | PUT | `/api/repeaters/` | Set or clear the saved password (`{password}`; empty string clears) | | DELETE | `/api/repeaters/` | Remove from the list (device contact untouched) | +| GET | `/api/repeaters//password` | Saved password (empty string when none); prefills the login-retry prompt for the trusted local UI | | POST | `/api/repeaters//login` | Log in with `{password?, save?}` — omitted password uses the saved one | | GET | `/api/repeaters//session` | In-memory session state (`logged_in`, `is_admin`, `permissions`) | | POST | `/api/repeaters//logout` | Log out and drop the session | diff --git a/docs/user-guide.md b/docs/user-guide.md index 41c4509..a242b76 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -557,6 +557,7 @@ Click a repeater row to log in. The first time, you are asked for the repeater p - The repeater decides your role from the password: **ADMIN** (full access) or **GUEST** (read-only: Status, Telemetry, Neighbors). - **Important:** a wrong password and an unreachable repeater look identical — MeshCore repeaters simply never answer a bad login. When a login times out, check reachability first (is the repeater several flood hops away?) before assuming the password is wrong. +- Because of that ambiguity, when a one-click login fails the retry prompt comes back with your **saved password already filled in** — a failure is far more often a flaky connection than a wrong password, so you can just retry without retyping it. - Logins can take up to a minute on flood paths. Pinning a direct path (below) makes them much faster. ### Row Actions @@ -599,6 +600,7 @@ Repeater configuration organized into collapsible sections: **Basic** (name, adm - Changed fields are highlighted and counted on the Apply button; only those fields are sent - Every field reports back individually: a green check (applied), a **reboot required** badge (stored, takes effect after a reboot — radio parameters work this way), or a red error showing the repeater's own reply (e.g. an out-of-range value). Failed fields stay marked so you can correct and re-apply - Changing radio parameters asks for confirmation first — wrong values can make the repeater unreachable over the mesh +- The **Location** section has a **Pick from map** button (enabled once the section has loaded): it opens a map, and clicking a point fills the latitude and longitude fields for you and marks the section changed, ready to Apply — handy when you know where the repeater is but not its exact coordinates - The admin password is write-only (the current one is never displayed). After a successful change, the password saved in mc-webui is updated automatically so one-click login keeps working. Changing it does **not** log out sessions that are already active #### Actions (admin only) @@ -626,15 +628,15 @@ Pick a time range at the top (last 1, 3, 5, or 7 days) — the tool loads every ### Filters -The filter bar applies to all three views at once and updates as you type: +The filter bar applies to all four views at once and updates as you type: - **Hops** - Only messages that arrived over exactly that many hops (0 = heard directly, 4+ = long routes). A message matches when *any* of its echoes has that hop count - **HB (path hash size)** - Only messages whose route was recorded with 1-, 2-, or 3-byte repeater hashes; a combined **2/3-byte** option covers both larger sizes at once -- **Repeater** - Type a repeater hash (e.g. `3B`) or part of a repeater's **name** (e.g. `wegrzce`); matches messages whose route passed through it. Note that with short 1-byte hashes two repeaters can share a hash, so a name search includes routes where the named repeater *might* be one of the candidates +- **Repeater** - Type a repeater hash (e.g. `3B`) or part of a repeater's **name** (e.g. `wegrzce`); matches messages whose route passed through it. Chain several with `>` (e.g. `AFE6>6E9A` or `barbarka>wegrzce`) to match a route that passed through them **in that order, consecutively** — mixing hashes and names is fine. Note that with short 1-byte hashes two repeaters can share a hash, so a name search includes routes where the named repeater *might* be one of the candidates - **Sender** - Part of the sender's name - **Message text** - Part of the message content -A counter shows how much of the data set matches ("38 of 412 messages"), and **Clear** resets everything. +A counter shows how much of the data set matches ("38 of 412 messages"), and **Clear** resets everything. On phones the whole filter bar collapses behind a **Filters** button (with a badge counting the filters you have set) so the results get the full screen — the view switcher and match counter stay visible. ### Messages view @@ -658,16 +660,27 @@ Per-repeater statistics computed from the currently filtered messages: Columns are sortable, and the **Contact** column matches each hash to your contact list (showing "ambiguous (n)" when several contacts share a short hash). Click any row to jump back to the Messages view filtered to that repeater. +### Routes view + +Where the Repeaters view counts single hops, the Routes view counts **sequences** of consecutive hops — so you can see which stretches of the mesh carry the most traffic to you, wherever they sit in a route. Pick a **Segment length** (2, 3, or 4 hops) and the table lists every such sequence found across the filtered messages: + +- **Route** - The hop sequence (e.g. `AFE6 → 6E9A`), with the matching contact names underneath +- **Echoes** - How many overheard copies contained this exact sequence +- **Messages** - How many distinct messages that was +- **As path end** - How often the sequence was the *end* of a route — i.e. the final stretch that actually reached your node. Sort by this to see which approaches deliver to you most often + +Columns are sortable (echo count first by default). Click any row to jump to the Messages view filtered to that sequence. + ### Map view -Repeaters from your contact list that have a position are plotted as dots. Pick a message in the side list (it shows sender, time, and the message text), then pick one of its routes — the path is drawn hop by hop: +Repeaters from your contact list that have a position are plotted as dots. Pick a message in the side list — each tile shows the sender, the channel it came from (dimmed, next to the sender), the time, and the message text. Its **shortest** route is drawn automatically the moment you pick the message, so you see a path with one tap; pick a different route from the list to redraw. The path is drawn hop by hop: - Hops that resolve to exactly one known repeater become red numbered points (the number matches the legend order) labeled with the repeater's name, connected by a red line — clearly distinct from the purple background dots of uninvolved repeaters - When a short hash matches several contacts, all candidates are marked in amber and the legend lists them — tap the right one and the path redraws with your choice. Assignments are reversible: an undo icon next to a manually assigned hop reverts just that hop, and **Reset picks** clears every manual assignment on the current path - Unknown hops (no matching contact, or no position) are listed in the legend and the line is drawn dashed across the gap, so you can see which parts of the route are certain - If the sender is in your contacts with a position, it is added as a green origin point -The eraser button clears the drawn path. On phones the message list moves above the map. +The eraser button clears the drawn path. On phones the map takes a fixed share of the screen (about 45%) and the message list gets the rest, so scrolling through routes is comfortable. Everything in the Path Analyzer is based on what **your node** overheard — it's a local view of the mesh, not a global one. A route you don't see here may still exist; it just never reached your radio. diff --git a/docs/whatsnew.md b/docs/whatsnew.md index afd38ba..1c09cfb 100644 --- a/docs/whatsnew.md +++ b/docs/whatsnew.md @@ -15,13 +15,16 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g - **Remote CLI, Settings, and Actions (admin logins).** **CLI** is a real terminal to the repeater — quick-command chips, per-repeater history, round-trip times. **Settings** edits the repeater configuration in collapsible sections (Basic, Radio, Location, Features, Network health, Advertisement, Operator info, Advanced): values load live from the repeater, only the fields you change are sent, and every field reports back individually — including a "reboot required" badge for radio parameters and the firmware's own error text for rejected values. **Actions** covers zero-hop advert, flood advert (marked "not recommended" — high network load), clock sync, and a confirmation-guarded reboot in a Danger zone. Erasing the file system stays USB-serial-only by firmware design, and the panel says so. - **Observer mode — feed packet analyzers straight from mc-webui.** The new **Settings → Observer** tab turns your node into a MeshCore observer: every packet the device overhears is published to one or more MQTT brokers in the standard `meshcore-packet-capture` format, so analyzer services (a self-hosted Corescope, letsmesh-style maps) see your local mesh traffic without a separate capture script or a dedicated second node. Configure brokers with host/port, optional username/password and TLS; each row shows a live connected/error badge, and the tab counts packets captured vs published in real time. Capture is completely passive — chat and direct messages are unaffected — and all changes apply immediately, no restart needed. (LetsMesh token-authenticated brokers are not supported yet.) - **Scheduled flood adverts.** The Observer tab includes an optional advert interval in hours: the app sends a flood advert on that schedule so your observer stays visible on analyzer maps. The timer survives restarts, so a redeploy won't send an extra advert early. Set it to 0 to keep adverts fully manual. -- **Path Analyzer — see how your messages travel the mesh.** A new full-screen tool (main menu → **Path Analyzer**) loads every channel message from all your channels for a chosen window (1–7 days) together with every copy your node overheard, and lets you dig in three ways. **Messages**: expand any message to see all its routes hop by hop with SNR — copy a repeater hash, copy the whole route, or jump straight to the map. **Repeaters**: per-repeater statistics (how many packets each hash relayed, over how many messages, and average SNR when it was the hop you actually heard), sortable and clickable to filter. **Map**: pick a message, pick a route, and it's drawn on the map as a red line with numbered, name-labeled points — when a short 1-byte hash matches several repeaters, the candidates are shown in amber and you pick the right one instead of the app guessing (a mistaken pick can be undone per hop or reset for the whole path), and uncertain segments are drawn dashed so you always know which part of a route is confirmed. -- **Path Analyzer filters.** Everything is filterable live and in combination: hop count, path-hash size (1/2/3-byte), repeater — by hash **or by name** — sender, and message text. All three views (including the statistics and the map's message list) follow the active filters. -- **Path Analyzer works on phones.** On narrow screens the tables shed their secondary columns (packet hash and hash size move into the expanded row), long routes wrap instead of scrolling sideways, and the map's message list stacks above the map. +- **Path Analyzer — see how your messages travel the mesh.** A new full-screen tool (main menu → **Path Analyzer**) loads every channel message from all your channels for a chosen window (1–7 days) together with every copy your node overheard, and lets you dig in four ways. **Messages**: expand any message to see all its routes hop by hop with SNR — copy a repeater hash, copy the whole route, or jump straight to the map. **Repeaters**: per-repeater statistics (how many packets each hash relayed, over how many messages, and average SNR when it was the hop you actually heard), sortable and clickable to filter. **Routes**: the busiest hop *sequences* across all your traffic — pick a length (2–4 hops) and see which stretches of the mesh relay the most, with an "as path end" count that highlights the routes actually delivering to you. **Map**: pick a message and its shortest route is drawn instantly as a red line with numbered, name-labeled points — when a short 1-byte hash matches several repeaters, the candidates are shown in amber and you pick the right one instead of the app guessing (a mistaken pick can be undone per hop or reset for the whole path), and uncertain segments are drawn dashed so you always know which part of a route is confirmed. +- **Path Analyzer filters.** Everything is filterable live and in combination: hop count, path-hash size (1/2/3-byte), repeater — by hash **or by name**, and now as a `>`-chained sequence to find a specific consecutive route (e.g. `AFE6>6E9A`) — sender, and message text. All four views follow the active filters, and clicking a Routes or Repeaters row drops you into the message list already filtered. +- **Path Analyzer works on phones.** On narrow screens the whole filter bar collapses behind a **Filters** button (with a badge counting the filters you have set), the tables shed their secondary columns (packet hash and hash size move into the expanded row) and long routes wrap instead of scrolling sideways, and on the map the message list gets most of the height with the map fixed to a comfortable share below it. +- **Pin a repeater's location from a map.** In **My Repeaters → Settings → Location**, a new **Pick from map** button lets you click a point on a map to fill in the latitude and longitude — no more looking up coordinates by hand. ### Reliability & polish - **Console `login` reports your role.** A successful repeater login in the Interactive Console now answers "Logged into X as admin" (or guest) instead of a bare success line. +- **Failed repeater login no longer makes you retype the password.** Since a wrong password and an unreachable repeater are indistinguishable — and a failure is usually just a flaky connection — the retry prompt now comes back with your saved password already filled in, so you can retry with one tap. +- **Repeater list reads better on narrow phones.** A repeater's "last login" time now sits on its own line, so a long pinned path no longer squeezes the rest of the row. ### Deploy notes