diff --git a/app/device_manager.py b/app/device_manager.py index 9ac5286..40866dc 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -2674,32 +2674,13 @@ class DeviceManager: # Read back the actual secret from device (firmware may have # generated it for # channels) and update in-memory cache + DB. - self._refresh_channel_secret(idx, name) + self._refresh_channel_secret(idx) return {'success': True, 'message': f'Channel {idx} set'} except Exception as e: logger.error(f"Failed to set channel: {e}") return {'success': False, 'error': str(e)} - def _refresh_channel_secret(self, idx: int, name: str = ''): - """Read back a channel's secret from device and update cache + DB.""" - try: - event = self.execute(self.mc.commands.get_channel(idx)) - if event: - data = getattr(event, 'payload', None) or {} - secret = data.get('channel_secret', data.get('secret', b'')) - if isinstance(secret, bytes): - secret = secret.hex() - if secret and len(secret) == 32: - self._channel_secrets[idx] = secret - ch_name = data.get('channel_name', data.get('name', '')) - if isinstance(ch_name, str): - ch_name = ch_name.strip('\x00').strip() - self.db.upsert_channel(idx, ch_name or name, secret) - logger.info(f"Refreshed channel {idx} secret into cache") - except Exception as e: - logger.warning(f"Failed to refresh channel {idx} secret: {e}") - def remove_channel(self, idx: int) -> Dict: """Remove a channel from the device.""" if not self.is_connected: diff --git a/app/static/css/style.css b/app/static/css/style.css index 56059ca..cd62f62 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1557,6 +1557,25 @@ main { border-bottom: 1px solid rgba(255, 255, 255, 0.15); word-break: break-all; cursor: pointer; + display: flex; + align-items: flex-start; + gap: 0.4rem; +} + +.path-popup .path-route { + flex: 1 1 auto; + min-width: 0; +} + +.path-popup .path-copy { + flex: 0 0 auto; + padding: 0.1rem 0.15rem; + opacity: 0.7; + cursor: pointer; +} + +.path-popup .path-copy:hover { + opacity: 1; } .path-popup .path-entry:active { diff --git a/app/static/js/app.js b/app/static/js/app.js index 15d2844..76b1c52 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1187,7 +1187,7 @@ function updateMessageMetaDOM(wrapper, meta) { : segments.join('\u2192'); const pathsData = encodeURIComponent(JSON.stringify(paths)); const routeLabel = paths.length > 1 ? `Route (${paths.length})` : 'Route'; - metaParts.push(`${routeLabel}: ${shortPath}`); + metaParts.push(`${routeLabel}: ${shortPath}`); } const metaInfo = metaParts.join(' | '); @@ -1329,7 +1329,7 @@ function createMessageElement(msg) { : segments.join('\u2192'); const pathsData = encodeURIComponent(JSON.stringify(msg.paths)); const routeLabel = msg.paths.length > 1 ? `Route (${msg.paths.length})` : 'Route'; - metaParts.push(`${routeLabel}: ${shortPath}`); + metaParts.push(`${routeLabel}: ${shortPath}`); } const metaInfo = metaParts.join(' | '); @@ -1693,7 +1693,7 @@ async function blockContactFromChat(senderName) { /** * Show paths popup on tap (mobile-friendly, shows all routes) */ -function showPathsPopup(element, encodedPaths) { +function showPathsPopup(element, encodedPaths, packetHash) { // Remove any existing popup const existing = document.querySelector('.path-popup'); if (existing) existing.remove(); @@ -1717,16 +1717,42 @@ function showPathsPopup(element, encodedPaths) { const hops = segments.length; const entry = document.createElement('div'); entry.className = 'path-entry'; - entry.innerHTML = `${fullRoute}SNR: ${snr} | Hops: ${hops}`; - entry.title = 'Tap to copy route'; - entry.addEventListener('click', (e) => { + + const body = document.createElement('span'); + body.className = 'path-route'; + body.innerHTML = `${fullRoute}SNR: ${snr} | Hops: ${hops}`; + entry.appendChild(body); + + const copyBtn = document.createElement('i'); + copyBtn.className = 'bi bi-clipboard path-copy'; + copyBtn.title = 'Copy route'; + copyBtn.addEventListener('click', (e) => { e.stopPropagation(); navigator.clipboard.writeText(commaRoute).then(() => { - const orig = entry.innerHTML; - entry.innerHTML = 'Copied!'; - setTimeout(() => { entry.innerHTML = orig; }, 1000); + copyBtn.className = 'bi bi-clipboard-check path-copy'; + setTimeout(() => { copyBtn.className = 'bi bi-clipboard path-copy'; }, 1000); }); }); + entry.appendChild(copyBtn); + + if (packetHash && p.path && segments.length > 0) { + entry.title = 'Show this route on the Path Analyzer map'; + entry.addEventListener('click', (e) => { + e.stopPropagation(); + popup.remove(); + openPathInAnalyzer(packetHash, p.path); + }); + } else { + // No packet hash (or direct message): keep the copy behavior + entry.title = 'Tap to copy route'; + entry.addEventListener('click', (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(commaRoute).then(() => { + copyBtn.className = 'bi bi-clipboard-check path-copy'; + setTimeout(() => { copyBtn.className = 'bi bi-clipboard path-copy'; }, 1000); + }); + }); + } popup.appendChild(entry); }); @@ -1757,6 +1783,18 @@ function showPathsPopup(element, encodedPaths) { }); } +/** + * Open the Path Analyzer modal deep-linked to one message + echo path. + * The modal's show handler (index.html) reads window.paDeepLink and builds + * the iframe URL from it. + */ +function openPathInAnalyzer(packetHash, pathHex) { + const modalEl = document.getElementById('pathAnalyzerModal'); + if (!modalEl) return; + window.paDeepLink = { hash: packetHash, path: pathHex }; + bootstrap.Modal.getOrCreateInstance(modalEl).show(); +} + /** * Load connection status */ diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index 1a692d6..2fbd252 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -88,6 +88,8 @@ 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 }; +let paDeepLink = null; // ?hash=..&path=.. from a chat path popup +let paContactsReady = Promise.resolve(); async function paLoadContacts() { try { @@ -978,6 +980,45 @@ async function paLoadMessages() { } else { paRender(); } + + if (paDeepLink) paApplyDeepLink(); +} + +// Deep link from the chat path popup: jump to the map view with the +// linked message selected and the linked echo path drawn. +async function paApplyDeepLink() { + const dl = paDeepLink; + const hash = (dl.hash || '').toLowerCase(); + const msg = paMessages.find(m => (m.packet_hash || '').toLowerCase() === hash); + + if (!msg) { + // The chat can show messages older than the default window - widen + // to the max range once before giving up + const daysSel = document.getElementById('paDaysSelect'); + if (!dl.retried && daysSel.value !== '7') { + dl.retried = true; + daysSel.value = '7'; + paLoadMessages(); // re-enters paApplyDeepLink when done + return; + } + paDeepLink = null; + showNotification('This message is no longer in the analyzer data (max 7 days).', 'warning'); + return; + } + + paDeepLink = null; + await paContactsReady; // map needs contacts to resolve hop positions + + let echoIdx = msg.echoView.findIndex(e => + e.hops > 0 && (e.path || '').toLowerCase() === (dl.path || '').toLowerCase()); + if (echoIdx === -1) echoIdx = paShortestEchoIdx(msg); + if (echoIdx === null) { + showNotification('This message has no routed echoes to draw.', 'warning'); + return; + } + + paMapSelection = { msgId: msg.id, echoIdx: echoIdx }; + paSwitchView('map'); } // ================================================================ @@ -1024,6 +1065,12 @@ function paClearFilters() { document.addEventListener('DOMContentLoaded', () => { loadUiSettings(); + + const qs = new URLSearchParams(window.location.search); + if (qs.get('hash')) { + paDeepLink = { hash: qs.get('hash'), path: qs.get('path') || '' }; + } + document.getElementById('paDaysSelect').addEventListener('change', paLoadMessages); document.getElementById('paRefreshBtn').addEventListener('click', paLoadMessages); @@ -1068,6 +1115,6 @@ document.addEventListener('DOMContentLoaded', () => { }); }); - paLoadContacts(); + paContactsReady = paLoadContacts(); paLoadMessages(); }); diff --git a/app/templates/index.html b/app/templates/index.html index 40dfeaf..03aa6a4 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -350,7 +350,13 @@ pathAnalyzerModal.addEventListener('show.bs.modal', function () { const pathAnalyzerFrame = document.getElementById('pathAnalyzerFrame'); if (pathAnalyzerFrame) { - pathAnalyzerFrame.src = '/path-analyzer'; + // Deep link from a chat path popup (openPathInAnalyzer): + // preselect the message + echo on the map view + const dl = window.paDeepLink; + window.paDeepLink = null; + pathAnalyzerFrame.src = dl + ? `/path-analyzer?hash=${encodeURIComponent(dl.hash)}&path=${encodeURIComponent(dl.path)}` + : '/path-analyzer'; } }); } diff --git a/docs/architecture.md b/docs/architecture.md index 8606f5f..bce1868 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,6 +112,7 @@ The `/path-analyzer` panel (standalone iframe page, `path-analyzer.js`) is a rea - **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/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 +- **Deep link** — `GET /path-analyzer?hash=&path=` opens straight on the Map view with that message selected and that exact echo drawn. Used by the chat path popup (`app.js` → `openPathInAnalyzer` stashes `{hash, path}` in `window.paDeepLink`; the modal's `show.bs.modal` handler builds the iframe URL). On load the analyzer resolves the message by `packet_hash`, widening the time range once to 7 days if it isn't in the current window, and matches the echo by raw path hex (fallback: shortest routed echo) - **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 diff --git a/docs/user-guide.md b/docs/user-guide.md index a242b76..5323856 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -170,6 +170,15 @@ Your own messages carry a small row of action buttons: - **Edit message** (pencil icon) - Copies the message text back into the composer so you can tweak it and send it again as a new message. - **Resend** (repeat-arrow icon) - Re-broadcasts the *same* packet. Repeaters that already forwarded the original ignore the duplicate, but nodes that never heard it can still pick it up — so the resend extends the repeater list on the existing message's badge instead of creating a new message. Handy right after sending when the delivery badge shows only partial coverage. This button only appears when your device runs companion firmware **1.16 or newer**; on older firmware it is hidden. +### Group Chat Message Routes + +When your node overheard how a channel message travelled, a **Route** line appears under the message (e.g. `Route (3): 92→AD→40→D1`). Tap it to open a popup listing every route the message arrived by, each with its SNR and hop count: + +- Tap a route to open the **[Path Analyzer](#path-analyzer)** on the Map view, with that message selected and that exact route drawn — the quickest way to see where a message physically travelled. +- Each route also has a small clipboard icon to copy it in comma-separated form (e.g. `92,AD,40,D1`) for use in the console `change_path` command. + +(In direct messages the same popup copies the route on tap, since only channel messages feed the Path Analyzer.) + --- ## Message Content Features @@ -624,6 +633,8 @@ To open: 1. Tap the hamburger menu (☰) 2. Select **Path Analyzer** from the menu +Or jump straight to a specific route: under any channel message, tap the **Route** line and pick one of its routes — the analyzer opens on the Map view with that message selected and that exact route already drawn (see [Group chat message routes](#group-chat-message-routes)). + 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 diff --git a/docs/whatsnew.md b/docs/whatsnew.md index 1c09cfb..a3ac348 100644 --- a/docs/whatsnew.md +++ b/docs/whatsnew.md @@ -6,7 +6,19 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g --- -## Unreleased (since debb711) +## Unreleased (since c6d2367) + +### New features + +- **Jump from a chat route straight to the map.** Tapping a route under a channel message used to just copy it to the clipboard. Now it opens the **Path Analyzer** on its map view with that message selected and that exact route already drawn — the quickest way to see where a message physically travelled. Copying isn't gone: each route in the popup keeps a small clipboard icon for pasting into the console's `change_path`. + +### Reliability & polish + +- **Your own sent channel messages no longer show a route that isn't theirs.** A bug could attach a stranger's overheard packet to your just-sent message, so the delivery badge and the Path Analyzer occasionally displayed a repeater hash and a physically impossible path that were never part of your message. Sent-message echoes are now matched exactly, so the route you see is really yours. + +--- + +## 2026-07-22 ### New features