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..e7b70a5 --- /dev/null +++ b/app/static/js/path-analyzer.js @@ -0,0 +1,202 @@ +// 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 = []; + +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 paRenderTable() { + const body = document.getElementById('paTableBody'); + body.innerHTML = ''; + + for (const msg of paMessages) { + const tr = document.createElement('tr'); + + 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', () => 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 tdEchoes = document.createElement('td'); + tdEchoes.className = 'text-end'; + tdEchoes.textContent = msg.echoes.length; + tr.appendChild(tdEchoes); + + body.appendChild(tr); + } + + document.getElementById('paCounter').textContent = + `${paMessages.length} message${paMessages.length === 1 ? '' : 's'}`; +} + +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'); +} + +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(); + } catch (e) { + console.error('Failed to load messages:', e); + showNotification(`Failed to load messages: ${e.message}`, 'danger'); + paMessages = []; + } + + if (paMessages.length === 0) { + paSetView('empty'); + } else { + paRenderTable(); + paSetView('table'); + } +} + +// ================================================================ +// Init +// ================================================================ + +document.addEventListener('DOMContentLoaded', () => { + loadUiSettings(); + document.getElementById('paDaysSelect').addEventListener('change', paLoadMessages); + document.getElementById('paRefreshBtn').addEventListener('click', paLoadMessages); + paLoadMessages(); +}); diff --git a/app/templates/base.html b/app/templates/base.html index ecc0ff2..b2ab716 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -205,6 +205,13 @@ Repeater administration +
@@ -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 @@ + + +