// 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' 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 || []; 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 }; } 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') { 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 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) { 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'; 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', (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'; 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; 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; 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('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('.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'); } } // ================================================================ // 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 selected = msg.id === paMapSelection.msgId; const entry = document.createElement('div'); 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 (selected) { 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 - // 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() ? `${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(); } } function paSwitchView(view) { paCurrentView = view; for (const [btnId, v] of [['paViewMessagesBtn', 'messages'], ['paViewStatsBtn', 'stats'], ['paViewMapBtn', 'map']]) { document.getElementById(btnId).className = 'btn ' + (view === v ? 'btn-primary' : 'btn-outline-primary'); } paApplyFilters(); } 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(); 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'); paMessages = []; } if (paMessages.length === 0) { document.getElementById('paEmptyText').textContent = 'No messages in the selected time range.'; paSetView('empty'); } else { paRender(); } } // ================================================================ // Filters // ================================================================ function paReadFilters() { paFilters.hops = document.getElementById('paHopsFilter').value; paFilters.hashSize = document.getElementById('paHashSizeFilter').value; 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()); } function paApplyFilters() { paReadFilters(); if (paMessages.length > 0) { paRender(); } } function paClearFilters() { document.getElementById('paHopsFilter').value = 'any'; document.getElementById('paHashSizeFilter').value = 'any'; document.getElementById('paTokenFilter').value = ''; document.getElementById('paSenderFilter').value = ''; document.getElementById('paContentFilter').value = ''; paApplyFilters(); } // ================================================================ // Init // ================================================================ 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('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')); 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; if (paStatsSort.key === k) { paStatsSort.dir = -paStatsSort.dir; } else { paStatsSort = { key: k, dir: -1 }; } paRenderStats(); }); }); paLoadContacts(); paLoadMessages(); });