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..d61f92a 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') @@ -508,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: @@ -550,6 +574,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.""" @@ -6274,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/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..1a692d6 --- /dev/null +++ b/app/static/js/path-analyzer.js @@ -0,0 +1,1073 @@ +// 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 }; + +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 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) + +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)`) + .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.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 { + // 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: PA_PATH_COLOR, weight: 3, opacity: 0.85, + 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'; + + // 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'; + 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); + 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'; + 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; +} + +// 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(); + + 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 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(left); + 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', () => { + // 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(); + }); + + 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 === 'routes') { + paRenderRoutes(); + } else if (paCurrentView === 'map') { + paRenderMapView(); + } else { + paRenderTable(); + } +} + +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'); + } + 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()); + + // Active-filter count on the mobile Filters toggle, so filters applied + // while the panel is collapsed stay visible + const activeCount = [paFilters.hops !== 'any', paFilters.hashSize !== 'any', + paFilters.token !== '', paFilters.sender !== '', + paFilters.content !== ''].filter(Boolean).length; + const badge = document.getElementById('paFiltersBadge'); + badge.textContent = activeCount; + badge.classList.toggle('d-none', activeCount === 0); +} + +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('paViewRoutesBtn').addEventListener('click', () => paSwitchView('routes')); + document.getElementById('paViewMapBtn').addEventListener('click', () => paSwitchView('map')); + document.getElementById('paMapClearBtn').addEventListener('click', paClearMapSelection); + 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) { + paStatsSort.dir = -paStatsSort.dir; + } else { + paStatsSort = { key: k, dir: -1 }; + } + 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/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' ? ` + ` : ''}
+
@@ -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 @@ + + + + + +