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(