From 07d2c347baccff343c123f9b56023c66b5ee8242 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sun, 19 Jul 2026 19:01:23 +0200 Subject: [PATCH] feat(pathanalyzer): path display and filters (stage 3) - Message rows expand to show each echo as per-hop repeater hash chips (A3 -> 3B -> 5E) with direction badge, SNR and received time; tokens split per that echo's own hash_size (same logic as showPathsPopup), 0-hop echoes shown as 'Direct (flood, 0 hops)' - Chip click copies the repeater hash, echo line click copies the comma route (parity with the chat path popup) - Filter bar (client-side, combinable, 150ms debounce): hop count (any/0/1/2/3/4+, matches if any echo has that hop count), repeater hash token (hex-normalized prefix match, bridges mixed hash sizes), sender substring; Clear button appears when any filter is active - 'N of M messages' counter and a dedicated empty state when filters match nothing Verified live via Playwright: expand/collapse, both copy actions, hops=2 yields only rows with a 2-hop echo (39/602), combined token+hops filter, sender filter, clear resets to full set. Co-Authored-By: Claude Fable 5 --- app/static/js/path-analyzer.js | 174 +++++++++++++++++++++++++++++-- app/templates/path-analyzer.html | 94 ++++++++++++++++- 2 files changed, 261 insertions(+), 7 deletions(-) diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index e7b70a5..faf694f 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -83,6 +83,41 @@ function showNotification(message, type = 'info') { // ================================================================ let paMessages = []; +let paFilters = { hops: 'any', token: '', sender: '' }; + +// 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) { + const chunkLen = (echo.hash_size || 1) * 2; + const tokens = []; + const hex = echo.path || ''; + for (let i = 0; i < hex.length; i += chunkLen) { + tokens.push(hex.substring(i, i + chunkLen).toUpperCase()); + } + return { ...echo, tokens: tokens, hops: tokens.length }; +} + +function paMessageMatchesFilters(msg) { + if (paFilters.hops !== 'any') { + const want = paFilters.hops; + const ok = msg.echoView.some(e => + want === '4+' ? e.hops >= 4 : e.hops === parseInt(want, 10)); + if (!ok) return false; + } + if (paFilters.token) { + const t = paFilters.token; + const ok = msg.echoView.some(e => e.tokens.some(tok => tok.startsWith(t))); + if (!ok) return false; + } + if (paFilters.sender) { + if (!(msg.sender || '').toLowerCase().includes(paFilters.sender)) return false; + } + return true; +} + +function paFiltersActive() { + return paFilters.hops !== 'any' || paFilters.token !== '' || paFilters.sender !== ''; +} function paFormatTime(msg) { if (!msg.timestamp) return '—'; @@ -99,12 +134,65 @@ function paCopyText(text, label) { ); } +function paBuildEchoLine(echo) { + const line = document.createElement('div'); + line.className = 'pa-echo-line'; + line.title = 'Click to copy route'; + + const dirBadge = document.createElement('span'); + dirBadge.className = 'badge ' + (echo.direction === 'outgoing' ? 'text-bg-primary' : 'text-bg-secondary'); + dirBadge.textContent = echo.direction === 'outgoing' ? 'out' : 'in'; + line.appendChild(dirBadge); + + if (echo.hops === 0) { + const direct = document.createElement('span'); + direct.className = 'pa-direct'; + direct.textContent = 'Direct (flood, 0 hops)'; + line.appendChild(direct); + } else { + echo.tokens.forEach((tok, i) => { + if (i > 0) { + const arrow = document.createElement('i'); + arrow.className = 'bi bi-arrow-right pa-chip-arrow'; + line.appendChild(arrow); + } + const chip = document.createElement('span'); + chip.className = 'pa-chip'; + chip.textContent = tok; + chip.title = `Copy repeater hash ${tok}`; + chip.addEventListener('click', (e) => { + e.stopPropagation(); + paCopyText(tok, 'Repeater hash'); + }); + line.appendChild(chip); + }); + } + + const meta = document.createElement('span'); + meta.className = 'pa-echo-meta ms-1'; + const snr = (echo.snr === null || echo.snr === undefined) ? '?' : `${Number(echo.snr).toFixed(1)} dB`; + meta.textContent = `SNR: ${snr} | ${echo.received_at || ''}`; + line.appendChild(meta); + + line.addEventListener('click', () => { + paCopyText(echo.tokens.join(','), 'Route'); + }); + return line; +} + function paRenderTable() { const body = document.getElementById('paTableBody'); body.innerHTML = ''; - for (const msg of paMessages) { + const filtered = paMessages.filter(paMessageMatchesFilters); + + for (const msg of filtered) { const tr = document.createElement('tr'); + tr.className = 'pa-msg-row' + (msg.echoView.length === 0 ? ' pa-no-echoes' : ''); + + const tdCaret = document.createElement('td'); + tdCaret.innerHTML = ''; + tr.appendChild(tdCaret); const tdTime = document.createElement('td'); tdTime.className = 'pa-time'; @@ -133,7 +221,10 @@ function paRenderTable() { span.className = 'pa-hash'; span.textContent = msg.packet_hash; span.title = 'Click to copy'; - span.addEventListener('click', () => paCopyText(msg.packet_hash, 'Packet hash')); + span.addEventListener('click', (e) => { + e.stopPropagation(); + paCopyText(msg.packet_hash, 'Packet hash'); + }); tdHash.appendChild(span); } else { tdHash.innerHTML = 'no path data'; @@ -147,14 +238,44 @@ function paRenderTable() { const tdEchoes = document.createElement('td'); tdEchoes.className = 'text-end'; - tdEchoes.textContent = msg.echoes.length; + tdEchoes.textContent = msg.echoView.length; tr.appendChild(tdEchoes); body.appendChild(tr); + + if (msg.echoView.length > 0) { + const detailTr = document.createElement('tr'); + detailTr.className = 'd-none'; + const detailTd = document.createElement('td'); + detailTd.className = 'pa-echo-cell'; + detailTd.colSpan = 8; + for (const echo of msg.echoView) { + detailTd.appendChild(paBuildEchoLine(echo)); + } + detailTr.appendChild(detailTd); + body.appendChild(detailTr); + + tr.addEventListener('click', () => { + tr.classList.toggle('pa-open'); + detailTr.classList.toggle('d-none'); + }); + } } - document.getElementById('paCounter').textContent = - `${paMessages.length} message${paMessages.length === 1 ? '' : 's'}`; + const counter = document.getElementById('paCounter'); + counter.textContent = paFiltersActive() + ? `${filtered.length} of ${paMessages.length} messages` + : `${paMessages.length} message${paMessages.length === 1 ? '' : 's'}`; + + // Empty state when filters exclude everything + if (paMessages.length > 0) { + if (filtered.length === 0) { + document.getElementById('paEmptyText').textContent = 'No messages match the current filters.'; + paSetView('empty'); + } else { + paSetView('table'); + } + } } function paSetView(state) { @@ -176,6 +297,9 @@ async function paLoadMessages() { } // Newest first for the analysis table (API returns ascending) paMessages = (data.messages || []).slice().reverse(); + paMessages.forEach(msg => { + msg.echoView = (msg.echoes || []).map(paDecodeEcho); + }); } catch (e) { console.error('Failed to load messages:', e); showNotification(`Failed to load messages: ${e.message}`, 'danger'); @@ -183,13 +307,40 @@ async function paLoadMessages() { } if (paMessages.length === 0) { + document.getElementById('paEmptyText').textContent = 'No messages in the selected time range.'; paSetView('empty'); } else { paRenderTable(); - paSetView('table'); } } +// ================================================================ +// Filters +// ================================================================ + +function paReadFilters() { + paFilters.hops = document.getElementById('paHopsFilter').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.sender = document.getElementById('paSenderFilter').value.trim().toLowerCase(); + document.getElementById('paClearFiltersBtn').classList.toggle('d-none', !paFiltersActive()); +} + +function paApplyFilters() { + paReadFilters(); + if (paMessages.length > 0) { + paRenderTable(); + } +} + +function paClearFilters() { + document.getElementById('paHopsFilter').value = 'any'; + document.getElementById('paTokenFilter').value = ''; + document.getElementById('paSenderFilter').value = ''; + paApplyFilters(); +} + // ================================================================ // Init // ================================================================ @@ -198,5 +349,16 @@ document.addEventListener('DOMContentLoaded', () => { loadUiSettings(); document.getElementById('paDaysSelect').addEventListener('change', paLoadMessages); document.getElementById('paRefreshBtn').addEventListener('click', paLoadMessages); + + let filterDebounce = null; + const debouncedApply = () => { + clearTimeout(filterDebounce); + filterDebounce = setTimeout(paApplyFilters, 150); + }; + document.getElementById('paHopsFilter').addEventListener('change', paApplyFilters); + document.getElementById('paTokenFilter').addEventListener('input', debouncedApply); + document.getElementById('paSenderFilter').addEventListener('input', debouncedApply); + document.getElementById('paClearFiltersBtn').addEventListener('click', paClearFilters); + paLoadMessages(); }); diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index e914176..e426a8a 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -103,6 +103,81 @@ white-space: nowrap; } + .pa-caret { + display: inline-block; + transition: transform 0.15s; + font-size: 0.7rem; + width: 1rem; + } + + tr.pa-open .pa-caret { + transform: rotate(90deg); + } + + .pa-msg-row { + cursor: pointer; + } + + .pa-msg-row.pa-no-echoes { + cursor: default; + } + + .pa-msg-row.pa-no-echoes .pa-caret { + visibility: hidden; + } + + .pa-echo-cell { + background-color: var(--bg-surface, rgba(0, 0, 0, 0.03)); + padding: 0.35rem 0.75rem 0.35rem 2rem; + } + + .pa-echo-line { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.2rem 0; + cursor: pointer; + border-radius: 0.25rem; + } + + .pa-echo-line:hover { + background-color: var(--hover-bg, rgba(0, 0, 0, 0.05)); + } + + .pa-echo-meta { + font-size: 0.75rem; + color: var(--text-secondary, #6c757d); + white-space: nowrap; + } + + .pa-chip { + font-family: var(--bs-font-monospace, monospace); + font-size: 0.75rem; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + padding: 0.05rem 0.35rem; + cursor: pointer; + background-color: var(--card-bg, transparent); + } + + .pa-chip:hover { + background-color: #6f42c1; + border-color: #6f42c1; + color: #fff; + } + + .pa-chip-arrow { + font-size: 0.7rem; + color: var(--text-secondary, #6c757d); + } + + .pa-direct { + font-size: 0.75rem; + font-style: italic; + color: var(--text-secondary, #6c757d); + } + .empty-state { text-align: center; color: var(--text-secondary, #6c757d); @@ -129,6 +204,22 @@ +
+ + + + @@ -140,12 +231,13 @@
- No messages in the selected time range. + No messages in the selected time range.
+
Time Channel Sender