From 2723d91526788a00733a5e6b7dd6603581b34121 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 19:18:13 +0000 Subject: [PATCH] perf: add destroy() lifecycle to all mode modules to prevent resource leaks Mode modules were leaking EventSource connections, setInterval timers, and setTimeout timers on every mode switch, causing progressive browser sluggishness. Added destroy() to 8 modules missing it (meshtastic, bluetooth, wifi, bt_locate, sstv, sstv-general, websdr, spy-stations) and centralized all destroy calls in switchMode() via a moduleDestroyMap that cleanly tears down only the previous mode. Co-Authored-By: Claude Opus 4.6 --- static/js/modes/bluetooth.js | 916 ++++++++++++++++---------------- static/js/modes/bt_locate.js | 35 ++ static/js/modes/meshtastic.js | 32 +- static/js/modes/spy-stations.js | 10 +- static/js/modes/sstv-general.js | 10 +- static/js/modes/sstv.js | 185 ++++--- static/js/modes/websdr.js | 10 + static/js/modes/wifi.js | 769 ++++++++++++++------------- templates/index.html | 52 +- 9 files changed, 1063 insertions(+), 956 deletions(-) diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index 2a7c856..f432e98 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -27,22 +27,22 @@ const BluetoothMode = (function() { trackers: [] }; - // Zone counts for proximity display - let zoneCounts = { immediate: 0, near: 0, far: 0 }; + // Zone counts for proximity display + let zoneCounts = { immediate: 0, near: 0, far: 0 }; // New visualization components let radarInitialized = false; let radarPaused = false; - // Device list filter - let currentDeviceFilter = 'all'; - let currentSearchTerm = ''; - let visibleDeviceCount = 0; - let pendingDeviceFlush = false; - let selectedDeviceNeedsRefresh = false; - let filterListenersBound = false; - let listListenersBound = false; - const pendingDeviceIds = new Set(); + // Device list filter + let currentDeviceFilter = 'all'; + let currentSearchTerm = ''; + let visibleDeviceCount = 0; + let pendingDeviceFlush = false; + let selectedDeviceNeedsRefresh = false; + let filterListenersBound = false; + let listListenersBound = false; + const pendingDeviceIds = new Set(); // Agent support let showAllAgentsMode = false; @@ -116,9 +116,9 @@ const BluetoothMode = (function() { // Initialize legacy heatmap (zone counts) initHeatmap(); - // Initialize device list filters - initDeviceFilters(); - initListInteractions(); + // Initialize device list filters + initDeviceFilters(); + initListInteractions(); // Set initial panel states updateVisualizationPanels(); @@ -127,133 +127,133 @@ const BluetoothMode = (function() { /** * Initialize device list filter buttons */ - function initDeviceFilters() { - if (filterListenersBound) return; - const filterContainer = document.getElementById('btDeviceFilters'); - if (filterContainer) { - filterContainer.addEventListener('click', (e) => { - const btn = e.target.closest('.bt-filter-btn'); - if (!btn) return; - - const filter = btn.dataset.filter; - if (!filter) return; - - // Update active state - filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - - // Apply filter - currentDeviceFilter = filter; - applyDeviceFilter(); - }); - } - - const searchInput = document.getElementById('btDeviceSearch'); - if (searchInput) { - searchInput.addEventListener('input', () => { - currentSearchTerm = searchInput.value.trim().toLowerCase(); - applyDeviceFilter(); - }); - } - filterListenersBound = true; - } - - function initListInteractions() { - if (listListenersBound) return; - if (deviceContainer) { - deviceContainer.addEventListener('click', (event) => { - const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]'); - if (locateBtn) { - event.preventDefault(); - locateById(locateBtn.dataset.locateId); - return; - } - - const row = event.target.closest('.bt-device-row[data-bt-device-id]'); - if (!row) return; - selectDevice(row.dataset.btDeviceId); - }); - } - - const trackerList = document.getElementById('btTrackerList'); - if (trackerList) { - trackerList.addEventListener('click', (event) => { - const row = event.target.closest('.bt-tracker-item[data-device-id]'); - if (!row) return; - selectDevice(row.dataset.deviceId); - }); - } - listListenersBound = true; - } + function initDeviceFilters() { + if (filterListenersBound) return; + const filterContainer = document.getElementById('btDeviceFilters'); + if (filterContainer) { + filterContainer.addEventListener('click', (e) => { + const btn = e.target.closest('.bt-filter-btn'); + if (!btn) return; + + const filter = btn.dataset.filter; + if (!filter) return; + + // Update active state + filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + // Apply filter + currentDeviceFilter = filter; + applyDeviceFilter(); + }); + } + + const searchInput = document.getElementById('btDeviceSearch'); + if (searchInput) { + searchInput.addEventListener('input', () => { + currentSearchTerm = searchInput.value.trim().toLowerCase(); + applyDeviceFilter(); + }); + } + filterListenersBound = true; + } + + function initListInteractions() { + if (listListenersBound) return; + if (deviceContainer) { + deviceContainer.addEventListener('click', (event) => { + const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]'); + if (locateBtn) { + event.preventDefault(); + locateById(locateBtn.dataset.locateId); + return; + } + + const row = event.target.closest('.bt-device-row[data-bt-device-id]'); + if (!row) return; + selectDevice(row.dataset.btDeviceId); + }); + } + + const trackerList = document.getElementById('btTrackerList'); + if (trackerList) { + trackerList.addEventListener('click', (event) => { + const row = event.target.closest('.bt-tracker-item[data-device-id]'); + if (!row) return; + selectDevice(row.dataset.deviceId); + }); + } + listListenersBound = true; + } /** * Apply current filter to device list */ - function applyDeviceFilter() { - if (!deviceContainer) return; - - const cards = deviceContainer.querySelectorAll('[data-bt-device-id]'); - let visibleCount = 0; - cards.forEach(card => { - const isNew = card.dataset.isNew === 'true'; - const hasName = card.dataset.hasName === 'true'; - const rssi = parseInt(card.dataset.rssi) || -100; - const isTracker = card.dataset.isTracker === 'true'; - const searchHaystack = (card.dataset.search || '').toLowerCase(); - - let matchesFilter = true; - switch (currentDeviceFilter) { - case 'new': - matchesFilter = isNew; - break; - case 'named': - matchesFilter = hasName; - break; - case 'strong': - matchesFilter = rssi >= -70; - break; - case 'trackers': - matchesFilter = isTracker; - break; - case 'all': - default: - matchesFilter = true; - } - - const matchesSearch = !currentSearchTerm || searchHaystack.includes(currentSearchTerm); - const visible = matchesFilter && matchesSearch; - card.style.display = visible ? '' : 'none'; - if (visible) visibleCount++; - }); - - visibleDeviceCount = visibleCount; - - let stateEl = deviceContainer.querySelector('.bt-device-filter-state'); - if (visibleCount === 0 && devices.size > 0) { - if (!stateEl) { - stateEl = document.createElement('div'); - stateEl.className = 'bt-device-filter-state app-collection-state is-empty'; - deviceContainer.appendChild(stateEl); - } - stateEl.textContent = 'No devices match current filters'; - } else if (stateEl) { - stateEl.remove(); - } - - // Update visible count - updateFilteredCount(); - } + function applyDeviceFilter() { + if (!deviceContainer) return; + + const cards = deviceContainer.querySelectorAll('[data-bt-device-id]'); + let visibleCount = 0; + cards.forEach(card => { + const isNew = card.dataset.isNew === 'true'; + const hasName = card.dataset.hasName === 'true'; + const rssi = parseInt(card.dataset.rssi) || -100; + const isTracker = card.dataset.isTracker === 'true'; + const searchHaystack = (card.dataset.search || '').toLowerCase(); + + let matchesFilter = true; + switch (currentDeviceFilter) { + case 'new': + matchesFilter = isNew; + break; + case 'named': + matchesFilter = hasName; + break; + case 'strong': + matchesFilter = rssi >= -70; + break; + case 'trackers': + matchesFilter = isTracker; + break; + case 'all': + default: + matchesFilter = true; + } + + const matchesSearch = !currentSearchTerm || searchHaystack.includes(currentSearchTerm); + const visible = matchesFilter && matchesSearch; + card.style.display = visible ? '' : 'none'; + if (visible) visibleCount++; + }); + + visibleDeviceCount = visibleCount; + + let stateEl = deviceContainer.querySelector('.bt-device-filter-state'); + if (visibleCount === 0 && devices.size > 0) { + if (!stateEl) { + stateEl = document.createElement('div'); + stateEl.className = 'bt-device-filter-state app-collection-state is-empty'; + deviceContainer.appendChild(stateEl); + } + stateEl.textContent = 'No devices match current filters'; + } else if (stateEl) { + stateEl.remove(); + } + + // Update visible count + updateFilteredCount(); + } /** * Update the device count display based on visible devices */ - function updateFilteredCount() { - const countEl = document.getElementById('btDeviceListCount'); - if (!countEl || !deviceContainer) return; - - const hasFilter = currentDeviceFilter !== 'all' || currentSearchTerm.length > 0; - countEl.textContent = hasFilter ? `${visibleDeviceCount}/${devices.size}` : devices.size; - } + function updateFilteredCount() { + const countEl = document.getElementById('btDeviceListCount'); + if (!countEl || !deviceContainer) return; + + const hasFilter = currentDeviceFilter !== 'all' || currentSearchTerm.length > 0; + countEl.textContent = hasFilter ? `${visibleDeviceCount}/${devices.size}` : devices.size; + } /** * Initialize the new proximity radar component @@ -369,20 +369,20 @@ const BluetoothMode = (function() { /** * Update proximity zone counts (simple HTML, no canvas) */ - function updateProximityZones() { - zoneCounts = { immediate: 0, near: 0, far: 0 }; - - devices.forEach(device => { - const rssi = device.rssi_current; - if (rssi == null) return; - - if (rssi >= -50) zoneCounts.immediate++; - else if (rssi >= -70) zoneCounts.near++; - else zoneCounts.far++; - }); - - updateProximityZoneCounts(zoneCounts); - } + function updateProximityZones() { + zoneCounts = { immediate: 0, near: 0, far: 0 }; + + devices.forEach(device => { + const rssi = device.rssi_current; + if (rssi == null) return; + + if (rssi >= -50) zoneCounts.immediate++; + else if (rssi >= -70) zoneCounts.near++; + else zoneCounts.far++; + }); + + updateProximityZoneCounts(zoneCounts); + } // Currently selected device let selectedDeviceId = null; @@ -944,59 +944,59 @@ const BluetoothMode = (function() { } } - async function stopScan() { - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const timeoutMs = isAgentMode ? 8000 : 2200; - const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; - const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; - - // Optimistic UI teardown keeps mode changes responsive. - setScanning(false); - stopEventStream(); - - try { - if (isAgentMode) { - await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { - method: 'POST', - ...(controller ? { signal: controller.signal } : {}), - }); - } else { - await fetch('/api/bluetooth/scan/stop', { - method: 'POST', - ...(controller ? { signal: controller.signal } : {}), - }); - } - } catch (err) { - console.error('Failed to stop scan:', err); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } - } + async function stopScan() { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const timeoutMs = isAgentMode ? 8000 : 2200; + const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; + + // Optimistic UI teardown keeps mode changes responsive. + setScanning(false); + stopEventStream(); + + try { + if (isAgentMode) { + await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } else { + await fetch('/api/bluetooth/scan/stop', { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } + } catch (err) { + console.error('Failed to stop scan:', err); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } function setScanning(scanning) { isScanning = scanning; - if (startBtn) startBtn.style.display = scanning ? 'none' : 'block'; - if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none'; - - if (scanning && deviceContainer) { - pendingDeviceIds.clear(); - selectedDeviceNeedsRefresh = false; - pendingDeviceFlush = false; - if (typeof renderCollectionState === 'function') { - renderCollectionState(deviceContainer, { type: 'loading', message: 'Scanning for Bluetooth devices...' }); - } else { - deviceContainer.innerHTML = ''; - } - devices.clear(); - resetStats(); - } else if (!scanning && deviceContainer && devices.size === 0) { - if (typeof renderCollectionState === 'function') { - renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' }); - } - } + if (startBtn) startBtn.style.display = scanning ? 'none' : 'block'; + if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none'; + + if (scanning && deviceContainer) { + pendingDeviceIds.clear(); + selectedDeviceNeedsRefresh = false; + pendingDeviceFlush = false; + if (typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'loading', message: 'Scanning for Bluetooth devices...' }); + } else { + deviceContainer.innerHTML = ''; + } + devices.clear(); + resetStats(); + } else if (!scanning && deviceContainer && devices.size === 0) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' }); + } + } const statusDot = document.getElementById('statusDot'); const statusText = document.getElementById('statusText'); @@ -1004,22 +1004,22 @@ const BluetoothMode = (function() { if (statusText) statusText.textContent = scanning ? 'Scanning...' : 'Idle'; } - function resetStats() { - deviceStats = { - strong: 0, - medium: 0, - weak: 0, - trackers: [] - }; - visibleDeviceCount = 0; - updateVisualizationPanels(); - updateProximityZones(); - updateFilteredCount(); - - // Clear radar - if (radarInitialized && typeof ProximityRadar !== 'undefined') { - ProximityRadar.clear(); - } + function resetStats() { + deviceStats = { + strong: 0, + medium: 0, + weak: 0, + trackers: [] + }; + visibleDeviceCount = 0; + updateVisualizationPanels(); + updateProximityZones(); + updateFilteredCount(); + + // Clear radar + if (radarInitialized && typeof ProximityRadar !== 'undefined') { + ProximityRadar.clear(); + } } function startEventStream() { @@ -1161,43 +1161,43 @@ const BluetoothMode = (function() { }, pollInterval); } - function handleDeviceUpdate(device) { - devices.set(device.device_id, device); - pendingDeviceIds.add(device.device_id); - if (selectedDeviceId === device.device_id) { - selectedDeviceNeedsRefresh = true; - } - scheduleDeviceFlush(); - } - - function scheduleDeviceFlush() { - if (pendingDeviceFlush) return; - pendingDeviceFlush = true; - - requestAnimationFrame(() => { - pendingDeviceFlush = false; - - pendingDeviceIds.forEach((deviceId) => { - const device = devices.get(deviceId); - if (device) { - renderDevice(device, false); - } - }); - pendingDeviceIds.clear(); - - applyDeviceFilter(); - updateDeviceCount(); - updateStatsFromDevices(); - updateVisualizationPanels(); - updateProximityZones(); - updateRadar(); - - if (selectedDeviceNeedsRefresh && selectedDeviceId && devices.has(selectedDeviceId)) { - showDeviceDetail(selectedDeviceId); - } - selectedDeviceNeedsRefresh = false; - }); - } + function handleDeviceUpdate(device) { + devices.set(device.device_id, device); + pendingDeviceIds.add(device.device_id); + if (selectedDeviceId === device.device_id) { + selectedDeviceNeedsRefresh = true; + } + scheduleDeviceFlush(); + } + + function scheduleDeviceFlush() { + if (pendingDeviceFlush) return; + pendingDeviceFlush = true; + + requestAnimationFrame(() => { + pendingDeviceFlush = false; + + pendingDeviceIds.forEach((deviceId) => { + const device = devices.get(deviceId); + if (device) { + renderDevice(device, false); + } + }); + pendingDeviceIds.clear(); + + applyDeviceFilter(); + updateDeviceCount(); + updateStatsFromDevices(); + updateVisualizationPanels(); + updateProximityZones(); + updateRadar(); + + if (selectedDeviceNeedsRefresh && selectedDeviceId && devices.has(selectedDeviceId)) { + showDeviceDetail(selectedDeviceId); + } + selectedDeviceNeedsRefresh = false; + }); + } /** * Update stats from all devices @@ -1232,9 +1232,9 @@ const BluetoothMode = (function() { /** * Update visualization panels */ - function updateVisualizationPanels() { - // Signal Distribution - const total = devices.size || 1; + function updateVisualizationPanels() { + // Signal Distribution + const total = devices.size || 1; const strongBar = document.getElementById('btSignalStrong'); const mediumBar = document.getElementById('btSignalMedium'); const weakBar = document.getElementById('btSignalWeak'); @@ -1245,120 +1245,120 @@ const BluetoothMode = (function() { if (strongBar) strongBar.style.width = (deviceStats.strong / total * 100) + '%'; if (mediumBar) mediumBar.style.width = (deviceStats.medium / total * 100) + '%'; if (weakBar) weakBar.style.width = (deviceStats.weak / total * 100) + '%'; - if (strongCount) strongCount.textContent = deviceStats.strong; - if (mediumCount) mediumCount.textContent = deviceStats.medium; - if (weakCount) weakCount.textContent = deviceStats.weak; - - // Device summary strip - const totalEl = document.getElementById('btSummaryTotal'); - const newEl = document.getElementById('btSummaryNew'); - const trackersEl = document.getElementById('btSummaryTrackers'); - const strongestEl = document.getElementById('btSummaryStrongest'); - if (totalEl || newEl || trackersEl || strongestEl) { - let newCount = 0; - let strongest = null; - devices.forEach(d => { - if (!d.in_baseline) newCount++; - if (d.rssi_current != null) { - strongest = strongest == null ? d.rssi_current : Math.max(strongest, d.rssi_current); - } - }); - if (totalEl) totalEl.textContent = devices.size; - if (newEl) newEl.textContent = newCount; - if (trackersEl) trackersEl.textContent = deviceStats.trackers.length; - if (strongestEl) strongestEl.textContent = strongest == null ? '--' : `${strongest} dBm`; - } - - // Tracker Detection - Enhanced display with confidence and evidence - const trackerList = document.getElementById('btTrackerList'); - if (trackerList) { - if (devices.size === 0) { - if (typeof renderCollectionState === 'function') { - renderCollectionState(trackerList, { type: 'empty', message: 'Start scanning to detect trackers' }); - } else { - trackerList.innerHTML = '
Start scanning to detect trackers
'; - } - } else if (deviceStats.trackers.length === 0) { - if (typeof renderCollectionState === 'function') { - renderCollectionState(trackerList, { type: 'empty', message: 'No trackers detected' }); - } else { - trackerList.innerHTML = '
No trackers detected
'; - } - } else { - // Sort by risk score (highest first), then confidence - const sortedTrackers = [...deviceStats.trackers].sort((a, b) => { - const riskA = a.risk_score || 0; - const riskB = b.risk_score || 0; - if (riskB !== riskA) return riskB - riskA; - const confA = a.tracker_confidence_score || 0; - const confB = b.tracker_confidence_score || 0; - return confB - confA; - }); - - trackerList.innerHTML = sortedTrackers.map((t) => { - const confidence = t.tracker_confidence || 'low'; - const riskScore = t.risk_score || 0; - const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker'; - const evidence = (t.tracker_evidence || []).slice(0, 2); - const evidenceHtml = evidence.length > 0 - ? `
${evidence.map((e) => `• ${escapeHtml(e)}`).join('
')}
` - : ''; - const riskClass = riskScore >= 0.5 ? 'high' : riskScore >= 0.3 ? 'medium' : 'low'; - const riskHtml = riskScore >= 0.3 - ? `RISK ${Math.round(riskScore * 100)}%` - : ''; - - return ` -
-
-
- ${escapeHtml(confidence.toUpperCase())} - ${escapeHtml(trackerType)} -
-
- ${riskHtml} - ${t.rssi_current != null ? t.rssi_current : '--'} dBm -
-
-
- ${escapeHtml(t.address_type === 'uuid' ? formatAddress(t) : (t.address || '--'))} - Seen ${t.seen_count || 0}x -
- ${evidenceHtml} -
- `; - }).join(''); - } - } - - } + if (strongCount) strongCount.textContent = deviceStats.strong; + if (mediumCount) mediumCount.textContent = deviceStats.medium; + if (weakCount) weakCount.textContent = deviceStats.weak; + + // Device summary strip + const totalEl = document.getElementById('btSummaryTotal'); + const newEl = document.getElementById('btSummaryNew'); + const trackersEl = document.getElementById('btSummaryTrackers'); + const strongestEl = document.getElementById('btSummaryStrongest'); + if (totalEl || newEl || trackersEl || strongestEl) { + let newCount = 0; + let strongest = null; + devices.forEach(d => { + if (!d.in_baseline) newCount++; + if (d.rssi_current != null) { + strongest = strongest == null ? d.rssi_current : Math.max(strongest, d.rssi_current); + } + }); + if (totalEl) totalEl.textContent = devices.size; + if (newEl) newEl.textContent = newCount; + if (trackersEl) trackersEl.textContent = deviceStats.trackers.length; + if (strongestEl) strongestEl.textContent = strongest == null ? '--' : `${strongest} dBm`; + } + + // Tracker Detection - Enhanced display with confidence and evidence + const trackerList = document.getElementById('btTrackerList'); + if (trackerList) { + if (devices.size === 0) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(trackerList, { type: 'empty', message: 'Start scanning to detect trackers' }); + } else { + trackerList.innerHTML = '
Start scanning to detect trackers
'; + } + } else if (deviceStats.trackers.length === 0) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(trackerList, { type: 'empty', message: 'No trackers detected' }); + } else { + trackerList.innerHTML = '
No trackers detected
'; + } + } else { + // Sort by risk score (highest first), then confidence + const sortedTrackers = [...deviceStats.trackers].sort((a, b) => { + const riskA = a.risk_score || 0; + const riskB = b.risk_score || 0; + if (riskB !== riskA) return riskB - riskA; + const confA = a.tracker_confidence_score || 0; + const confB = b.tracker_confidence_score || 0; + return confB - confA; + }); + + trackerList.innerHTML = sortedTrackers.map((t) => { + const confidence = t.tracker_confidence || 'low'; + const riskScore = t.risk_score || 0; + const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker'; + const evidence = (t.tracker_evidence || []).slice(0, 2); + const evidenceHtml = evidence.length > 0 + ? `
${evidence.map((e) => `• ${escapeHtml(e)}`).join('
')}
` + : ''; + const riskClass = riskScore >= 0.5 ? 'high' : riskScore >= 0.3 ? 'medium' : 'low'; + const riskHtml = riskScore >= 0.3 + ? `RISK ${Math.round(riskScore * 100)}%` + : ''; + + return ` +
+
+
+ ${escapeHtml(confidence.toUpperCase())} + ${escapeHtml(trackerType)} +
+
+ ${riskHtml} + ${t.rssi_current != null ? t.rssi_current : '--'} dBm +
+
+
+ ${escapeHtml(t.address_type === 'uuid' ? formatAddress(t) : (t.address || '--'))} + Seen ${t.seen_count || 0}x +
+ ${evidenceHtml} +
+ `; + }).join(''); + } + } + + } function updateDeviceCount() { updateFilteredCount(); } - function renderDevice(device, reapplyFilter = true) { - if (!deviceContainer) { - deviceContainer = document.getElementById('btDeviceListContent'); - if (!deviceContainer) return; - } - - deviceContainer.querySelectorAll('.app-collection-state, .bt-device-filter-state').forEach((el) => el.remove()); - - const escapedId = CSS.escape(device.device_id); - const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]'); - const cardHtml = createSimpleDeviceCard(device); + function renderDevice(device, reapplyFilter = true) { + if (!deviceContainer) { + deviceContainer = document.getElementById('btDeviceListContent'); + if (!deviceContainer) return; + } - if (existingCard) { - existingCard.outerHTML = cardHtml; - } else { - deviceContainer.insertAdjacentHTML('afterbegin', cardHtml); - } - - if (reapplyFilter) { - applyDeviceFilter(); - } - } + deviceContainer.querySelectorAll('.app-collection-state, .bt-device-filter-state').forEach((el) => el.remove()); + + const escapedId = CSS.escape(device.device_id); + const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]'); + const cardHtml = createSimpleDeviceCard(device); + + if (existingCard) { + existingCard.outerHTML = cardHtml; + } else { + deviceContainer.insertAdjacentHTML('afterbegin', cardHtml); + } + + if (reapplyFilter) { + applyDeviceFilter(); + } + } function createSimpleDeviceCard(device) { const protocol = device.protocol || 'ble'; @@ -1378,19 +1378,19 @@ const BluetoothMode = (function() { // RSSI typically ranges from -100 (weak) to -30 (very strong) const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0; - const displayName = device.name || formatDeviceId(device.address); - const name = escapeHtml(displayName); - const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown')); - const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; - const seenCount = device.seen_count || 0; - const searchIndex = [ - displayName, - device.address, - device.manufacturer_name, - device.tracker_name, - device.tracker_type, - agentName - ].filter(Boolean).join(' ').toLowerCase(); + const displayName = device.name || formatDeviceId(device.address); + const name = escapeHtml(displayName); + const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown')); + const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; + const seenCount = device.seen_count || 0; + const searchIndex = [ + displayName, + device.address, + device.manufacturer_name, + device.tracker_name, + device.tracker_type, + agentName + ].filter(Boolean).join(' ').toLowerCase(); // Protocol badge - compact const protoBadge = protocol === 'ble' @@ -1473,14 +1473,14 @@ const BluetoothMode = (function() { } const secondaryInfo = secondaryParts.join(' · '); - // Row border color - highlight trackers in red/orange - const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' : - isTracker ? '#f97316' : rssiColor; - - return '
' + - '
' + - '
' + - protoBadge + + // Row border color - highlight trackers in red/orange + const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' : + isTracker ? '#f97316' : rssiColor; + + return '
' + + '
' + + '
' + + protoBadge + '' + name + '' + trackerBadge + irkBadge + @@ -1495,13 +1495,13 @@ const BluetoothMode = (function() { '
' + statusDot + '
' + - '
' + - '
' + secondaryInfo + '
' + - '
' + - '' + - '
' + + '
' + + '
' + secondaryInfo + '
' + + '
' + + '' + + '
' + '
'; } @@ -1514,16 +1514,16 @@ const BluetoothMode = (function() { return '#ef4444'; } - function escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = String(text); - return div.innerHTML; - } - - function escapeAttr(text) { - return escapeHtml(text).replace(/"/g, '"').replace(/'/g, '''); - } + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; + } + + function escapeAttr(text) { + return escapeHtml(text).replace(/"/g, '"').replace(/'/g, '''); + } async function setBaseline() { try { @@ -1632,22 +1632,22 @@ const BluetoothMode = (function() { /** * Clear all collected data. */ - function clearData() { - devices.clear(); - pendingDeviceIds.clear(); - pendingDeviceFlush = false; - selectedDeviceNeedsRefresh = false; - resetStats(); - clearSelection(); - - if (deviceContainer) { - if (typeof renderCollectionState === 'function') { - renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' }); - } else { - deviceContainer.innerHTML = ''; - } - } - } + function clearData() { + devices.clear(); + pendingDeviceIds.clear(); + pendingDeviceFlush = false; + selectedDeviceNeedsRefresh = false; + resetStats(); + clearSelection(); + + if (deviceContainer) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' }); + } else { + deviceContainer.innerHTML = ''; + } + } + } /** * Toggle "Show All Agents" mode. @@ -1682,27 +1682,27 @@ const BluetoothMode = (function() { } }); - toRemove.forEach(deviceId => devices.delete(deviceId)); - - // Re-render device list - if (deviceContainer) { - deviceContainer.innerHTML = ''; - devices.forEach(device => renderDevice(device, false)); - applyDeviceFilter(); - if (devices.size === 0 && typeof renderCollectionState === 'function') { - renderCollectionState(deviceContainer, { type: 'empty', message: 'No devices for current agent' }); - } - } - - if (selectedDeviceId && !devices.has(selectedDeviceId)) { - clearSelection(); - } - - updateDeviceCount(); - updateStatsFromDevices(); - updateVisualizationPanels(); - updateProximityZones(); - updateRadar(); + toRemove.forEach(deviceId => devices.delete(deviceId)); + + // Re-render device list + if (deviceContainer) { + deviceContainer.innerHTML = ''; + devices.forEach(device => renderDevice(device, false)); + applyDeviceFilter(); + if (devices.size === 0 && typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'empty', message: 'No devices for current agent' }); + } + } + + if (selectedDeviceId && !devices.has(selectedDeviceId)) { + clearSelection(); + } + + updateDeviceCount(); + updateStatsFromDevices(); + updateVisualizationPanels(); + updateProximityZones(); + updateRadar(); } /** @@ -1730,23 +1730,23 @@ const BluetoothMode = (function() { function doLocateHandoff(device) { console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined'); - if (typeof BtLocate !== 'undefined') { - BtLocate.handoff({ - device_id: device.device_id, - device_key: device.device_key || null, - mac_address: device.address, - address_type: device.address_type || null, - irk_hex: device.irk_hex || null, - known_name: device.name || null, - known_manufacturer: device.manufacturer_name || null, - last_known_rssi: device.rssi_current, - tx_power: device.tx_power || null, - appearance_name: device.appearance_name || null, - fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null, - mac_cluster_count: device.mac_cluster_count || 0 - }); - } - } + if (typeof BtLocate !== 'undefined') { + BtLocate.handoff({ + device_id: device.device_id, + device_key: device.device_key || null, + mac_address: device.address, + address_type: device.address_type || null, + irk_hex: device.irk_hex || null, + known_name: device.name || null, + known_manufacturer: device.manufacturer_name || null, + last_known_rssi: device.rssi_current, + tx_power: device.tx_power || null, + appearance_name: device.appearance_name || null, + fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null, + mac_cluster_count: device.mac_cluster_count || 0 + }); + } + } // Public API return { @@ -1773,8 +1773,18 @@ const BluetoothMode = (function() { // Getters getDevices: () => Array.from(devices.values()), isScanning: () => isScanning, - isShowAllAgents: () => showAllAgentsMode + isShowAllAgents: () => showAllAgentsMode, + + // Lifecycle + destroy }; + + /** + * Destroy — close SSE stream and clear polling timers for clean mode switching. + */ + function destroy() { + stopEventStream(); + } })(); // Global functions for onclick handlers diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index 7187c45..a52d127 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -1909,7 +1909,42 @@ const BtLocate = (function() { handleDetection, invalidateMap, fetchPairedIrks, + destroy, }; + + /** + * Destroy — close SSE stream and clear all timers for clean mode switching. + */ + function destroy() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + if (durationTimer) { + clearInterval(durationTimer); + durationTimer = null; + } + if (mapStabilizeTimer) { + clearInterval(mapStabilizeTimer); + mapStabilizeTimer = null; + } + if (queuedDetectionTimer) { + clearTimeout(queuedDetectionTimer); + queuedDetectionTimer = null; + } + if (crosshairResetTimer) { + clearTimeout(crosshairResetTimer); + crosshairResetTimer = null; + } + if (beepTimer) { + clearInterval(beepTimer); + beepTimer = null; + } + } })(); window.BtLocate = BtLocate; diff --git a/static/js/modes/meshtastic.js b/static/js/modes/meshtastic.js index 6f6a093..939037e 100644 --- a/static/js/modes/meshtastic.js +++ b/static/js/modes/meshtastic.js @@ -117,13 +117,13 @@ const Meshtastic = (function() { Settings.createTileLayer().addTo(meshMap); Settings.registerMap(meshMap); } else { - L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { - attribution: '© OSM © CARTO', - maxZoom: 19, - subdomains: 'abcd', - className: 'tile-layer-cyan' - }).addTo(meshMap); - } + L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { + attribution: '© OSM © CARTO', + maxZoom: 19, + subdomains: 'abcd', + className: 'tile-layer-cyan' + }).addTo(meshMap); + } // Handle resize setTimeout(() => { @@ -401,10 +401,10 @@ const Meshtastic = (function() { // Position is nested in the response const pos = info.position; - if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) { - if (posRow) posRow.style.display = 'flex'; - if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`; - } else { + if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) { + if (posRow) posRow.style.display = 'flex'; + if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`; + } else { if (posRow) posRow.style.display = 'none'; } } @@ -2295,7 +2295,8 @@ const Meshtastic = (function() { // Store & Forward showStoreForwardModal, requestStoreForward, - closeStoreForwardModal + closeStoreForwardModal, + destroy }; /** @@ -2306,6 +2307,13 @@ const Meshtastic = (function() { setTimeout(() => meshMap.invalidateSize(), 100); } } + + /** + * Destroy — tear down SSE, timers, and event listeners for clean mode switching. + */ + function destroy() { + stopStream(); + } })(); // Initialize when DOM is ready (will be called by selectMode) diff --git a/static/js/modes/spy-stations.js b/static/js/modes/spy-stations.js index a6176f6..09b4955 100644 --- a/static/js/modes/spy-stations.js +++ b/static/js/modes/spy-stations.js @@ -515,6 +515,13 @@ const SpyStations = (function() { } } + /** + * Destroy — no-op placeholder for consistent lifecycle interface. + */ + function destroy() { + // SpyStations has no background timers or streams to clean up. + } + // Public API return { init, @@ -524,7 +531,8 @@ const SpyStations = (function() { showDetails, closeDetails, showHelp, - closeHelp + closeHelp, + destroy }; })(); diff --git a/static/js/modes/sstv-general.js b/static/js/modes/sstv-general.js index c16791d..3bec33d 100644 --- a/static/js/modes/sstv-general.js +++ b/static/js/modes/sstv-general.js @@ -858,6 +858,13 @@ const SSTVGeneral = (function() { } } + /** + * Destroy — close SSE stream and stop scope animation for clean mode switching. + */ + function destroy() { + stopStream(); + } + // Public API return { init, @@ -869,6 +876,7 @@ const SSTVGeneral = (function() { deleteImage, deleteAllImages, downloadImage, - selectPreset + selectPreset, + destroy }; })(); diff --git a/static/js/modes/sstv.js b/static/js/modes/sstv.js index 24e2f29..bb60d1d 100644 --- a/static/js/modes/sstv.js +++ b/static/js/modes/sstv.js @@ -12,12 +12,12 @@ const SSTV = (function() { let progress = 0; let issMap = null; let issMarker = null; - let issTrackLine = null; - let issPosition = null; - let issUpdateInterval = null; - let countdownInterval = null; - let nextPassData = null; - let pendingMapInvalidate = false; + let issTrackLine = null; + let issPosition = null; + let issUpdateInterval = null; + let countdownInterval = null; + let nextPassData = null; + let pendingMapInvalidate = false; // ISS frequency const ISS_FREQ = 145.800; @@ -38,31 +38,31 @@ const SSTV = (function() { /** * Initialize the SSTV mode */ - function init() { - checkStatus(); - loadImages(); - loadLocationInputs(); - loadIssSchedule(); - initMap(); - startIssTracking(); - startCountdown(); - // Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible. - setTimeout(() => invalidateMap(), 80); - setTimeout(() => invalidateMap(), 260); - } - - function isMapContainerVisible() { - if (!issMap || typeof issMap.getContainer !== 'function') return false; - const container = issMap.getContainer(); - if (!container) return false; - if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false; - if (container.style && container.style.display === 'none') return false; - if (typeof window.getComputedStyle === 'function') { - const style = window.getComputedStyle(container); - if (style.display === 'none' || style.visibility === 'hidden') return false; - } - return true; - } + function init() { + checkStatus(); + loadImages(); + loadLocationInputs(); + loadIssSchedule(); + initMap(); + startIssTracking(); + startCountdown(); + // Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible. + setTimeout(() => invalidateMap(), 80); + setTimeout(() => invalidateMap(), 260); + } + + function isMapContainerVisible() { + if (!issMap || typeof issMap.getContainer !== 'function') return false; + const container = issMap.getContainer(); + if (!container) return false; + if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false; + if (container.style && container.style.display === 'none') return false; + if (typeof window.getComputedStyle === 'function') { + const style = window.getComputedStyle(container); + if (style.display === 'none' || style.visibility === 'hidden') return false; + } + return true; + } /** * Load location into input fields @@ -189,9 +189,9 @@ const SSTV = (function() { /** * Initialize Leaflet map for ISS tracking */ - async function initMap() { - const mapContainer = document.getElementById('sstvIssMap'); - if (!mapContainer || issMap) return; + async function initMap() { + const mapContainer = document.getElementById('sstvIssMap'); + if (!mapContainer || issMap) return; // Create map issMap = L.map('sstvIssMap', { @@ -231,21 +231,21 @@ const SSTV = (function() { issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap); // Create ground track line - issTrackLine = L.polyline([], { - color: '#00d4ff', - weight: 2, - opacity: 0.6, - dashArray: '5, 5' - }).addTo(issMap); - - issMap.on('resize moveend zoomend', () => { - if (pendingMapInvalidate) invalidateMap(); - }); - - // Initial layout passes for first-time mode load. - setTimeout(() => invalidateMap(), 40); - setTimeout(() => invalidateMap(), 180); - } + issTrackLine = L.polyline([], { + color: '#00d4ff', + weight: 2, + opacity: 0.6, + dashArray: '5, 5' + }).addTo(issMap); + + issMap.on('resize moveend zoomend', () => { + if (pendingMapInvalidate) invalidateMap(); + }); + + // Initial layout passes for first-time mode load. + setTimeout(() => invalidateMap(), 40); + setTimeout(() => invalidateMap(), 180); + } /** * Start ISS position tracking @@ -454,9 +454,9 @@ const SSTV = (function() { /** * Update map with ISS position */ - function updateMap() { - if (!issMap || !issPosition) return; - if (pendingMapInvalidate) invalidateMap(); + function updateMap() { + if (!issMap || !issPosition) return; + if (pendingMapInvalidate) invalidateMap(); const lat = issPosition.lat; const lon = issPosition.lon; @@ -516,13 +516,13 @@ const SSTV = (function() { issTrackLine.setLatLngs(segments.length > 0 ? segments : []); } - // Pan map to follow ISS only when the map pane is currently renderable. - if (isMapContainerVisible()) { - issMap.panTo([lat, lon], { animate: true, duration: 0.5 }); - } else { - pendingMapInvalidate = true; - } - } + // Pan map to follow ISS only when the map pane is currently renderable. + if (isMapContainerVisible()) { + issMap.panTo([lat, lon], { animate: true, duration: 0.5 }); + } else { + pendingMapInvalidate = true; + } + } /** * Check current decoder status @@ -1335,27 +1335,27 @@ const SSTV = (function() { /** * Show status message */ - function showStatusMessage(message, type) { - if (typeof showNotification === 'function') { - showNotification('SSTV', message); - } else { - console.log(`[SSTV ${type}] ${message}`); - } - } - - /** - * Invalidate ISS map size after pane/layout changes. - */ - function invalidateMap() { - if (!issMap) return false; - if (!isMapContainerVisible()) { - pendingMapInvalidate = true; - return false; - } - issMap.invalidateSize({ pan: false, animate: false }); - pendingMapInvalidate = false; - return true; - } + function showStatusMessage(message, type) { + if (typeof showNotification === 'function') { + showNotification('SSTV', message); + } else { + console.log(`[SSTV ${type}] ${message}`); + } + } + + /** + * Invalidate ISS map size after pane/layout changes. + */ + function invalidateMap() { + if (!issMap) return false; + if (!isMapContainerVisible()) { + pendingMapInvalidate = true; + return false; + } + issMap.invalidateSize({ pan: false, animate: false }); + pendingMapInvalidate = false; + return true; + } // Public API return { @@ -1370,12 +1370,25 @@ const SSTV = (function() { deleteAllImages, downloadImage, useGPS, - updateTLE, - stopIssTracking, - stopCountdown, - invalidateMap - }; -})(); + updateTLE, + stopIssTracking, + stopCountdown, + invalidateMap, + destroy + }; + + /** + * Destroy — close SSE stream and clear ISS tracking/countdown timers for clean mode switching. + */ + function destroy() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + stopIssTracking(); + stopCountdown(); + } +})(); // Initialize when DOM is ready (will be called by selectMode) document.addEventListener('DOMContentLoaded', function() { diff --git a/static/js/modes/websdr.js b/static/js/modes/websdr.js index b2a60fe..f99a6ea 100644 --- a/static/js/modes/websdr.js +++ b/static/js/modes/websdr.js @@ -1005,6 +1005,15 @@ function escapeHtmlWebsdr(str) { // ============== EXPORTS ============== +/** + * Destroy — disconnect audio and clear S-meter timer for clean mode switching. + */ +function destroyWebSDR() { + disconnectFromReceiver(); +} + +const WebSDR = { destroy: destroyWebSDR }; + window.initWebSDR = initWebSDR; window.searchReceivers = searchReceivers; window.selectReceiver = selectReceiver; @@ -1015,3 +1024,4 @@ window.disconnectFromReceiver = disconnectFromReceiver; window.tuneKiwi = tuneKiwi; window.tuneFromBar = tuneFromBar; window.setKiwiVolume = setKiwiVolume; +window.WebSDR = WebSDR; diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 35c93c0..bc44c02 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -28,9 +28,9 @@ const WiFiMode = (function() { maxProbes: 1000, }; - // ========================================================================== - // Agent Support - // ========================================================================== + // ========================================================================== + // Agent Support + // ========================================================================== /** * Get the API base URL, routing through agent proxy if agent is selected. @@ -59,49 +59,49 @@ const WiFiMode = (function() { /** * Check for agent mode conflicts before starting WiFi scan. */ - function checkAgentConflicts() { - if (typeof currentAgent === 'undefined' || currentAgent === 'local') { - return true; - } - if (typeof checkAgentModeConflict === 'function') { - return checkAgentModeConflict('wifi'); - } - return true; - } - - function getChannelPresetList(preset) { - switch (preset) { - case '2.4-common': - return '1,6,11'; - case '2.4-all': - return '1,2,3,4,5,6,7,8,9,10,11,12,13'; - case '5-low': - return '36,40,44,48'; - case '5-mid': - return '52,56,60,64'; - case '5-high': - return '149,153,157,161,165'; - default: - return ''; - } - } - - function buildChannelConfig() { - const preset = document.getElementById('wifiChannelPreset')?.value || ''; - const listInput = document.getElementById('wifiChannelList')?.value || ''; - const singleInput = document.getElementById('wifiChannel')?.value || ''; - - const listValue = listInput.trim(); - const presetValue = getChannelPresetList(preset); - - const channels = listValue || presetValue || ''; - const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null); - - return { - channels: channels || null, - channel: Number.isFinite(channel) ? channel : null, - }; - } + function checkAgentConflicts() { + if (typeof currentAgent === 'undefined' || currentAgent === 'local') { + return true; + } + if (typeof checkAgentModeConflict === 'function') { + return checkAgentModeConflict('wifi'); + } + return true; + } + + function getChannelPresetList(preset) { + switch (preset) { + case '2.4-common': + return '1,6,11'; + case '2.4-all': + return '1,2,3,4,5,6,7,8,9,10,11,12,13'; + case '5-low': + return '36,40,44,48'; + case '5-mid': + return '52,56,60,64'; + case '5-high': + return '149,153,157,161,165'; + default: + return ''; + } + } + + function buildChannelConfig() { + const preset = document.getElementById('wifiChannelPreset')?.value || ''; + const listInput = document.getElementById('wifiChannelList')?.value || ''; + const singleInput = document.getElementById('wifiChannel')?.value || ''; + + const listValue = listInput.trim(); + const presetValue = getChannelPresetList(preset); + + const channels = listValue || presetValue || ''; + const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null); + + return { + channels: channels || null, + channel: Number.isFinite(channel) ? channel : null, + }; + } // ========================================================================== // State @@ -120,23 +120,23 @@ const WiFiMode = (function() { let channelStats = []; let recommendations = []; - // UI state - let selectedNetwork = null; - let currentFilter = 'all'; - let currentSort = { field: 'rssi', order: 'desc' }; - let renderFramePending = false; - const pendingRender = { - table: false, - stats: false, - radar: false, - chart: false, - detail: false, - }; - const listenersBound = { - scanTabs: false, - filters: false, - sort: false, - }; + // UI state + let selectedNetwork = null; + let currentFilter = 'all'; + let currentSort = { field: 'rssi', order: 'desc' }; + let renderFramePending = false; + const pendingRender = { + table: false, + stats: false, + radar: false, + chart: false, + detail: false, + }; + const listenersBound = { + scanTabs: false, + filters: false, + sort: false, + }; // Agent state let showAllAgentsMode = false; // Show combined results from all agents @@ -165,11 +165,11 @@ const WiFiMode = (function() { // Initialize components initScanModeTabs(); - initNetworkFilters(); - initSortControls(); - initProximityRadar(); - initChannelChart(); - scheduleRender({ table: true, stats: true, radar: true, chart: true }); + initNetworkFilters(); + initSortControls(); + initProximityRadar(); + initChannelChart(); + scheduleRender({ table: true, stats: true, radar: true, chart: true }); // Check if already scanning checkScanStatus(); @@ -378,16 +378,16 @@ const WiFiMode = (function() { // Scan Mode Tabs // ========================================================================== - function initScanModeTabs() { - if (listenersBound.scanTabs) return; - if (elements.scanModeQuick) { - elements.scanModeQuick.addEventListener('click', () => setScanMode('quick')); - } - if (elements.scanModeDeep) { - elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); - } - listenersBound.scanTabs = true; - } + function initScanModeTabs() { + if (listenersBound.scanTabs) return; + if (elements.scanModeQuick) { + elements.scanModeQuick.addEventListener('click', () => setScanMode('quick')); + } + if (elements.scanModeDeep) { + elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); + } + listenersBound.scanTabs = true; + } function setScanMode(mode) { scanMode = mode; @@ -511,10 +511,10 @@ const WiFiMode = (function() { setScanning(true, 'deep'); try { - const iface = elements.interfaceSelect?.value || null; - const band = document.getElementById('wifiBand')?.value || 'all'; - const channelConfig = buildChannelConfig(); - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const iface = elements.interfaceSelect?.value || null; + const band = document.getElementById('wifiBand')?.value || 'all'; + const channelConfig = buildChannelConfig(); + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; let response; if (isAgentMode) { @@ -523,25 +523,25 @@ const WiFiMode = (function() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - interface: iface, - scan_type: 'deep', - band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', - channel: channelConfig.channel, - channels: channelConfig.channels, - }), - }); - } else { - response = await fetch(`${CONFIG.apiBase}/scan/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - interface: iface, - band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', - channel: channelConfig.channel, - channels: channelConfig.channels, - }), - }); - } + interface: iface, + scan_type: 'deep', + band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', + channel: channelConfig.channel, + channels: channelConfig.channels, + }), + }); + } else { + response = await fetch(`${CONFIG.apiBase}/scan/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + interface: iface, + band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', + channel: channelConfig.channel, + channels: channelConfig.channels, + }), + }); + } if (!response.ok) { const error = await response.json(); @@ -572,8 +572,8 @@ const WiFiMode = (function() { } } - async function stopScan() { - console.log('[WiFiMode] Stopping scan...'); + async function stopScan() { + console.log('[WiFiMode] Stopping scan...'); // Stop polling if (pollTimer) { @@ -585,41 +585,41 @@ const WiFiMode = (function() { stopAgentDeepScanPolling(); // Close event stream - if (eventSource) { - eventSource.close(); - eventSource = null; - } - - // Update UI immediately so mode transitions are responsive even if the - // backend needs extra time to terminate subprocesses. - setScanning(false); - - // Stop scan on server (local or agent) - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const timeoutMs = isAgentMode ? 8000 : 2200; - const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; - const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; - - try { - if (isAgentMode) { - await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { - method: 'POST', - ...(controller ? { signal: controller.signal } : {}), - }); - } else if (scanMode === 'deep') { - await fetch(`${CONFIG.apiBase}/scan/stop`, { - method: 'POST', - ...(controller ? { signal: controller.signal } : {}), - }); - } - } catch (error) { - console.warn('[WiFiMode] Error stopping scan:', error); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } - } + if (eventSource) { + eventSource.close(); + eventSource = null; + } + + // Update UI immediately so mode transitions are responsive even if the + // backend needs extra time to terminate subprocesses. + setScanning(false); + + // Stop scan on server (local or agent) + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const timeoutMs = isAgentMode ? 8000 : 2200; + const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; + + try { + if (isAgentMode) { + await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } else if (scanMode === 'deep') { + await fetch(`${CONFIG.apiBase}/scan/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } + } catch (error) { + console.warn('[WiFiMode] Error stopping scan:', error); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } function setScanning(scanning, mode = null) { isScanning = scanning; @@ -713,10 +713,10 @@ const WiFiMode = (function() { }, CONFIG.pollInterval); } - function processQuickScanResult(result) { - // Update networks - result.access_points.forEach(ap => { - networks.set(ap.bssid, ap); + function processQuickScanResult(result) { + // Update networks + result.access_points.forEach(ap => { + networks.set(ap.bssid, ap); }); // Update channel stats (calculate from networks if not provided by API) @@ -724,12 +724,12 @@ const WiFiMode = (function() { recommendations = result.recommendations || []; // If no channel stats from API, calculate from networks - if (channelStats.length === 0 && networks.size > 0) { - channelStats = calculateChannelStats(); - } - - // Update UI - scheduleRender({ table: true, stats: true, radar: true, chart: true }); + if (channelStats.length === 0 && networks.size > 0) { + channelStats = calculateChannelStats(); + } + + // Update UI + scheduleRender({ table: true, stats: true, radar: true, chart: true }); // Callbacks result.access_points.forEach(ap => { @@ -938,25 +938,25 @@ const WiFiMode = (function() { } } - function handleNetworkUpdate(network) { - networks.set(network.bssid, network); - scheduleRender({ - table: true, - stats: true, - radar: true, - chart: true, - detail: selectedNetwork === network.bssid, - }); - - if (onNetworkUpdate) onNetworkUpdate(network); - } - - function handleClientUpdate(client) { - clients.set(client.mac, client); - scheduleRender({ stats: true }); - - // Update client display if this client belongs to the selected network - updateClientInList(client); + function handleNetworkUpdate(network) { + networks.set(network.bssid, network); + scheduleRender({ + table: true, + stats: true, + radar: true, + chart: true, + detail: selectedNetwork === network.bssid, + }); + + if (onNetworkUpdate) onNetworkUpdate(network); + } + + function handleClientUpdate(client) { + clients.set(client.mac, client); + scheduleRender({ stats: true }); + + // Update client display if this client belongs to the selected network + updateClientInList(client); if (onClientUpdate) onClientUpdate(client); } @@ -970,37 +970,37 @@ const WiFiMode = (function() { if (onProbeRequest) onProbeRequest(probe); } - function handleHiddenRevealed(bssid, revealedSsid) { - const network = networks.get(bssid); - if (network) { - network.revealed_essid = revealedSsid; - network.display_name = `${revealedSsid} (revealed)`; - scheduleRender({ - table: true, - detail: selectedNetwork === bssid, - }); - - // Show notification - showInfo(`Hidden SSID revealed: ${revealedSsid}`); - } - } + function handleHiddenRevealed(bssid, revealedSsid) { + const network = networks.get(bssid); + if (network) { + network.revealed_essid = revealedSsid; + network.display_name = `${revealedSsid} (revealed)`; + scheduleRender({ + table: true, + detail: selectedNetwork === bssid, + }); + + // Show notification + showInfo(`Hidden SSID revealed: ${revealedSsid}`); + } + } // ========================================================================== // Network Table // ========================================================================== - function initNetworkFilters() { - if (listenersBound.filters) return; - if (!elements.networkFilters) return; - - elements.networkFilters.addEventListener('click', (e) => { - if (e.target.matches('.wifi-filter-btn')) { - const filter = e.target.dataset.filter; - setNetworkFilter(filter); - } - }); - listenersBound.filters = true; - } + function initNetworkFilters() { + if (listenersBound.filters) return; + if (!elements.networkFilters) return; + + elements.networkFilters.addEventListener('click', (e) => { + if (e.target.matches('.wifi-filter-btn')) { + const filter = e.target.dataset.filter; + setNetworkFilter(filter); + } + }); + listenersBound.filters = true; + } function setNetworkFilter(filter) { currentFilter = filter; @@ -1015,11 +1015,11 @@ const WiFiMode = (function() { updateNetworkTable(); } - function initSortControls() { - if (listenersBound.sort) return; - if (!elements.networkTable) return; - - elements.networkTable.addEventListener('click', (e) => { + function initSortControls() { + if (listenersBound.sort) return; + if (!elements.networkTable) return; + + elements.networkTable.addEventListener('click', (e) => { const th = e.target.closest('th[data-sort]'); if (th) { const field = th.dataset.sort; @@ -1029,54 +1029,54 @@ const WiFiMode = (function() { currentSort.field = field; currentSort.order = 'desc'; } - updateNetworkTable(); - } - }); - - if (elements.networkTableBody) { - elements.networkTableBody.addEventListener('click', (e) => { - const row = e.target.closest('tr[data-bssid]'); - if (!row) return; - selectNetwork(row.dataset.bssid); - }); - } - listenersBound.sort = true; - } - - function scheduleRender(flags = {}) { - pendingRender.table = pendingRender.table || Boolean(flags.table); - pendingRender.stats = pendingRender.stats || Boolean(flags.stats); - pendingRender.radar = pendingRender.radar || Boolean(flags.radar); - pendingRender.chart = pendingRender.chart || Boolean(flags.chart); - pendingRender.detail = pendingRender.detail || Boolean(flags.detail); - - if (renderFramePending) return; - renderFramePending = true; - - requestAnimationFrame(() => { - renderFramePending = false; - - if (pendingRender.table) updateNetworkTable(); - if (pendingRender.stats) updateStats(); - if (pendingRender.radar) updateProximityRadar(); - if (pendingRender.chart) updateChannelChart(); - if (pendingRender.detail && selectedNetwork) { - updateDetailPanel(selectedNetwork, { refreshClients: false }); - } - - pendingRender.table = false; - pendingRender.stats = false; - pendingRender.radar = false; - pendingRender.chart = false; - pendingRender.detail = false; - }); - } - - function updateNetworkTable() { - if (!elements.networkTableBody) return; - - // Filter networks - let filtered = Array.from(networks.values()); + updateNetworkTable(); + } + }); + + if (elements.networkTableBody) { + elements.networkTableBody.addEventListener('click', (e) => { + const row = e.target.closest('tr[data-bssid]'); + if (!row) return; + selectNetwork(row.dataset.bssid); + }); + } + listenersBound.sort = true; + } + + function scheduleRender(flags = {}) { + pendingRender.table = pendingRender.table || Boolean(flags.table); + pendingRender.stats = pendingRender.stats || Boolean(flags.stats); + pendingRender.radar = pendingRender.radar || Boolean(flags.radar); + pendingRender.chart = pendingRender.chart || Boolean(flags.chart); + pendingRender.detail = pendingRender.detail || Boolean(flags.detail); + + if (renderFramePending) return; + renderFramePending = true; + + requestAnimationFrame(() => { + renderFramePending = false; + + if (pendingRender.table) updateNetworkTable(); + if (pendingRender.stats) updateStats(); + if (pendingRender.radar) updateProximityRadar(); + if (pendingRender.chart) updateChannelChart(); + if (pendingRender.detail && selectedNetwork) { + updateDetailPanel(selectedNetwork, { refreshClients: false }); + } + + pendingRender.table = false; + pendingRender.stats = false; + pendingRender.radar = false; + pendingRender.chart = false; + pendingRender.detail = false; + }); + } + + function updateNetworkTable() { + if (!elements.networkTableBody) return; + + // Filter networks + let filtered = Array.from(networks.values()); switch (currentFilter) { case 'hidden': @@ -1126,44 +1126,44 @@ const WiFiMode = (function() { return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; } else { return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; - } - }); - - if (filtered.length === 0) { - let message = 'Start scanning to discover networks'; - let type = 'empty'; - if (isScanning) { - message = 'Scanning for networks...'; - type = 'loading'; - } else if (networks.size > 0) { - message = 'No networks match current filters'; - } - if (typeof renderCollectionState === 'function') { - renderCollectionState(elements.networkTableBody, { - type, - message, - columns: 7, - }); - } else { - elements.networkTableBody.innerHTML = `
${escapeHtml(message)}
`; - } - return; - } - - // Render table - elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); - } + } + }); - function createNetworkRow(network) { - const rssi = network.rssi_current; - const security = network.security || 'Unknown'; - const signalClass = rssi >= -50 ? 'signal-strong' : - rssi >= -70 ? 'signal-medium' : - rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; - - const securityClass = security === 'Open' ? 'security-open' : - security === 'WEP' ? 'security-wep' : - security.includes('WPA3') ? 'security-wpa3' : 'security-wpa'; + if (filtered.length === 0) { + let message = 'Start scanning to discover networks'; + let type = 'empty'; + if (isScanning) { + message = 'Scanning for networks...'; + type = 'loading'; + } else if (networks.size > 0) { + message = 'No networks match current filters'; + } + if (typeof renderCollectionState === 'function') { + renderCollectionState(elements.networkTableBody, { + type, + message, + columns: 7, + }); + } else { + elements.networkTableBody.innerHTML = `
${escapeHtml(message)}
`; + } + return; + } + + // Render table + elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); + } + + function createNetworkRow(network) { + const rssi = network.rssi_current; + const security = network.security || 'Unknown'; + const signalClass = rssi >= -50 ? 'signal-strong' : + rssi >= -70 ? 'signal-medium' : + rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; + + const securityClass = security === 'Open' ? 'security-open' : + security === 'WEP' ? 'security-wep' : + security.includes('WPA3') ? 'security-wpa3' : 'security-wpa'; const hiddenBadge = network.is_hidden ? 'Hidden' : ''; const newBadge = network.is_new ? 'New' : ''; @@ -1172,25 +1172,25 @@ const WiFiMode = (function() { const agentName = network._agent || 'Local'; const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote'; - return ` - - - ${escapeHtml(network.display_name || network.essid || '[Hidden]')} - ${hiddenBadge}${newBadge} - + return ` + + + ${escapeHtml(network.display_name || network.essid || '[Hidden]')} + ${hiddenBadge}${newBadge} + ${escapeHtml(network.bssid)} ${network.channel || '-'} - - ${rssi != null ? rssi : '-'} - - - ${escapeHtml(security)} - + + ${rssi != null ? rssi : '-'} + + + ${escapeHtml(security)} + ${network.client_count || 0} ${escapeHtml(agentName)} @@ -1199,12 +1199,12 @@ const WiFiMode = (function() { `; } - function updateNetworkRow(network) { - scheduleRender({ - table: true, - detail: selectedNetwork === network.bssid, - }); - } + function updateNetworkRow(network) { + scheduleRender({ + table: true, + detail: selectedNetwork === network.bssid, + }); + } function selectNetwork(bssid) { selectedNetwork = bssid; @@ -1227,9 +1227,9 @@ const WiFiMode = (function() { // Detail Panel // ========================================================================== - function updateDetailPanel(bssid, options = {}) { - const { refreshClients = true } = options; - if (!elements.detailDrawer) return; + function updateDetailPanel(bssid, options = {}) { + const { refreshClients = true } = options; + if (!elements.detailDrawer) return; const network = networks.get(bssid); if (!network) { @@ -1274,11 +1274,11 @@ const WiFiMode = (function() { // Show the drawer elements.detailDrawer.classList.add('open'); - // Fetch and display clients for this network - if (refreshClients) { - fetchClientsForNetwork(network.bssid); - } - } + // Fetch and display clients for this network + if (refreshClients) { + fetchClientsForNetwork(network.bssid); + } + } function closeDetail() { selectedNetwork = null; @@ -1294,18 +1294,18 @@ const WiFiMode = (function() { // Client Display // ========================================================================== - async function fetchClientsForNetwork(bssid) { - if (!elements.detailClientList) return; - const listContainer = elements.detailClientList.querySelector('.wifi-client-list'); - - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' }); - elements.detailClientList.style.display = 'block'; - } - - try { - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - let response; + async function fetchClientsForNetwork(bssid) { + if (!elements.detailClientList) return; + const listContainer = elements.detailClientList.querySelector('.wifi-client-list'); + + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' }); + elements.detailClientList.style.display = 'block'; + } + + try { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + let response; if (isAgentMode) { // Route through agent proxy @@ -1314,44 +1314,44 @@ const WiFiMode = (function() { response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`); } - if (!response.ok) { - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); - elements.detailClientList.style.display = 'block'; - } else { - elements.detailClientList.style.display = 'none'; - } - return; - } + if (!response.ok) { + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + return; + } const data = await response.json(); // Handle agent response format (may be nested in 'result') const result = isAgentMode && data.result ? data.result : data; const clientList = result.clients || []; - if (clientList.length > 0) { - renderClientList(clientList, bssid); - elements.detailClientList.style.display = 'block'; - } else { - const countBadge = document.getElementById('wifiClientCountBadge'); - if (countBadge) countBadge.textContent = '0'; - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' }); - elements.detailClientList.style.display = 'block'; - } else { - elements.detailClientList.style.display = 'none'; - } - } - } catch (error) { - console.debug('[WiFiMode] Error fetching clients:', error); - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); - elements.detailClientList.style.display = 'block'; - } else { - elements.detailClientList.style.display = 'none'; - } - } - } + if (clientList.length > 0) { + renderClientList(clientList, bssid); + elements.detailClientList.style.display = 'block'; + } else { + const countBadge = document.getElementById('wifiClientCountBadge'); + if (countBadge) countBadge.textContent = '0'; + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + } + } catch (error) { + console.debug('[WiFiMode] Error fetching clients:', error); + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + } + } function renderClientList(clientList, bssid) { const container = elements.detailClientList?.querySelector('.wifi-client-list'); @@ -1708,16 +1708,16 @@ const WiFiMode = (function() { /** * Clear all collected data. */ - function clearData() { - networks.clear(); - clients.clear(); - probeRequests = []; - channelStats = []; - recommendations = []; - if (selectedNetwork) { - closeDetail(); - } - scheduleRender({ table: true, stats: true, radar: true, chart: true }); + function clearData() { + networks.clear(); + clients.clear(); + probeRequests = []; + channelStats = []; + recommendations = []; + if (selectedNetwork) { + closeDetail(); + } + scheduleRender({ table: true, stats: true, radar: true, chart: true }); } /** @@ -1763,12 +1763,12 @@ const WiFiMode = (function() { clientsToRemove.push(mac); } }); - clientsToRemove.forEach(mac => clients.delete(mac)); - if (selectedNetwork && !networks.has(selectedNetwork)) { - closeDetail(); - } - scheduleRender({ table: true, stats: true, radar: true, chart: true }); - } + clientsToRemove.forEach(mac => clients.delete(mac)); + if (selectedNetwork && !networks.has(selectedNetwork)) { + closeDetail(); + } + scheduleRender({ table: true, stats: true, radar: true, chart: true }); + } /** * Refresh WiFi interfaces from current agent. @@ -1811,7 +1811,28 @@ const WiFiMode = (function() { onNetworkUpdate: (cb) => { onNetworkUpdate = cb; }, onClientUpdate: (cb) => { onClientUpdate = cb; }, onProbeRequest: (cb) => { onProbeRequest = cb; }, + + // Lifecycle + destroy, }; + + /** + * Destroy — close SSE stream and clear polling timers for clean mode switching. + */ + function destroy() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + if (agentPollTimer) { + clearInterval(agentPollTimer); + agentPollTimer = null; + } + } })(); // Auto-initialize when DOM is ready diff --git a/templates/index.html b/templates/index.html index 2844a23..92a459a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4140,12 +4140,27 @@ const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs); await styleReadyPromise; - // Clean up SubGHz SSE connection when leaving the mode - if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') { - SubGhz.destroy(); - } - if (typeof MorseMode !== 'undefined' && currentMode === 'morse' && mode !== 'morse' && typeof MorseMode.destroy === 'function') { - MorseMode.destroy(); + // Generic module cleanup — destroy previous mode's timers, SSE, etc. + const moduleDestroyMap = { + subghz: () => typeof SubGhz !== 'undefined' && SubGhz.destroy(), + morse: () => typeof MorseMode !== 'undefined' && MorseMode.destroy?.(), + spaceweather: () => typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy?.(), + weathersat: () => typeof WeatherSat !== 'undefined' && WeatherSat.suspend?.(), + wefax: () => typeof WeFax !== 'undefined' && WeFax.destroy?.(), + system: () => typeof SystemHealth !== 'undefined' && SystemHealth.destroy?.(), + waterfall: () => typeof Waterfall !== 'undefined' && Waterfall.destroy?.(), + gps: () => typeof GPS !== 'undefined' && GPS.destroy?.(), + meshtastic: () => typeof Meshtastic !== 'undefined' && Meshtastic.destroy?.(), + bluetooth: () => typeof BluetoothMode !== 'undefined' && BluetoothMode.destroy?.(), + wifi: () => typeof WiFiMode !== 'undefined' && WiFiMode.destroy?.(), + bt_locate: () => typeof BtLocate !== 'undefined' && BtLocate.destroy?.(), + sstv: () => typeof SSTV !== 'undefined' && SSTV.destroy?.(), + sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(), + websdr: () => typeof WebSDR !== 'undefined' && WebSDR.destroy?.(), + spystations: () => typeof SpyStations !== 'undefined' && SpyStations.destroy?.(), + }; + if (previousMode && previousMode !== mode && moduleDestroyMap[previousMode]) { + try { moduleDestroyMap[previousMode](); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); } } currentMode = mode; @@ -4301,25 +4316,7 @@ refreshTscmDevices(); } - // Initialize/destroy Space Weather mode - if (mode !== 'spaceweather') { - if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy(); - } - - // Suspend Weather Satellite background timers/streams when leaving the mode - if (mode !== 'weathersat') { - if (typeof WeatherSat !== 'undefined' && WeatherSat.suspend) WeatherSat.suspend(); - } - - // Suspend WeFax background streams when leaving the mode - if (mode !== 'wefax') { - if (typeof WeFax !== 'undefined' && WeFax.destroy) WeFax.destroy(); - } - - // Disconnect System Health SSE when leaving the mode - if (mode !== 'system') { - if (typeof SystemHealth !== 'undefined' && SystemHealth.destroy) SystemHealth.destroy(); - } + // Module destroy is now handled by moduleDestroyMap above. // Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm) const reconBtn = document.getElementById('reconBtn'); @@ -4460,10 +4457,7 @@ SystemHealth.init(); } - // Destroy Waterfall WebSocket when leaving SDR receiver modes - if (mode !== 'waterfall' && typeof Waterfall !== 'undefined' && Waterfall.destroy) { - Promise.resolve(Waterfall.destroy()).catch(() => {}); - } + // Waterfall destroy is now handled by moduleDestroyMap above. const totalMs = Math.round(performance.now() - switchStartMs); console.info(