// 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 = []; let paFilters = { hops: 'any', hashSize: 'any', token: '', sender: '', content: '' }; 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 { const resp = await fetch('/api/contacts/cached?format=full'); if (resp.ok) { const data = await resp.json(); if (data.success) { paContacts = data.contacts || []; paTokenNameCache = new Map(); } } } 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)); } // 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) { 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 }; } // 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; const ok = msg.echoView.some(e => want === '4+' ? e.hops >= 4 : e.hops === parseInt(want, 10)); if (!ok) return false; } if (paFilters.hashSize !== 'any') { // 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) { // '>' (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) { 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.content !== ''; } 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 paBuildEchoLine(msg, echo, echoIdx) { 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); 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'); }); return line; } function paRenderTable() { const body = document.getElementById('paTableBody'); body.innerHTML = ''; 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'; 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'); 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'); tdHash.className = 'pa-col-hash'; 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', (e) => { e.stopPropagation(); 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 tdHb = document.createElement('td'); 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 pa-col-echoes'; 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 = 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)); }); detailTr.appendChild(detailTd); body.appendChild(detailTr); tr.addEventListener('click', () => { tr.classList.toggle('pa-open'); detailTr.classList.toggle('d-none'); }); } } 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) { 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'); document.getElementById('paRoutesWrap').classList.toggle('d-none', state !== 'routes'); document.getElementById('paMapWrap').classList.toggle('d-none', state !== 'map'); } // ================================================================ // 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('#paStatsWrap .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, cls] of [[s.relayed, ''], [s.messages, ' pa-col-msgs'], [s.lastHop, ' pa-col-lasthop']]) { const td = document.createElement('td'); td.className = 'text-end' + cls; 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'); } } // ================================================================ // 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 // ================================================================ let paMap = null; let paBaseLayer = null; // repeater contact markers let paAltLayer = null; // the selected message's other echoes let paPathLayer = null; // drawn path for the selected echo // Both map overlays are opt-in: the map opens showing just the picked // route, everything else is noise until the user asks for it let paShowAllRepeaters = false; let paShowAltPaths = false; // 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'; // Alternative echoes get one light hue each, cycled by echo index, so two // alternatives running near each other stay tellable apart. Kept clear of // the primary red and of the marker colors above. const PA_ALT_PATH_COLORS = [ '#4dabf7', // light blue '#20c997', // teal '#ff922b', // orange '#e599f7', // violet '#94d82d', // lime ]; function paAltColor(echoIdx) { return PA_ALT_PATH_COLORS[echoIdx % PA_ALT_PATH_COLORS.length]; } function paHopIcon(n) { return L.divIcon({ className: 'pa-hop-icon', html: `
${(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 };
}
// Draw one echo's route. The primary echo gets numbered hop badges, name
// labels and candidate markers; alternatives get a plain coloured line -
// several fully decorated paths at once would be unreadable.
// Returns the latlngs it contributed, for bounds fitting.
function paDrawEcho(msg, echo, primary, seen, altColor) {
const layer = primary ? paPathLayer : paAltLayer;
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) {
if (primary) {
L.circleMarker([origin.adv_lat, origin.adv_lon], {
radius: 7, color: '#198754', weight: 2, fillOpacity: 0.8
}).bindPopup(`${origin.name}${tok}`)
.bindTooltip(contact.name, { permanent: true, direction: 'right', offset: [13, 0], className: 'pa-hop-label' })
.addTo(layer);
}
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
if (primary) {
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}${tok}`).addTo(layer);
});
}
gapPending = true;
}
});
// Polyline: solid between consecutive resolved hops, dashed across skipped ones.
// Echoes of one message usually share a long prefix, so a segment is drawn
// only once - an alternative then shows exactly where it diverges instead of
// hiding under the primary route.
const snr = (echo.snr === null || echo.snr === undefined) ? '?' : `${Number(echo.snr).toFixed(1)} dB`;
const popup = `Alternative path