diff --git a/app/static/js/app.js b/app/static/js/app.js index aae4ea6..bb33f4d 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -484,6 +484,11 @@ function connectChatSocket() { chatSocket.on('device_status', (data) => { updateStatus(data.connected ? 'connected' : 'disconnected'); }); + + // Observer live status (Settings > Observer tab badges + counters) + chatSocket.on('observer_status', (data) => { + applyObserverLiveStatus(data || {}); + }); } document.addEventListener('DOMContentLoaded', async function() { @@ -2572,6 +2577,7 @@ document.addEventListener('DOMContentLoaded', () => { loadContactsSettings(); loadRegions(); loadAnalyzers(); + loadObserverTab(); }); settingsModal.addEventListener('shown.bs.modal', () => { settingsModal.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => { @@ -2631,6 +2637,29 @@ document.addEventListener('DOMContentLoaded', () => { }); } + // Observer tab: settings + broker add/edit form + document.getElementById('observerEnabledToggle')?.addEventListener('change', (e) => { + saveObserverSettings({ enabled: e.target.checked }); + }); + document.getElementById('observerIataInput')?.addEventListener('change', (e) => { + const iata = (e.target.value || '').trim(); + if (iata && !/^[A-Za-z]{3}$/.test(iata)) { + showNotification('Location code must be empty or exactly 3 letters', 'warning'); + return; + } + saveObserverSettings({ iata }); + }); + document.getElementById('observerAdvertIntervalInput')?.addEventListener('change', (e) => { + saveObserverSettings({ advert_interval_hours: parseInt(e.target.value || '0', 10) || 0 }); + }); + document.getElementById('addObserverBrokerBtn')?.addEventListener('click', () => { + openObserverBrokerModal(null); + }); + document.getElementById('observerBrokerEditForm')?.addEventListener('submit', (e) => { + e.preventDefault(); + saveObserverBrokerFromForm(); + }); + // Preload analyzers so the first click on a chart icon doesn't need a round-trip. loadAnalyzers(); @@ -3466,6 +3495,283 @@ function openAnalyzerChooser(packetHash, enabled) { modal.show(); } +// ================================================================ +// Observer (Settings > Observer): MQTT packet capture +// ================================================================ + +window.observerCache = window.observerCache || { + status: null, + brokers: [], +}; + +async function loadObserverTab() { + try { + const resp = await fetch('/api/observer/status'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + if (!data.success) throw new Error(data.error || 'Failed'); + window.observerCache.status = data.status; + window.observerCache.brokers = data.status.brokers || []; + renderObserverSettings(data.status); + renderObserverBrokers(); + renderObserverStatusLine(data.status); + } catch (e) { + console.error('Error loading observer status:', e); + const listEl = document.getElementById('observerBrokersList'); + if (listEl) { + listEl.innerHTML = '
Failed to load observer status
'; + } + } +} + +function renderObserverSettings(status) { + const s = status.settings || {}; + const enabledEl = document.getElementById('observerEnabledToggle'); + const iataEl = document.getElementById('observerIataInput'); + const advertEl = document.getElementById('observerAdvertIntervalInput'); + if (enabledEl) enabledEl.checked = !!s.enabled; + // Don't clobber a field the user is typing in + if (iataEl && document.activeElement !== iataEl) iataEl.value = (s.iata || '').toUpperCase(); + if (advertEl && document.activeElement !== advertEl) advertEl.value = s.advert_interval_hours || 0; +} + +function renderObserverStatusLine(status) { + const el = document.getElementById('observerStatusLine'); + if (!el || !status) return; + if (!status.enabled) { + el.innerHTML = 'Observer is off.'; + return; + } + const state = status.running + ? 'running' + : `waiting ${escapeHtml(status.reason || '')}`; + el.innerHTML = `${state} — packets captured: ${status.packets_seen ?? 0},` + + ` published: ${status.packets_published ?? 0}`; +} + +function observerBrokerBadgeParts(b) { + if (b.connected) return { cls: 'bg-success', txt: 'connected', title: '' }; + if (b.last_error) return { cls: 'bg-danger', txt: 'error', title: b.last_error }; + return { cls: 'bg-secondary', txt: 'offline', title: '' }; +} + +function renderObserverBrokers() { + const listEl = document.getElementById('observerBrokersList'); + if (!listEl) return; + const brokers = window.observerCache.brokers || []; + + if (brokers.length === 0) { + listEl.innerHTML = + '
No brokers configured. Click "Add broker" to add one.
'; + return; + } + + const rows = brokers.map(b => { + const enabled = !b.is_disabled; + const nameClass = enabled ? '' : 'text-muted text-decoration-line-through'; + const safeName = escapeHtml(b.name); + const tlsBadge = b.use_tls ? 'TLS' : ''; + const badge = b.is_disabled + ? 'Disabled' + : (() => { + const p = observerBrokerBadgeParts(b); + return `${p.txt}`; + })(); + const userInfo = b.username ? `${escapeHtml(b.username)}@` : ''; + return ` +
+
+
${safeName}${tlsBadge}${badge}
+ ${userInfo}${escapeHtml(b.host)}:${b.port} +
+
+ +
+ + +
+ `; + }).join(''); + + listEl.innerHTML = rows; +} + +// Live updates pushed by the backend (observer_status socket event). +// Payload has no settings/broker rows — only counters + per-client state. +function applyObserverLiveStatus(live) { + renderObserverStatusLine({ ...live, settings: undefined }); + (live.brokers || []).forEach(b => { + const badge = document.getElementById(`observerBrokerBadge_${b.id}`); + if (!badge) return; + const p = observerBrokerBadgeParts(b); + badge.className = `badge ${p.cls} ms-1`; + badge.textContent = p.txt; + badge.title = p.title; + }); +} + +function openObserverBrokerModal(id) { + const modalEl = document.getElementById('observerBrokerEditModal'); + if (!modalEl) return; + const titleEl = document.getElementById('observerBrokerEditModalTitle'); + const idEl = document.getElementById('observerBrokerEditId'); + const nameEl = document.getElementById('observerBrokerEditName'); + const hostEl = document.getElementById('observerBrokerEditHost'); + const portEl = document.getElementById('observerBrokerEditPort'); + const userEl = document.getElementById('observerBrokerEditUsername'); + const passEl = document.getElementById('observerBrokerEditPassword'); + const passHintEl = document.getElementById('observerBrokerPasswordHint'); + const tlsEl = document.getElementById('observerBrokerEditTls'); + const tlsVerifyEl = document.getElementById('observerBrokerEditTlsVerify'); + const enabledEl = document.getElementById('observerBrokerEditEnabled'); + const errorEl = document.getElementById('observerBrokerEditError'); + + errorEl.classList.add('d-none'); + errorEl.textContent = ''; + passEl.value = ''; + + if (id) { + const b = (window.observerCache.brokers || []).find(x => x.id === id); + if (!b) return; + titleEl.textContent = 'Edit broker'; + idEl.value = String(b.id); + nameEl.value = b.name || ''; + hostEl.value = b.host || ''; + portEl.value = b.port || 1883; + userEl.value = b.username || ''; + tlsEl.checked = !!b.use_tls; + tlsVerifyEl.checked = !!b.tls_verify; + enabledEl.checked = !b.is_disabled; + passHintEl.classList.toggle('d-none', !b.has_password); + } else { + titleEl.textContent = 'Add broker'; + idEl.value = ''; + nameEl.value = ''; + hostEl.value = ''; + portEl.value = 1883; + userEl.value = ''; + tlsEl.checked = false; + tlsVerifyEl.checked = true; + enabledEl.checked = true; + passHintEl.classList.add('d-none'); + } + + modalEl.addEventListener('shown.bs.modal', _bumpAnalyzerBackdrop, { once: true }); + bootstrap.Modal.getOrCreateInstance(modalEl).show(); +} + +function showObserverBrokerFormError(msg) { + const errorEl = document.getElementById('observerBrokerEditError'); + if (!errorEl) return; + errorEl.textContent = msg; + errorEl.classList.remove('d-none'); +} + +async function saveObserverBrokerFromForm() { + const id = document.getElementById('observerBrokerEditId').value + ? parseInt(document.getElementById('observerBrokerEditId').value, 10) : null; + const name = (document.getElementById('observerBrokerEditName').value || '').trim(); + const host = (document.getElementById('observerBrokerEditHost').value || '').trim(); + const port = parseInt(document.getElementById('observerBrokerEditPort').value || '1883', 10); + const username = (document.getElementById('observerBrokerEditUsername').value || '').trim(); + const password = document.getElementById('observerBrokerEditPassword').value; + const use_tls = document.getElementById('observerBrokerEditTls').checked; + const tls_verify = document.getElementById('observerBrokerEditTlsVerify').checked; + const is_disabled = !document.getElementById('observerBrokerEditEnabled').checked; + + if (!name) { showObserverBrokerFormError('Name is required'); return; } + if (!host) { showObserverBrokerFormError('Host is required'); return; } + if (!(port >= 1 && port <= 65535)) { showObserverBrokerFormError('Port must be 1-65535'); return; } + + const body = { name, host, port, username, use_tls, tls_verify, is_disabled }; + // Edit mode: an empty password field means "keep the stored password" + if (!id || password !== '') body.password = password; + + try { + const url = id ? `/api/observer/brokers/${id}` : '/api/observer/brokers'; + const resp = await fetch(url, { + method: id ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data.success) { + showObserverBrokerFormError(data.error || 'Failed to save broker'); + return; + } + bootstrap.Modal.getInstance(document.getElementById('observerBrokerEditModal'))?.hide(); + // Reload once now and once after the reconnect settles + await loadObserverTab(); + setTimeout(loadObserverTab, 1500); + } catch (e) { + console.error('Error saving observer broker:', e); + showObserverBrokerFormError('Network error saving broker'); + } +} + +async function deleteObserverBroker(id, name) { + if (!confirm(`Delete broker "${name}"?`)) return; + try { + const resp = await fetch(`/api/observer/brokers/${id}`, { method: 'DELETE' }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data.success) { + showNotification(data.error || 'Failed to delete broker', 'danger'); + return; + } + await loadObserverTab(); + } catch (e) { + console.error('Error deleting observer broker:', e); + showNotification('Network error deleting broker', 'danger'); + } +} + +async function toggleObserverBrokerDisabled(id, disabled) { + try { + const resp = await fetch(`/api/observer/brokers/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_disabled: !!disabled }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data.success) { + showNotification(data.error || 'Failed to update broker', 'danger'); + } + } catch (e) { + console.error('Error toggling observer broker:', e); + showNotification('Network error updating broker', 'danger'); + } + await loadObserverTab(); + setTimeout(loadObserverTab, 1500); +} + +async function saveObserverSettings(patch) { + try { + const resp = await fetch('/api/observer/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data.success) { + showNotification(data.error || 'Failed to save observer settings', 'danger'); + await loadObserverTab(); + return; + } + // The reload runs on a backend thread; refresh after it settles + setTimeout(loadObserverTab, 1500); + } catch (e) { + console.error('Error saving observer settings:', e); + showNotification('Network error saving observer settings', 'danger'); + } +} + // ================================================================ // Per-channel region picker (Manage Channels > row > pin icon) // ================================================================ diff --git a/app/templates/base.html b/app/templates/base.html index f436521..b154945 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -459,6 +459,9 @@ + @@ -1009,6 +1012,45 @@ Disabled analyzers stay in the list but are hidden from the chooser. +
+
+ Publish every mesh packet this device overhears to + MQTT brokers, in the meshcore-packet-capture format + (topics meshcore/IATA/PUBKEY/packets). Compatible with + letsmesh-style packet analyzers. +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ The 3-letter location code is used in MQTT topics; leave it empty for the flat + meshcore/packets topics. Advert interval 0 disables scheduled flood adverts. +
+
+
MQTT Brokers
+
+
+
Loading... +
+
+ +

Browser notifications appear when the app is hidden or in the background.

+ + +