diff --git a/static/css/index.css b/static/css/index.css index b0ac50d..d84de78 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -3722,6 +3722,7 @@ header h1 .tagline { .badge.wpa3 { color: var(--accent-cyan); background: var(--accent-cyan-dim); border-color: var(--accent-cyan); } .badge.wep { color: var(--accent-orange); background: var(--accent-amber-dim); border-color: var(--accent-orange); } .badge.hidden-tag { color: var(--text-dim); background: transparent; border-color: var(--border-color); font-size: 8px; } +.badge.unknown { color: var(--text-dim); background: transparent; border-color: var(--border-color); } .row-bottom { display: flex; diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index c9489d3..631249a 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -1,1862 +1,1862 @@ -/** - * WiFi Mode Controller (v2) - * - * Unified WiFi scanning with dual-mode architecture: - * - Quick Scan: System tools without monitor mode - * - Deep Scan: airodump-ng with monitor mode - * - * Features: - * - Proximity radar visualization - * - Channel utilization analysis - * - Hidden SSID correlation - * - Real-time SSE streaming - */ - -const WiFiMode = (function() { - 'use strict'; - - // ========================================================================== - // Configuration - // ========================================================================== - - const CONFIG = { - apiBase: '/wifi/v2', - pollInterval: 5000, - keepaliveTimeout: 30000, - maxNetworks: 500, - maxClients: 500, - maxProbes: 1000, - }; - - // ========================================================================== - // Agent Support - // ========================================================================== - - /** - * Get the API base URL, routing through agent proxy if agent is selected. - */ - function getApiBase() { - if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') { - return `/controller/agents/${currentAgent}/wifi/v2`; - } - return CONFIG.apiBase; - } - - /** - * Get the current agent name for tagging data. - */ - function getCurrentAgentName() { - if (typeof currentAgent === 'undefined' || currentAgent === 'local') { - return 'Local'; - } - if (typeof agents !== 'undefined') { - const agent = agents.find(a => a.id == currentAgent); - return agent ? agent.name : `Agent ${currentAgent}`; - } - return `Agent ${currentAgent}`; - } - - /** - * Check for agent mode conflicts before starting WiFi scan. - */ - async function checkAgentConflicts() { - if (typeof currentAgent === 'undefined' || currentAgent === 'local') { - return true; - } - if (typeof checkAgentModeConflict === 'function') { - return await 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 - // ========================================================================== - - let isScanning = false; - let scanMode = 'quick'; // 'quick' or 'deep' - let eventSource = null; - let pollTimer = null; - let agentPollTimer = null; - - // Data stores - let networks = new Map(); // bssid -> network - let clients = new Map(); // mac -> client - let probeRequests = []; - let channelStats = []; - let recommendations = []; - - // UI state - let selectedBssid = 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 - let lastAgentId = null; // Track agent switches - - // Capabilities - let capabilities = null; - - // Callbacks for external integration - let onNetworkUpdate = null; - let onClientUpdate = null; - let onProbeRequest = null; - - // ========================================================================== - // Initialization - // ========================================================================== - - function init() { - console.log('[WiFiMode] Initializing...'); - - // Cache DOM elements - cacheDOM(); - - // Check capabilities - checkCapabilities(); - - // Initialize components - initScanModeTabs(); - initNetworkFilters(); - initSortControls(); - initProximityRadar(); - initChannelChart(); - scheduleRender({ table: true, stats: true, radar: true, chart: true }); - - // Check if already scanning - checkScanStatus(); - - console.log('[WiFiMode] Initialized'); - } - - // DOM element cache - let elements = {}; - - function cacheDOM() { - elements = { - // Scan controls - quickScanBtn: document.getElementById('wifiQuickScanBtn'), - deepScanBtn: document.getElementById('wifiDeepScanBtn'), - stopScanBtn: document.getElementById('wifiStopScanBtn'), - scanModeQuick: document.getElementById('wifiScanModeQuick'), - scanModeDeep: document.getElementById('wifiScanModeDeep'), - - // Status bar - scanIndicator: document.getElementById('wifiScanIndicator'), - openCount: document.getElementById('wifiOpenCount'), - networkCount: document.getElementById('wifiNetworkCount'), - clientCount: document.getElementById('wifiClientCount'), - hiddenCount: document.getElementById('wifiHiddenCount'), - - // Network list - networkList: document.getElementById('wifiNetworkList'), - networkFilters: document.getElementById('wifiNetworkFilters'), - - // Visualizations - proximityRadar: document.getElementById('wifiProximityRadar'), - channelChart: document.getElementById('wifiChannelChart'), - channelBandTabs: document.getElementById('wifiChannelBandTabs'), - - // Zone summary - zoneImmediate: document.getElementById('wifiZoneImmediate'), - zoneNear: document.getElementById('wifiZoneNear'), - zoneFar: document.getElementById('wifiZoneFar'), - - // Detail drawer - detailDrawer: document.getElementById('wifiDetailDrawer'), - detailEssid: document.getElementById('wifiDetailEssid'), - detailBssid: document.getElementById('wifiDetailBssid'), - detailRssi: document.getElementById('wifiDetailRssi'), - detailChannel: document.getElementById('wifiDetailChannel'), - detailBand: document.getElementById('wifiDetailBand'), - detailSecurity: document.getElementById('wifiDetailSecurity'), - detailCipher: document.getElementById('wifiDetailCipher'), - detailVendor: document.getElementById('wifiDetailVendor'), - detailClients: document.getElementById('wifiDetailClients'), - detailFirstSeen: document.getElementById('wifiDetailFirstSeen'), - detailClientList: document.getElementById('wifiDetailClientList'), - - // Interface select - interfaceSelect: document.getElementById('wifiInterfaceSelect'), - - // Capability status - capabilityStatus: document.getElementById('wifiCapabilityStatus'), - - // Export buttons - exportCsvBtn: document.getElementById('wifiExportCsv'), - exportJsonBtn: document.getElementById('wifiExportJson'), - }; - } - - // ========================================================================== - // Capabilities - // ========================================================================== - - async function checkCapabilities() { - const capBtn = document.getElementById('wifiQuickScanBtn'); - if (capBtn) capBtn.classList.add('btn-loading'); - try { - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - let response; - - if (isAgentMode) { - // Fetch capabilities from agent via controller proxy - response = await fetch(`/controller/agents/${currentAgent}?refresh=true`); - if (!response.ok) throw new Error('Failed to fetch agent capabilities'); - - const data = await response.json(); - // Extract WiFi capabilities from agent data - if (data.agent && data.agent.capabilities) { - const agentCaps = data.agent.capabilities; - const agentInterfaces = data.agent.interfaces || {}; - - // Build WiFi-compatible capabilities object - capabilities = { - can_quick_scan: agentCaps.wifi || false, - can_deep_scan: agentCaps.wifi || false, - interfaces: (agentInterfaces.wifi_interfaces || []).map(iface => ({ - name: iface.name || iface, - supports_monitor: iface.supports_monitor !== false - })), - default_interface: agentInterfaces.default_wifi || null, - preferred_quick_tool: 'agent', - issues: [] - }; - console.log('[WiFiMode] Agent capabilities:', capabilities); - } else { - throw new Error('Agent does not support WiFi mode'); - } - } else { - // Local capabilities - response = await fetch(`${CONFIG.apiBase}/capabilities`); - if (!response.ok) throw new Error('Failed to fetch capabilities'); - capabilities = await response.json(); - console.log('[WiFiMode] Local capabilities:', capabilities); - } - - updateCapabilityUI(); - populateInterfaceSelect(); - } catch (error) { - console.error('[WiFiMode] Capability check failed:', error); - showCapabilityError('Failed to check WiFi capabilities'); - } finally { - if (capBtn) capBtn.classList.remove('btn-loading'); - } - } - - function updateCapabilityUI() { - if (!capabilities || !elements.capabilityStatus) return; - - let html = ''; - - if (!capabilities.can_quick_scan && !capabilities.can_deep_scan) { - html = ` -
- WiFi scanning not available - -
- `; - } else { - // Show available modes - const modes = []; - if (capabilities.can_quick_scan) modes.push('Quick Scan'); - if (capabilities.can_deep_scan) modes.push('Deep Scan'); - - html = ` -
- Available modes: ${modes.join(', ')} - ${capabilities.preferred_quick_tool ? ` (using ${capabilities.preferred_quick_tool})` : ''} -
- `; - - if (capabilities.issues.length > 0) { - html += ` -
- ${capabilities.issues.join('. ')} -
- `; - } - } - - elements.capabilityStatus.innerHTML = html; - elements.capabilityStatus.style.display = html ? 'block' : 'none'; - - // Enable/disable scan buttons based on capabilities - if (elements.quickScanBtn) { - elements.quickScanBtn.disabled = !capabilities.can_quick_scan; - } - if (elements.deepScanBtn) { - elements.deepScanBtn.disabled = !capabilities.can_deep_scan; - } - } - - function showCapabilityError(message) { - if (!elements.capabilityStatus) return; - - elements.capabilityStatus.innerHTML = ` -
${escapeHtml(message)}
- `; - elements.capabilityStatus.style.display = 'block'; - } - - function populateInterfaceSelect() { - if (!elements.interfaceSelect || !capabilities) return; - - elements.interfaceSelect.innerHTML = ''; - - if (capabilities.interfaces.length === 0) { - elements.interfaceSelect.innerHTML = ''; - return; - } - - capabilities.interfaces.forEach(iface => { - const option = document.createElement('option'); - option.value = iface.name; - option.textContent = `${iface.name}${iface.supports_monitor ? ' (monitor capable)' : ''}`; - elements.interfaceSelect.appendChild(option); - }); - - // Select default - if (capabilities.default_interface) { - elements.interfaceSelect.value = capabilities.default_interface; - } - } - - // ========================================================================== - // 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')); - } - // Arrow key navigation between tabs - const tabContainer = document.querySelector('.wifi-scan-mode-tabs'); - if (tabContainer) { - tabContainer.addEventListener('keydown', (e) => { - const tabs = Array.from(tabContainer.querySelectorAll('[role="tab"]')); - const idx = tabs.indexOf(document.activeElement); - if (idx === -1) return; - if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { - e.preventDefault(); - const next = tabs[(idx + 1) % tabs.length]; - next.focus(); - next.click(); - } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { - e.preventDefault(); - const prev = tabs[(idx - 1 + tabs.length) % tabs.length]; - prev.focus(); - prev.click(); - } - }); - } - listenersBound.scanTabs = true; - } - - function setScanMode(mode) { - scanMode = mode; - - // Update tab UI and ARIA states - if (elements.scanModeQuick) { - elements.scanModeQuick.classList.toggle('active', mode === 'quick'); - elements.scanModeQuick.setAttribute('aria-selected', mode === 'quick' ? 'true' : 'false'); - } - if (elements.scanModeDeep) { - elements.scanModeDeep.classList.toggle('active', mode === 'deep'); - elements.scanModeDeep.setAttribute('aria-selected', mode === 'deep' ? 'true' : 'false'); - } - - console.log('[WiFiMode] Scan mode set to:', mode); - } - - // ========================================================================== - // Scanning - // ========================================================================== - - async function startQuickScan() { - if (isScanning) return; - - // Check for agent mode conflicts - if (!await checkAgentConflicts()) { - return; - } - - console.log('[WiFiMode] Starting quick scan...'); - if (elements.quickScanBtn) elements.quickScanBtn.classList.add('btn-loading'); - setScanning(true, 'quick'); - - try { - const iface = elements.interfaceSelect?.value || null; - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const agentName = getCurrentAgentName(); - - let response; - if (isAgentMode) { - // Route through agent proxy - response = await fetch(`/controller/agents/${currentAgent}/wifi/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ interface: iface, scan_type: 'quick' }), - }); - } else { - response = await fetch(`${CONFIG.apiBase}/scan/quick`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ interface: iface }), - }); - } - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Quick scan failed'); - } - - const result = await response.json(); - console.log('[WiFiMode] Quick scan complete:', result); - - // Handle controller proxy response format (agent response is nested in 'result') - const scanResult = isAgentMode && result.result ? result.result : result; - - // Check for error first - if (scanResult.error || scanResult.status === 'error') { - console.error('[WiFiMode] Quick scan error from server:', scanResult.error || scanResult.message); - showError(scanResult.error || scanResult.message || 'Quick scan failed'); - setScanning(false); - return; - } - - // Handle agent response format - let accessPoints = scanResult.access_points || scanResult.networks || []; - - // Check if we got results - if (accessPoints.length === 0) { - // No error but no results - let msg = 'Quick scan found no networks in range.'; - if (scanResult.warnings && scanResult.warnings.length > 0) { - msg += ' Warnings: ' + scanResult.warnings.join('; '); - } - console.warn('[WiFiMode] ' + msg); - showError(msg + ' Try Deep Scan with monitor mode.'); - setScanning(false); - return; - } - - // Tag results with agent source - accessPoints.forEach(ap => { - ap._agent = agentName; - }); - - // Show any warnings even on success - if (scanResult.warnings && scanResult.warnings.length > 0) { - console.warn('[WiFiMode] Quick scan warnings:', scanResult.warnings); - } - - // Process results - processQuickScanResult({ ...scanResult, access_points: accessPoints }); - - // For quick scan, we're done after one scan - // But keep polling if user wants continuous updates - if (scanMode === 'quick') { - startQuickScanPolling(); - } - } catch (error) { - console.error('[WiFiMode] Quick scan error:', error); - showError(error.message + '. Try using Deep Scan instead.'); - setScanning(false); - } finally { - if (elements.quickScanBtn) elements.quickScanBtn.classList.remove('btn-loading'); - } - } - - async function startDeepScan() { - if (isScanning) return; - - // Check for agent mode conflicts - if (!await checkAgentConflicts()) { - return; - } - - console.log('[WiFiMode] Starting deep scan...'); - if (elements.deepScanBtn) elements.deepScanBtn.classList.add('btn-loading'); - 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'; - - let response; - if (isAgentMode) { - // Route through agent proxy - response = await fetch(`/controller/agents/${currentAgent}/wifi/start`, { - 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, - }), - }); - } - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to start deep scan'); - } - - // Check for agent error in response - if (isAgentMode) { - const result = await response.json(); - const scanResult = result.result || result; - if (scanResult.status === 'error') { - throw new Error(scanResult.message || 'Agent failed to start deep scan'); - } - console.log('[WiFiMode] Agent deep scan started:', scanResult); - } - - // Start SSE stream for real-time updates (works with push-enabled agents) - startEventStream(); - - // Also start polling for agent data (works without push enabled) - if (isAgentMode) { - startAgentDeepScanPolling(); - } - } catch (error) { - console.error('[WiFiMode] Deep scan error:', error); - showError(error.message); - setScanning(false); - } finally { - if (elements.deepScanBtn) elements.deepScanBtn.classList.remove('btn-loading'); - } - } - - async function stopScan() { - console.log('[WiFiMode] Stopping scan...'); - - // Stop polling - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - - // Stop agent polling - 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); - } - } - } - - function setScanning(scanning, mode = null) { - isScanning = scanning; - if (mode) scanMode = mode; - - // Update buttons - if (elements.quickScanBtn) { - elements.quickScanBtn.style.display = scanning ? 'none' : 'inline-block'; - } - if (elements.deepScanBtn) { - elements.deepScanBtn.style.display = scanning ? 'none' : 'inline-block'; - } - if (elements.stopScanBtn) { - elements.stopScanBtn.style.display = scanning ? 'inline-block' : 'none'; - } - - // Update status - const dot = elements.scanIndicator?.querySelector('.wifi-scan-dot'); - const text = elements.scanIndicator?.querySelector('.wifi-scan-text'); - if (dot) dot.style.display = scanning ? 'inline-block' : 'none'; - if (text) text.textContent = scanning - ? `SCANNING (${scanMode === 'quick' ? 'Quick' : 'Deep'})` - : 'IDLE'; - } - - async function checkScanStatus() { - try { - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const endpoint = isAgentMode - ? `/controller/agents/${currentAgent}/wifi/status` - : `${CONFIG.apiBase}/scan/status`; - - const response = await fetch(endpoint); - if (!response.ok) return; - - const data = await response.json(); - // Handle agent response format (may be nested in 'result') - const status = isAgentMode && data.result ? data.result : data; - - if (status.is_scanning || status.running) { - // Agent returns scan_type in params, local returns scan_mode - // Normalize: agent may return 'deepscan' or 'deep', UI expects 'deep' or 'quick' - let detectedMode = status.scan_mode || (status.params && status.params.scan_type) || 'deep'; - if (detectedMode === 'deepscan') detectedMode = 'deep'; - - setScanning(true, detectedMode); - if (detectedMode === 'deep') { - startEventStream(); - // Also start polling for agent mode (works without push enabled) - if (isAgentMode) { - startAgentDeepScanPolling(); - } - } else { - startQuickScanPolling(); - } - } - } catch (error) { - console.debug('[WiFiMode] Status check failed:', error); - } - } - - // ========================================================================== - // Quick Scan Polling - // ========================================================================== - - function startQuickScanPolling() { - if (pollTimer) return; - - pollTimer = setInterval(async () => { - if (!isScanning || scanMode !== 'quick') { - clearInterval(pollTimer); - pollTimer = null; - return; - } - - try { - const iface = elements.interfaceSelect?.value || null; - const response = await fetch(`${CONFIG.apiBase}/scan/quick`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ interface: iface }), - }); - - if (response.ok) { - const result = await response.json(); - processQuickScanResult(result); - } - } catch (error) { - console.debug('[WiFiMode] Poll error:', error); - } - }, CONFIG.pollInterval); - } - - 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) - channelStats = result.channel_stats || []; - 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 }); - - // Callbacks - result.access_points.forEach(ap => { - if (onNetworkUpdate) onNetworkUpdate(ap); - }); - } - - // ========================================================================== - // Agent Deep Scan Polling (fallback when push is not enabled) - // ========================================================================== - - function startAgentDeepScanPolling() { - if (agentPollTimer) return; - - console.log('[WiFiMode] Starting agent deep scan polling...'); - - agentPollTimer = setInterval(async () => { - if (!isScanning || scanMode !== 'deep') { - clearInterval(agentPollTimer); - agentPollTimer = null; - return; - } - - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - if (!isAgentMode) { - clearInterval(agentPollTimer); - agentPollTimer = null; - return; - } - - try { - const response = await fetch(`/controller/agents/${currentAgent}/wifi/data`); - if (!response.ok) return; - - const result = await response.json(); - if (result.status !== 'success' || !result.data) return; - - const data = result.data.data || result.data; - const agentName = result.agent_name || 'Remote'; - - // Process networks - if (data.networks && Array.isArray(data.networks)) { - data.networks.forEach(net => { - net._agent = agentName; - handleStreamEvent({ - type: 'network_update', - network: net - }); - }); - } - - // Process clients - if (data.clients && Array.isArray(data.clients)) { - data.clients.forEach(client => { - client._agent = agentName; - handleStreamEvent({ - type: 'client_update', - client: client - }); - }); - } - - console.debug(`[WiFiMode] Agent poll: ${data.networks?.length || 0} networks, ${data.clients?.length || 0} clients`); - - } catch (error) { - console.debug('[WiFiMode] Agent poll error:', error); - } - }, 2000); // Poll every 2 seconds - } - - function stopAgentDeepScanPolling() { - if (agentPollTimer) { - clearInterval(agentPollTimer); - agentPollTimer = null; - } - } - - // ========================================================================== - // SSE Event Stream - // ========================================================================== - - function startEventStream() { - if (eventSource) { - eventSource.close(); - } - - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const agentName = getCurrentAgentName(); - let streamUrl; - - if (isAgentMode) { - // Use multi-agent stream for remote agents - streamUrl = '/controller/stream/all'; - console.log('[WiFiMode] Starting multi-agent event stream...'); - } else { - streamUrl = `${CONFIG.apiBase}/stream`; - console.log('[WiFiMode] Starting local event stream...'); - } - - eventSource = new EventSource(streamUrl); - - eventSource.onopen = () => { - console.log('[WiFiMode] Event stream connected'); - }; - - eventSource.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - - // For multi-agent stream, filter and transform data - if (isAgentMode) { - // Skip keepalive and non-wifi data - if (data.type === 'keepalive') return; - if (data.scan_type !== 'wifi') return; - - // Filter by current agent if not in "show all" mode - if (!showAllAgentsMode && typeof agents !== 'undefined') { - const currentAgentObj = agents.find(a => a.id == currentAgent); - if (currentAgentObj && data.agent_name && data.agent_name !== currentAgentObj.name) { - return; - } - } - - // Transform multi-agent payload to stream event format - if (data.payload && data.payload.networks) { - data.payload.networks.forEach(net => { - net._agent = data.agent_name || 'Unknown'; - handleStreamEvent({ - type: 'network_update', - network: net - }); - }); - } - if (data.payload && data.payload.clients) { - data.payload.clients.forEach(client => { - client._agent = data.agent_name || 'Unknown'; - handleStreamEvent({ - type: 'client_update', - client: client - }); - }); - } - } else { - // Local stream - tag with local - if (data.network) data.network._agent = 'Local'; - if (data.client) data.client._agent = 'Local'; - handleStreamEvent(data); - } - } catch (error) { - console.debug('[WiFiMode] Event parse error:', error); - } - }; - - eventSource.onerror = (error) => { - console.warn('[WiFiMode] Event stream error:', error); - if (isScanning) { - // Attempt to reconnect - setTimeout(() => { - if (isScanning && scanMode === 'deep') { - startEventStream(); - } - }, 3000); - } - }; - } - - function handleStreamEvent(event) { - switch (event.type) { - case 'network_update': - handleNetworkUpdate(event.network); - break; - - case 'client_update': - handleClientUpdate(event.client); - break; - - case 'probe_request': - handleProbeRequest(event.probe); - break; - - case 'hidden_revealed': - handleHiddenRevealed(event.bssid, event.revealed_essid); - break; - - case 'scan_started': - console.log('[WiFiMode] Scan started:', event); - break; - - case 'scan_stopped': - console.log('[WiFiMode] Scan stopped'); - setScanning(false); - break; - - case 'scan_error': - console.error('[WiFiMode] Scan error:', event.error); - showError(event.error); - setScanning(false); - break; - - case 'keepalive': - // Ignore keepalives - break; - - default: - console.debug('[WiFiMode] Unknown event type:', event.type); - } - } - - function handleNetworkUpdate(network) { - networks.set(network.bssid, network); - scheduleRender({ - table: true, - stats: true, - radar: true, - chart: true, - detail: selectedBssid === 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); - } - - function handleProbeRequest(probe) { - probeRequests.push(probe); - if (probeRequests.length > CONFIG.maxProbes) { - probeRequests.shift(); - } - - 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: selectedBssid === 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 setNetworkFilter(filter) { - currentFilter = filter; - - // Update button states - if (elements.networkFilters) { - elements.networkFilters.querySelectorAll('.wifi-filter-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.filter === filter); - }); - } - - renderNetworks(); - } - - function initSortControls() { - if (listenersBound.sort) return; - - document.querySelectorAll('.wifi-sort-btn').forEach(btn => { - btn.addEventListener('click', () => { - const field = btn.dataset.sort; - if (currentSort.field === field) { - currentSort.order = currentSort.order === 'desc' ? 'asc' : 'desc'; - } else { - currentSort.field = field; - currentSort.order = 'desc'; - } - document.querySelectorAll('.wifi-sort-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - scheduleRender({ table: true }); - }); - }); - - 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) renderNetworks(); - if (pendingRender.stats) updateStats(); - if (pendingRender.radar) updateProximityRadar(); - if (pendingRender.chart) updateChannelChart(); - if (pendingRender.detail && selectedBssid) { - updateDetailPanel(selectedBssid, { refreshClients: false }); - } - - pendingRender.table = false; - pendingRender.stats = false; - pendingRender.radar = false; - pendingRender.chart = false; - pendingRender.detail = false; - }); - } - - function renderNetworks() { - if (!elements.networkList) return; - - // Filter networks - let filtered = Array.from(networks.values()); - - switch (currentFilter) { - case 'hidden': - filtered = filtered.filter(n => n.is_hidden); - break; - case 'open': - filtered = filtered.filter(n => n.security === 'Open'); - break; - case 'strong': - filtered = filtered.filter(n => n.rssi_current && n.rssi_current >= -60); - break; - case '2.4': - filtered = filtered.filter(n => n.band === '2.4GHz'); - break; - case '5': - filtered = filtered.filter(n => n.band === '5GHz'); - break; - } - - // Sort networks - filtered.sort((a, b) => { - let aVal, bVal; - - switch (currentSort.field) { - case 'rssi': - aVal = a.rssi_current || -100; - bVal = b.rssi_current || -100; - break; - case 'channel': - aVal = a.channel || 0; - bVal = b.channel || 0; - break; - case 'essid': - aVal = (a.essid || '').toLowerCase(); - bVal = (b.essid || '').toLowerCase(); - break; - case 'clients': - aVal = a.client_count || 0; - bVal = b.client_count || 0; - break; - default: - aVal = a.rssi_current || -100; - bVal = b.rssi_current || -100; - } - - if (currentSort.order === 'desc') { - return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; - } else { - return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; - } - }); - - if (filtered.length === 0) { - let message = networks.size > 0 - ? 'No networks match current filters' - : (isScanning ? 'Scanning for networks...' : 'Start scanning to discover networks'); - elements.networkList.innerHTML = `

${escapeHtml(message)}

`; - return; - } - - // Render list - elements.networkList.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); - - // Re-apply selected state after re-render - if (selectedBssid) { - const sel = elements.networkList.querySelector(`[data-bssid="${CSS.escape(selectedBssid)}"]`); - if (sel) sel.classList.add('selected'); - } - } - - function createNetworkRow(network) { - const rssi = network.rssi_current; - const security = network.security || 'Unknown'; - - // Badge class - const sec = security.toLowerCase(); - const badgeClass = sec === 'open' || sec === '' ? 'open' - : sec.includes('wpa3') ? 'wpa3' - : sec.includes('wpa') ? 'wpa2' - : sec.includes('wep') ? 'wep' - : 'wpa2'; - - // Threat class (left border) - const threatClass = badgeClass === 'open' ? 'threat-open' - : badgeClass === 'wpa2' || badgeClass === 'wpa3' ? 'threat-safe' - : 'threat-hidden'; - - // Signal bar width + class - const pct = rssi != null ? Math.max(0, Math.min(100, (rssi + 100) / 80 * 100)) : 0; - const fillClass = rssi > -55 ? 'strong' : rssi > -70 ? 'medium' : 'weak'; - - const displayName = escapeHtml(network.display_name || network.essid || '[Hidden]'); - const isHidden = network.is_hidden; - const hiddenTag = isHidden ? 'HIDDEN' : ''; - - return ` -
-
- ${displayName} -
- ${escapeHtml(security)} - ${hiddenTag} -
-
-
-
-
-
-
-
-
- ch ${network.channel || '?'} - ${network.client_count || 0} ↔ - ${rssi != null ? rssi : '?'} -
-
-
- `; - } - - function updateNetworkRow(network) { - scheduleRender({ - table: true, - detail: selectedBssid === network.bssid, - }); - } - - function selectNetwork(bssid) { - selectedBssid = bssid; - - // Update row selection - elements.networkList?.querySelectorAll('.network-row').forEach(row => { - row.classList.toggle('selected', row.dataset.bssid === bssid); - }); - - // Update detail panel - updateDetailPanel(bssid); - - // Highlight on radar - if (typeof WiFiProximityRadar !== 'undefined') { - WiFiProximityRadar.highlightNetwork(bssid); - } - } - - // ========================================================================== - // Detail Panel - // ========================================================================== - - function updateDetailPanel(bssid, options = {}) { - const { refreshClients = true } = options; - if (!elements.detailDrawer) return; - - const network = networks.get(bssid); - if (!network) { - closeDetail(); - return; - } - - // Update drawer header - if (elements.detailEssid) { - elements.detailEssid.textContent = network.display_name || network.essid || '[Hidden SSID]'; - } - if (elements.detailBssid) { - elements.detailBssid.textContent = network.bssid; - } - - // Update detail stats - if (elements.detailRssi) { - elements.detailRssi.textContent = network.rssi_current ? `${network.rssi_current} dBm` : '--'; - } - if (elements.detailChannel) { - elements.detailChannel.textContent = network.channel || '--'; - } - if (elements.detailBand) { - elements.detailBand.textContent = network.band || '--'; - } - if (elements.detailSecurity) { - elements.detailSecurity.textContent = network.security || '--'; - } - if (elements.detailCipher) { - elements.detailCipher.textContent = network.cipher || '--'; - } - if (elements.detailVendor) { - elements.detailVendor.textContent = network.vendor || 'Unknown'; - } - if (elements.detailClients) { - elements.detailClients.textContent = network.client_count || '0'; - } - if (elements.detailFirstSeen) { - elements.detailFirstSeen.textContent = formatTime(network.first_seen); - } - - // Show the drawer - elements.detailDrawer.classList.add('open'); - - // Fetch and display clients for this network - if (refreshClients) { - fetchClientsForNetwork(network.bssid); - } - } - - function closeDetail() { - selectedBssid = null; - if (elements.detailDrawer) { - elements.detailDrawer.classList.remove('open'); - } - elements.networkList?.querySelectorAll('.network-row').forEach(row => { - row.classList.remove('selected'); - }); - } - - // ========================================================================== - // 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; - - if (isAgentMode) { - // Route through agent proxy - response = await fetch(`/controller/agents/${currentAgent}/wifi/v2/clients?bssid=${encodeURIComponent(bssid)}&associated=true`); - } else { - 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; - } - - 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'; - } - } - } - - function renderClientList(clientList, bssid) { - const container = elements.detailClientList?.querySelector('.wifi-client-list'); - const countBadge = document.getElementById('wifiClientCountBadge'); - - if (!container) return; - - // Update count badge - if (countBadge) { - countBadge.textContent = clientList.length; - } - - // Render client cards - container.innerHTML = clientList.map(client => { - const rssi = client.rssi_current; - const signalClass = rssi >= -50 ? 'signal-strong' : - rssi >= -70 ? 'signal-medium' : - rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; - - // Format last seen time - const lastSeen = client.last_seen ? formatTime(client.last_seen) : '--'; - - // Build probed SSIDs badges - let probesHtml = ''; - if (client.probed_ssids && client.probed_ssids.length > 0) { - const probes = client.probed_ssids.slice(0, 5); // Show max 5 - probesHtml = ` -
- ${probes.map(ssid => `${escapeHtml(ssid)}`).join('')} - ${client.probed_ssids.length > 5 ? `+${client.probed_ssids.length - 5}` : ''} -
- `; - } - - return ` -
-
- ${escapeHtml(client.mac)} - ${escapeHtml(client.vendor || 'Unknown vendor')} - ${probesHtml} -
-
- ${rssi !== null && rssi !== undefined ? rssi + ' dBm' : '--'} - ${lastSeen} -
-
- `; - }).join(''); - } - - function updateClientInList(client) { - // Check if this client belongs to the currently selected network - if (!selectedBssid || client.associated_bssid !== selectedBssid) { - return; - } - - const container = elements.detailClientList?.querySelector('.wifi-client-list'); - if (!container) return; - - const existingCard = container.querySelector(`[data-mac="${client.mac}"]`); - - if (existingCard) { - // Update existing card's RSSI and last seen - const rssiEl = existingCard.querySelector('.wifi-client-rssi'); - const lastSeenEl = existingCard.querySelector('.wifi-client-lastseen'); - - if (rssiEl && client.rssi_current !== null && client.rssi_current !== undefined) { - const rssi = client.rssi_current; - const signalClass = rssi >= -50 ? 'signal-strong' : - rssi >= -70 ? 'signal-medium' : - rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; - rssiEl.textContent = rssi + ' dBm'; - rssiEl.className = 'wifi-client-rssi ' + signalClass; - } - - if (lastSeenEl && client.last_seen) { - lastSeenEl.textContent = formatTime(client.last_seen); - } - } else { - // New client for this network - re-fetch the full list - fetchClientsForNetwork(selectedBssid); - } - } - - // ========================================================================== - // Statistics - // ========================================================================== - - function updateStats() { - const networksList = Array.from(networks.values()); - - // Update counts in status bar - if (elements.networkCount) { - elements.networkCount.textContent = networks.size; - } - if (elements.clientCount) { - elements.clientCount.textContent = clients.size; - } - if (elements.hiddenCount) { - const hidden = networksList.filter(n => n.is_hidden).length; - elements.hiddenCount.textContent = hidden; - } - - // Update security counts - const securityCounts = { wpa3: 0, wpa2: 0, wep: 0, open: 0 }; - networksList.forEach(n => { - const sec = (n.security || '').toLowerCase(); - if (sec.includes('wpa3')) securityCounts.wpa3++; - else if (sec.includes('wpa2') || sec.includes('wpa')) securityCounts.wpa2++; - else if (sec.includes('wep')) securityCounts.wep++; - else if (sec === 'open' || sec === '') securityCounts.open++; - }); - - if (elements.openCount) elements.openCount.textContent = securityCounts.open; - - // Update zone summary - const zoneCounts = { immediate: 0, near: 0, far: 0 }; - networksList.forEach(n => { - const rssi = n.rssi_current; - if (rssi >= -50) zoneCounts.immediate++; - else if (rssi >= -70) zoneCounts.near++; - else zoneCounts.far++; - }); - - if (elements.zoneImmediate) elements.zoneImmediate.textContent = zoneCounts.immediate; - if (elements.zoneNear) elements.zoneNear.textContent = zoneCounts.near; - if (elements.zoneFar) elements.zoneFar.textContent = zoneCounts.far; - } - - // ========================================================================== - // Proximity Radar - // ========================================================================== - - function initProximityRadar() { - if (!elements.proximityRadar) return; - - // Initialize radar component - if (typeof ProximityRadar !== 'undefined') { - ProximityRadar.init('wifiProximityRadar', { - mode: 'wifi', - size: 280, - onDeviceClick: (bssid) => selectNetwork(bssid), - }); - } - } - - function updateProximityRadar() { - if (typeof ProximityRadar === 'undefined') return; - - // Convert networks to radar-compatible format - const devices = Array.from(networks.values()).map(n => ({ - device_key: n.bssid, - device_id: n.bssid, - name: n.essid || '[Hidden]', - rssi_current: n.rssi_current, - rssi_ema: n.rssi_ema, - proximity_band: n.proximity_band, - estimated_distance_m: n.estimated_distance_m, - is_new: n.is_new, - heuristic_flags: n.heuristic_flags || [], - })); - - ProximityRadar.updateDevices(devices); - } - - // ========================================================================== - // Channel Chart - // ========================================================================== - - function initChannelChart() { - if (!elements.channelChart) return; - - // Initialize channel chart component - if (typeof ChannelChart !== 'undefined') { - ChannelChart.init('wifiChannelChart'); - } - - // Band tabs - if (elements.channelBandTabs) { - elements.channelBandTabs.addEventListener('click', (e) => { - if (e.target.matches('.channel-band-tab')) { - const band = e.target.dataset.band; - elements.channelBandTabs.querySelectorAll('.channel-band-tab').forEach(t => { - t.classList.toggle('active', t.dataset.band === band); - }); - updateChannelChart(band); - } - }); - } - } - - function calculateChannelStats() { - // Calculate channel stats from current networks - const stats = {}; - const networksList = Array.from(networks.values()); - - // Initialize all channels - // 2.4 GHz: channels 1-13 - for (let ch = 1; ch <= 13; ch++) { - stats[ch] = { channel: ch, band: '2.4GHz', ap_count: 0, client_count: 0, utilization_score: 0 }; - } - // 5 GHz: common channels - [36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165].forEach(ch => { - stats[ch] = { channel: ch, band: '5GHz', ap_count: 0, client_count: 0, utilization_score: 0 }; - }); - - // Count APs per channel - networksList.forEach(net => { - const ch = parseInt(net.channel); - if (stats[ch]) { - stats[ch].ap_count++; - stats[ch].client_count += (net.client_count || 0); - } - }); - - // Calculate utilization score (0-1) - const maxAPs = Math.max(1, ...Object.values(stats).map(s => s.ap_count)); - Object.values(stats).forEach(s => { - s.utilization_score = s.ap_count / maxAPs; - }); - - return Object.values(stats).filter(s => s.ap_count > 0 || [1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161, 165].includes(s.channel)); - } - - function updateChannelChart(band) { - if (typeof ChannelChart === 'undefined') return; - - // Use the currently active band tab if no band specified - if (!band) { - const activeTab = elements.channelBandTabs && elements.channelBandTabs.querySelector('.channel-band-tab.active'); - band = activeTab ? activeTab.dataset.band : '2.4'; - } - - // Recalculate channel stats from networks if needed - if (channelStats.length === 0 && networks.size > 0) { - channelStats = calculateChannelStats(); - } - - // Filter stats by band - const bandFilter = band === '2.4' ? '2.4GHz' : band === '5' ? '5GHz' : '6GHz'; - const filteredStats = channelStats.filter(s => s.band === bandFilter); - const filteredRecs = recommendations.filter(r => r.band === bandFilter); - - ChannelChart.update(filteredStats, filteredRecs); - } - - // ========================================================================== - // Export - // ========================================================================== - - async function exportData(format) { - try { - const response = await fetch(`${CONFIG.apiBase}/export?format=${format}&type=all`); - if (!response.ok) throw new Error('Export failed'); - - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `wifi_scan_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.${format}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } catch (error) { - console.error('[WiFiMode] Export error:', error); - showError('Export failed: ' + error.message); - } - } - - // ========================================================================== - // Utilities - // ========================================================================== - - function escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - - function formatTime(isoString) { - if (!isoString) return '-'; - const date = new Date(isoString); - return date.toLocaleTimeString(); - } - - function showError(message) { - // Use global notification if available - if (typeof showNotification === 'function') { - showNotification('WiFi Error', message, 'error'); - } else { - console.error('[WiFiMode]', message); - } - } - - function showInfo(message) { - if (typeof showNotification === 'function') { - showNotification('WiFi', message, 'info'); - } else { - console.log('[WiFiMode]', message); - } - } - - // ========================================================================== - // Agent Handling - // ========================================================================== - - /** - * Handle agent change - refresh interfaces and optionally clear data. - * Called when user selects a different agent. - */ - function handleAgentChange() { - const currentAgentId = typeof currentAgent !== 'undefined' ? currentAgent : 'local'; - - // Check if agent actually changed - if (lastAgentId === currentAgentId) return; - - console.log('[WiFiMode] Agent changed from', lastAgentId, 'to', currentAgentId); - - // Stop UI polling only - don't stop the actual scan on the agent - // The agent should continue running independently - if (isScanning) { - stopAgentDeepScanPolling(); - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - if (eventSource) { - eventSource.close(); - eventSource = null; - } - setScanning(false); - } - - // Clear existing data when switching agents (unless "Show All" is enabled) - if (!showAllAgentsMode) { - clearData(); - showInfo(`Switched to ${getCurrentAgentName()} - previous data cleared`); - } - - // Refresh capabilities for new agent - checkCapabilities(); - - // Check if new agent already has a scan running - checkScanStatus(); - - lastAgentId = currentAgentId; - } - - /** - * Clear all collected data. - */ - function clearData() { - networks.clear(); - clients.clear(); - probeRequests = []; - channelStats = []; - recommendations = []; - if (selectedBssid) { - closeDetail(); - } - scheduleRender({ table: true, stats: true, radar: true, chart: true }); - } - - /** - * Toggle "Show All Agents" mode. - * When enabled, displays combined WiFi results from all agents. - */ - function toggleShowAllAgents(enabled) { - showAllAgentsMode = enabled; - console.log('[WiFiMode] Show all agents mode:', enabled); - - if (enabled) { - // If currently scanning, switch to multi-agent stream - if (isScanning && eventSource) { - eventSource.close(); - startEventStream(); - } - showInfo('Showing WiFi networks from all agents'); - } else { - // Filter to current agent only - filterToCurrentAgent(); - } - } - - /** - * Filter networks to only show those from current agent. - */ - function filterToCurrentAgent() { - const agentName = getCurrentAgentName(); - const toRemove = []; - - networks.forEach((network, bssid) => { - if (network._agent && network._agent !== agentName) { - toRemove.push(bssid); - } - }); - - toRemove.forEach(bssid => networks.delete(bssid)); - - // Also filter clients - const clientsToRemove = []; - clients.forEach((client, mac) => { - if (client._agent && client._agent !== agentName) { - clientsToRemove.push(mac); - } - }); - clientsToRemove.forEach(mac => clients.delete(mac)); - if (selectedBssid && !networks.has(selectedBssid)) { - closeDetail(); - } - scheduleRender({ table: true, stats: true, radar: true, chart: true }); - } - - /** - * Refresh WiFi interfaces from current agent. - * Called when agent changes. - */ - async function refreshInterfaces() { - await checkCapabilities(); - } - - // ========================================================================== - // Public API - // ========================================================================== - - return { - init, - startQuickScan, - startDeepScan, - stopScan, - selectNetwork, - closeDetail, - setFilter: setNetworkFilter, - exportData, - checkCapabilities, - - // Agent handling - handleAgentChange, - clearData, - toggleShowAllAgents, - refreshInterfaces, - - // Getters - getNetworks: () => Array.from(networks.values()), - getClients: () => Array.from(clients.values()), - getProbes: () => [...probeRequests], - isScanning: () => isScanning, - getScanMode: () => scanMode, - isShowAllAgents: () => showAllAgentsMode, - - // Callbacks - 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 -document.addEventListener('DOMContentLoaded', () => { - // Only init if we're in WiFi mode - if (typeof currentMode !== 'undefined' && currentMode === 'wifi') { - WiFiMode.init(); - } -}); +/** + * WiFi Mode Controller (v2) + * + * Unified WiFi scanning with dual-mode architecture: + * - Quick Scan: System tools without monitor mode + * - Deep Scan: airodump-ng with monitor mode + * + * Features: + * - Proximity radar visualization + * - Channel utilization analysis + * - Hidden SSID correlation + * - Real-time SSE streaming + */ + +const WiFiMode = (function() { + 'use strict'; + + // ========================================================================== + // Configuration + // ========================================================================== + + const CONFIG = { + apiBase: '/wifi/v2', + pollInterval: 5000, + keepaliveTimeout: 30000, + maxNetworks: 500, + maxClients: 500, + maxProbes: 1000, + }; + + // ========================================================================== + // Agent Support + // ========================================================================== + + /** + * Get the API base URL, routing through agent proxy if agent is selected. + */ + function getApiBase() { + if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') { + return `/controller/agents/${currentAgent}/wifi/v2`; + } + return CONFIG.apiBase; + } + + /** + * Get the current agent name for tagging data. + */ + function getCurrentAgentName() { + if (typeof currentAgent === 'undefined' || currentAgent === 'local') { + return 'Local'; + } + if (typeof agents !== 'undefined') { + const agent = agents.find(a => a.id == currentAgent); + return agent ? agent.name : `Agent ${currentAgent}`; + } + return `Agent ${currentAgent}`; + } + + /** + * Check for agent mode conflicts before starting WiFi scan. + */ + async function checkAgentConflicts() { + if (typeof currentAgent === 'undefined' || currentAgent === 'local') { + return true; + } + if (typeof checkAgentModeConflict === 'function') { + return await 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 + // ========================================================================== + + let isScanning = false; + let scanMode = 'quick'; // 'quick' or 'deep' + let eventSource = null; + let pollTimer = null; + let agentPollTimer = null; + + // Data stores + let networks = new Map(); // bssid -> network + let clients = new Map(); // mac -> client + let probeRequests = []; + let channelStats = []; + let recommendations = []; + + // UI state + let selectedBssid = 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 + let lastAgentId = null; // Track agent switches + + // Capabilities + let capabilities = null; + + // Callbacks for external integration + let onNetworkUpdate = null; + let onClientUpdate = null; + let onProbeRequest = null; + + // ========================================================================== + // Initialization + // ========================================================================== + + function init() { + console.log('[WiFiMode] Initializing...'); + + // Cache DOM elements + cacheDOM(); + + // Check capabilities + checkCapabilities(); + + // Initialize components + initScanModeTabs(); + initNetworkFilters(); + initSortControls(); + initProximityRadar(); + initChannelChart(); + scheduleRender({ table: true, stats: true, radar: true, chart: true }); + + // Check if already scanning + checkScanStatus(); + + console.log('[WiFiMode] Initialized'); + } + + // DOM element cache + let elements = {}; + + function cacheDOM() { + elements = { + // Scan controls + quickScanBtn: document.getElementById('wifiQuickScanBtn'), + deepScanBtn: document.getElementById('wifiDeepScanBtn'), + stopScanBtn: document.getElementById('wifiStopScanBtn'), + scanModeQuick: document.getElementById('wifiScanModeQuick'), + scanModeDeep: document.getElementById('wifiScanModeDeep'), + + // Status bar + scanIndicator: document.getElementById('wifiScanIndicator'), + openCount: document.getElementById('wifiOpenCount'), + networkCount: document.getElementById('wifiNetworkCount'), + clientCount: document.getElementById('wifiClientCount'), + hiddenCount: document.getElementById('wifiHiddenCount'), + + // Network list + networkList: document.getElementById('wifiNetworkList'), + networkFilters: document.getElementById('wifiNetworkFilters'), + + // Visualizations + proximityRadar: document.getElementById('wifiProximityRadar'), + channelChart: document.getElementById('wifiChannelChart'), + channelBandTabs: document.getElementById('wifiChannelBandTabs'), + + // Zone summary + zoneImmediate: document.getElementById('wifiZoneImmediate'), + zoneNear: document.getElementById('wifiZoneNear'), + zoneFar: document.getElementById('wifiZoneFar'), + + // Detail drawer + detailDrawer: document.getElementById('wifiDetailDrawer'), + detailEssid: document.getElementById('wifiDetailEssid'), + detailBssid: document.getElementById('wifiDetailBssid'), + detailRssi: document.getElementById('wifiDetailRssi'), + detailChannel: document.getElementById('wifiDetailChannel'), + detailBand: document.getElementById('wifiDetailBand'), + detailSecurity: document.getElementById('wifiDetailSecurity'), + detailCipher: document.getElementById('wifiDetailCipher'), + detailVendor: document.getElementById('wifiDetailVendor'), + detailClients: document.getElementById('wifiDetailClients'), + detailFirstSeen: document.getElementById('wifiDetailFirstSeen'), + detailClientList: document.getElementById('wifiDetailClientList'), + + // Interface select + interfaceSelect: document.getElementById('wifiInterfaceSelect'), + + // Capability status + capabilityStatus: document.getElementById('wifiCapabilityStatus'), + + // Export buttons + exportCsvBtn: document.getElementById('wifiExportCsv'), + exportJsonBtn: document.getElementById('wifiExportJson'), + }; + } + + // ========================================================================== + // Capabilities + // ========================================================================== + + async function checkCapabilities() { + const capBtn = document.getElementById('wifiQuickScanBtn'); + if (capBtn) capBtn.classList.add('btn-loading'); + try { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + let response; + + if (isAgentMode) { + // Fetch capabilities from agent via controller proxy + response = await fetch(`/controller/agents/${currentAgent}?refresh=true`); + if (!response.ok) throw new Error('Failed to fetch agent capabilities'); + + const data = await response.json(); + // Extract WiFi capabilities from agent data + if (data.agent && data.agent.capabilities) { + const agentCaps = data.agent.capabilities; + const agentInterfaces = data.agent.interfaces || {}; + + // Build WiFi-compatible capabilities object + capabilities = { + can_quick_scan: agentCaps.wifi || false, + can_deep_scan: agentCaps.wifi || false, + interfaces: (agentInterfaces.wifi_interfaces || []).map(iface => ({ + name: iface.name || iface, + supports_monitor: iface.supports_monitor !== false + })), + default_interface: agentInterfaces.default_wifi || null, + preferred_quick_tool: 'agent', + issues: [] + }; + console.log('[WiFiMode] Agent capabilities:', capabilities); + } else { + throw new Error('Agent does not support WiFi mode'); + } + } else { + // Local capabilities + response = await fetch(`${CONFIG.apiBase}/capabilities`); + if (!response.ok) throw new Error('Failed to fetch capabilities'); + capabilities = await response.json(); + console.log('[WiFiMode] Local capabilities:', capabilities); + } + + updateCapabilityUI(); + populateInterfaceSelect(); + } catch (error) { + console.error('[WiFiMode] Capability check failed:', error); + showCapabilityError('Failed to check WiFi capabilities'); + } finally { + if (capBtn) capBtn.classList.remove('btn-loading'); + } + } + + function updateCapabilityUI() { + if (!capabilities || !elements.capabilityStatus) return; + + let html = ''; + + if (!capabilities.can_quick_scan && !capabilities.can_deep_scan) { + html = ` +
+ WiFi scanning not available + +
+ `; + } else { + // Show available modes + const modes = []; + if (capabilities.can_quick_scan) modes.push('Quick Scan'); + if (capabilities.can_deep_scan) modes.push('Deep Scan'); + + html = ` +
+ Available modes: ${modes.join(', ')} + ${capabilities.preferred_quick_tool ? ` (using ${capabilities.preferred_quick_tool})` : ''} +
+ `; + + if (capabilities.issues.length > 0) { + html += ` +
+ ${capabilities.issues.join('. ')} +
+ `; + } + } + + elements.capabilityStatus.innerHTML = html; + elements.capabilityStatus.style.display = html ? 'block' : 'none'; + + // Enable/disable scan buttons based on capabilities + if (elements.quickScanBtn) { + elements.quickScanBtn.disabled = !capabilities.can_quick_scan; + } + if (elements.deepScanBtn) { + elements.deepScanBtn.disabled = !capabilities.can_deep_scan; + } + } + + function showCapabilityError(message) { + if (!elements.capabilityStatus) return; + + elements.capabilityStatus.innerHTML = ` +
${escapeHtml(message)}
+ `; + elements.capabilityStatus.style.display = 'block'; + } + + function populateInterfaceSelect() { + if (!elements.interfaceSelect || !capabilities) return; + + elements.interfaceSelect.innerHTML = ''; + + if (capabilities.interfaces.length === 0) { + elements.interfaceSelect.innerHTML = ''; + return; + } + + capabilities.interfaces.forEach(iface => { + const option = document.createElement('option'); + option.value = iface.name; + option.textContent = `${iface.name}${iface.supports_monitor ? ' (monitor capable)' : ''}`; + elements.interfaceSelect.appendChild(option); + }); + + // Select default + if (capabilities.default_interface) { + elements.interfaceSelect.value = capabilities.default_interface; + } + } + + // ========================================================================== + // 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')); + } + // Arrow key navigation between tabs + const tabContainer = document.querySelector('.wifi-scan-mode-tabs'); + if (tabContainer) { + tabContainer.addEventListener('keydown', (e) => { + const tabs = Array.from(tabContainer.querySelectorAll('[role="tab"]')); + const idx = tabs.indexOf(document.activeElement); + if (idx === -1) return; + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + e.preventDefault(); + const next = tabs[(idx + 1) % tabs.length]; + next.focus(); + next.click(); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + e.preventDefault(); + const prev = tabs[(idx - 1 + tabs.length) % tabs.length]; + prev.focus(); + prev.click(); + } + }); + } + listenersBound.scanTabs = true; + } + + function setScanMode(mode) { + scanMode = mode; + + // Update tab UI and ARIA states + if (elements.scanModeQuick) { + elements.scanModeQuick.classList.toggle('active', mode === 'quick'); + elements.scanModeQuick.setAttribute('aria-selected', mode === 'quick' ? 'true' : 'false'); + } + if (elements.scanModeDeep) { + elements.scanModeDeep.classList.toggle('active', mode === 'deep'); + elements.scanModeDeep.setAttribute('aria-selected', mode === 'deep' ? 'true' : 'false'); + } + + console.log('[WiFiMode] Scan mode set to:', mode); + } + + // ========================================================================== + // Scanning + // ========================================================================== + + async function startQuickScan() { + if (isScanning) return; + + // Check for agent mode conflicts + if (!await checkAgentConflicts()) { + return; + } + + console.log('[WiFiMode] Starting quick scan...'); + if (elements.quickScanBtn) elements.quickScanBtn.classList.add('btn-loading'); + setScanning(true, 'quick'); + + try { + const iface = elements.interfaceSelect?.value || null; + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const agentName = getCurrentAgentName(); + + let response; + if (isAgentMode) { + // Route through agent proxy + response = await fetch(`/controller/agents/${currentAgent}/wifi/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interface: iface, scan_type: 'quick' }), + }); + } else { + response = await fetch(`${CONFIG.apiBase}/scan/quick`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interface: iface }), + }); + } + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Quick scan failed'); + } + + const result = await response.json(); + console.log('[WiFiMode] Quick scan complete:', result); + + // Handle controller proxy response format (agent response is nested in 'result') + const scanResult = isAgentMode && result.result ? result.result : result; + + // Check for error first + if (scanResult.error || scanResult.status === 'error') { + console.error('[WiFiMode] Quick scan error from server:', scanResult.error || scanResult.message); + showError(scanResult.error || scanResult.message || 'Quick scan failed'); + setScanning(false); + return; + } + + // Handle agent response format + let accessPoints = scanResult.access_points || scanResult.networks || []; + + // Check if we got results + if (accessPoints.length === 0) { + // No error but no results + let msg = 'Quick scan found no networks in range.'; + if (scanResult.warnings && scanResult.warnings.length > 0) { + msg += ' Warnings: ' + scanResult.warnings.join('; '); + } + console.warn('[WiFiMode] ' + msg); + showError(msg + ' Try Deep Scan with monitor mode.'); + setScanning(false); + return; + } + + // Tag results with agent source + accessPoints.forEach(ap => { + ap._agent = agentName; + }); + + // Show any warnings even on success + if (scanResult.warnings && scanResult.warnings.length > 0) { + console.warn('[WiFiMode] Quick scan warnings:', scanResult.warnings); + } + + // Process results + processQuickScanResult({ ...scanResult, access_points: accessPoints }); + + // For quick scan, we're done after one scan + // But keep polling if user wants continuous updates + if (scanMode === 'quick') { + startQuickScanPolling(); + } + } catch (error) { + console.error('[WiFiMode] Quick scan error:', error); + showError(error.message + '. Try using Deep Scan instead.'); + setScanning(false); + } finally { + if (elements.quickScanBtn) elements.quickScanBtn.classList.remove('btn-loading'); + } + } + + async function startDeepScan() { + if (isScanning) return; + + // Check for agent mode conflicts + if (!await checkAgentConflicts()) { + return; + } + + console.log('[WiFiMode] Starting deep scan...'); + if (elements.deepScanBtn) elements.deepScanBtn.classList.add('btn-loading'); + 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'; + + let response; + if (isAgentMode) { + // Route through agent proxy + response = await fetch(`/controller/agents/${currentAgent}/wifi/start`, { + 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, + }), + }); + } + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to start deep scan'); + } + + // Check for agent error in response + if (isAgentMode) { + const result = await response.json(); + const scanResult = result.result || result; + if (scanResult.status === 'error') { + throw new Error(scanResult.message || 'Agent failed to start deep scan'); + } + console.log('[WiFiMode] Agent deep scan started:', scanResult); + } + + // Start SSE stream for real-time updates (works with push-enabled agents) + startEventStream(); + + // Also start polling for agent data (works without push enabled) + if (isAgentMode) { + startAgentDeepScanPolling(); + } + } catch (error) { + console.error('[WiFiMode] Deep scan error:', error); + showError(error.message); + setScanning(false); + } finally { + if (elements.deepScanBtn) elements.deepScanBtn.classList.remove('btn-loading'); + } + } + + async function stopScan() { + console.log('[WiFiMode] Stopping scan...'); + + // Stop polling + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + + // Stop agent polling + 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); + } + } + } + + function setScanning(scanning, mode = null) { + isScanning = scanning; + if (mode) scanMode = mode; + + // Update buttons + if (elements.quickScanBtn) { + elements.quickScanBtn.style.display = scanning ? 'none' : 'inline-block'; + } + if (elements.deepScanBtn) { + elements.deepScanBtn.style.display = scanning ? 'none' : 'inline-block'; + } + if (elements.stopScanBtn) { + elements.stopScanBtn.style.display = scanning ? 'inline-block' : 'none'; + } + + // Update status + const dot = elements.scanIndicator?.querySelector('.wifi-scan-dot'); + const text = elements.scanIndicator?.querySelector('.wifi-scan-text'); + if (dot) dot.style.display = scanning ? 'inline-block' : 'none'; + if (text) text.textContent = scanning + ? `SCANNING (${scanMode === 'quick' ? 'Quick' : 'Deep'})` + : 'IDLE'; + } + + async function checkScanStatus() { + try { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const endpoint = isAgentMode + ? `/controller/agents/${currentAgent}/wifi/status` + : `${CONFIG.apiBase}/scan/status`; + + const response = await fetch(endpoint); + if (!response.ok) return; + + const data = await response.json(); + // Handle agent response format (may be nested in 'result') + const status = isAgentMode && data.result ? data.result : data; + + if (status.is_scanning || status.running) { + // Agent returns scan_type in params, local returns scan_mode + // Normalize: agent may return 'deepscan' or 'deep', UI expects 'deep' or 'quick' + let detectedMode = status.scan_mode || (status.params && status.params.scan_type) || 'deep'; + if (detectedMode === 'deepscan') detectedMode = 'deep'; + + setScanning(true, detectedMode); + if (detectedMode === 'deep') { + startEventStream(); + // Also start polling for agent mode (works without push enabled) + if (isAgentMode) { + startAgentDeepScanPolling(); + } + } else { + startQuickScanPolling(); + } + } + } catch (error) { + console.debug('[WiFiMode] Status check failed:', error); + } + } + + // ========================================================================== + // Quick Scan Polling + // ========================================================================== + + function startQuickScanPolling() { + if (pollTimer) return; + + pollTimer = setInterval(async () => { + if (!isScanning || scanMode !== 'quick') { + clearInterval(pollTimer); + pollTimer = null; + return; + } + + try { + const iface = elements.interfaceSelect?.value || null; + const response = await fetch(`${CONFIG.apiBase}/scan/quick`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interface: iface }), + }); + + if (response.ok) { + const result = await response.json(); + processQuickScanResult(result); + } + } catch (error) { + console.debug('[WiFiMode] Poll error:', error); + } + }, CONFIG.pollInterval); + } + + 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) + channelStats = result.channel_stats || []; + 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 }); + + // Callbacks + result.access_points.forEach(ap => { + if (onNetworkUpdate) onNetworkUpdate(ap); + }); + } + + // ========================================================================== + // Agent Deep Scan Polling (fallback when push is not enabled) + // ========================================================================== + + function startAgentDeepScanPolling() { + if (agentPollTimer) return; + + console.log('[WiFiMode] Starting agent deep scan polling...'); + + agentPollTimer = setInterval(async () => { + if (!isScanning || scanMode !== 'deep') { + clearInterval(agentPollTimer); + agentPollTimer = null; + return; + } + + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + if (!isAgentMode) { + clearInterval(agentPollTimer); + agentPollTimer = null; + return; + } + + try { + const response = await fetch(`/controller/agents/${currentAgent}/wifi/data`); + if (!response.ok) return; + + const result = await response.json(); + if (result.status !== 'success' || !result.data) return; + + const data = result.data.data || result.data; + const agentName = result.agent_name || 'Remote'; + + // Process networks + if (data.networks && Array.isArray(data.networks)) { + data.networks.forEach(net => { + net._agent = agentName; + handleStreamEvent({ + type: 'network_update', + network: net + }); + }); + } + + // Process clients + if (data.clients && Array.isArray(data.clients)) { + data.clients.forEach(client => { + client._agent = agentName; + handleStreamEvent({ + type: 'client_update', + client: client + }); + }); + } + + console.debug(`[WiFiMode] Agent poll: ${data.networks?.length || 0} networks, ${data.clients?.length || 0} clients`); + + } catch (error) { + console.debug('[WiFiMode] Agent poll error:', error); + } + }, 2000); // Poll every 2 seconds + } + + function stopAgentDeepScanPolling() { + if (agentPollTimer) { + clearInterval(agentPollTimer); + agentPollTimer = null; + } + } + + // ========================================================================== + // SSE Event Stream + // ========================================================================== + + function startEventStream() { + if (eventSource) { + eventSource.close(); + } + + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const agentName = getCurrentAgentName(); + let streamUrl; + + if (isAgentMode) { + // Use multi-agent stream for remote agents + streamUrl = '/controller/stream/all'; + console.log('[WiFiMode] Starting multi-agent event stream...'); + } else { + streamUrl = `${CONFIG.apiBase}/stream`; + console.log('[WiFiMode] Starting local event stream...'); + } + + eventSource = new EventSource(streamUrl); + + eventSource.onopen = () => { + console.log('[WiFiMode] Event stream connected'); + }; + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + // For multi-agent stream, filter and transform data + if (isAgentMode) { + // Skip keepalive and non-wifi data + if (data.type === 'keepalive') return; + if (data.scan_type !== 'wifi') return; + + // Filter by current agent if not in "show all" mode + if (!showAllAgentsMode && typeof agents !== 'undefined') { + const currentAgentObj = agents.find(a => a.id == currentAgent); + if (currentAgentObj && data.agent_name && data.agent_name !== currentAgentObj.name) { + return; + } + } + + // Transform multi-agent payload to stream event format + if (data.payload && data.payload.networks) { + data.payload.networks.forEach(net => { + net._agent = data.agent_name || 'Unknown'; + handleStreamEvent({ + type: 'network_update', + network: net + }); + }); + } + if (data.payload && data.payload.clients) { + data.payload.clients.forEach(client => { + client._agent = data.agent_name || 'Unknown'; + handleStreamEvent({ + type: 'client_update', + client: client + }); + }); + } + } else { + // Local stream - tag with local + if (data.network) data.network._agent = 'Local'; + if (data.client) data.client._agent = 'Local'; + handleStreamEvent(data); + } + } catch (error) { + console.debug('[WiFiMode] Event parse error:', error); + } + }; + + eventSource.onerror = (error) => { + console.warn('[WiFiMode] Event stream error:', error); + if (isScanning) { + // Attempt to reconnect + setTimeout(() => { + if (isScanning && scanMode === 'deep') { + startEventStream(); + } + }, 3000); + } + }; + } + + function handleStreamEvent(event) { + switch (event.type) { + case 'network_update': + handleNetworkUpdate(event.network); + break; + + case 'client_update': + handleClientUpdate(event.client); + break; + + case 'probe_request': + handleProbeRequest(event.probe); + break; + + case 'hidden_revealed': + handleHiddenRevealed(event.bssid, event.revealed_essid); + break; + + case 'scan_started': + console.log('[WiFiMode] Scan started:', event); + break; + + case 'scan_stopped': + console.log('[WiFiMode] Scan stopped'); + setScanning(false); + break; + + case 'scan_error': + console.error('[WiFiMode] Scan error:', event.error); + showError(event.error); + setScanning(false); + break; + + case 'keepalive': + // Ignore keepalives + break; + + default: + console.debug('[WiFiMode] Unknown event type:', event.type); + } + } + + function handleNetworkUpdate(network) { + networks.set(network.bssid, network); + scheduleRender({ + table: true, + stats: true, + radar: true, + chart: true, + detail: selectedBssid === 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); + } + + function handleProbeRequest(probe) { + probeRequests.push(probe); + if (probeRequests.length > CONFIG.maxProbes) { + probeRequests.shift(); + } + + 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: selectedBssid === 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 setNetworkFilter(filter) { + currentFilter = filter; + + // Update button states + if (elements.networkFilters) { + elements.networkFilters.querySelectorAll('.wifi-filter-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.filter === filter); + }); + } + + renderNetworks(); + } + + function initSortControls() { + if (listenersBound.sort) return; + + document.querySelectorAll('.wifi-sort-btn').forEach(btn => { + btn.addEventListener('click', () => { + const field = btn.dataset.sort; + if (currentSort.field === field) { + currentSort.order = currentSort.order === 'desc' ? 'asc' : 'desc'; + } else { + currentSort.field = field; + currentSort.order = 'desc'; + } + document.querySelectorAll('.wifi-sort-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + scheduleRender({ table: true }); + }); + }); + + 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) renderNetworks(); + if (pendingRender.stats) updateStats(); + if (pendingRender.radar) updateProximityRadar(); + if (pendingRender.chart) updateChannelChart(); + if (pendingRender.detail && selectedBssid) { + updateDetailPanel(selectedBssid, { refreshClients: false }); + } + + pendingRender.table = false; + pendingRender.stats = false; + pendingRender.radar = false; + pendingRender.chart = false; + pendingRender.detail = false; + }); + } + + function renderNetworks() { + if (!elements.networkList) return; + + // Filter networks + let filtered = Array.from(networks.values()); + + switch (currentFilter) { + case 'hidden': + filtered = filtered.filter(n => n.is_hidden); + break; + case 'open': + filtered = filtered.filter(n => n.security === 'Open'); + break; + case 'strong': + filtered = filtered.filter(n => n.rssi_current && n.rssi_current >= -60); + break; + case '2.4': + filtered = filtered.filter(n => n.band === '2.4GHz'); + break; + case '5': + filtered = filtered.filter(n => n.band === '5GHz'); + break; + } + + // Sort networks + filtered.sort((a, b) => { + let aVal, bVal; + + switch (currentSort.field) { + case 'rssi': + aVal = a.rssi_current || -100; + bVal = b.rssi_current || -100; + break; + case 'channel': + aVal = a.channel || 0; + bVal = b.channel || 0; + break; + case 'essid': + aVal = (a.essid || '').toLowerCase(); + bVal = (b.essid || '').toLowerCase(); + break; + case 'clients': + aVal = a.client_count || 0; + bVal = b.client_count || 0; + break; + default: + aVal = a.rssi_current || -100; + bVal = b.rssi_current || -100; + } + + if (currentSort.order === 'desc') { + return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; + } else { + return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; + } + }); + + if (filtered.length === 0) { + let message = networks.size > 0 + ? 'No networks match current filters' + : (isScanning ? 'Scanning for networks...' : 'Start scanning to discover networks'); + elements.networkList.innerHTML = `

${escapeHtml(message)}

`; + return; + } + + // Render list + elements.networkList.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); + + // Re-apply selected state after re-render + if (selectedBssid) { + const sel = elements.networkList.querySelector(`[data-bssid="${CSS.escape(selectedBssid)}"]`); + if (sel) sel.classList.add('selected'); + } + } + + function createNetworkRow(network) { + const rssi = network.rssi_current; + const security = network.security || 'Unknown'; + + // Badge class + const sec = security.toLowerCase(); + const badgeClass = sec === 'open' || sec === '' ? 'open' + : sec.includes('wpa3') ? 'wpa3' + : sec.includes('wpa') ? 'wpa2' + : sec.includes('wep') ? 'wep' + : 'unknown'; + + // Threat class (left border) + const threatClass = badgeClass === 'open' ? 'threat-open' + : badgeClass === 'wpa2' || badgeClass === 'wpa3' ? 'threat-safe' + : 'threat-hidden'; + + // Signal bar width + class + const pct = rssi != null ? Math.max(0, Math.min(100, (rssi + 100) / 80 * 100)) : 0; + const fillClass = rssi == null ? 'weak' : rssi > -55 ? 'strong' : rssi > -70 ? 'medium' : 'weak'; + + const displayName = escapeHtml(network.display_name || network.essid || '[Hidden]'); + const isHidden = network.is_hidden; + const hiddenTag = isHidden ? 'HIDDEN' : ''; + + return ` +
+
+ ${displayName} +
+ ${escapeHtml(security)} + ${hiddenTag} +
+
+
+
+
+
+
+
+
+ ch ${network.channel || '?'} + ${network.client_count || 0} ↔ + ${rssi != null ? rssi : '?'} +
+
+
+ `; + } + + function updateNetworkRow(network) { + scheduleRender({ + table: true, + detail: selectedBssid === network.bssid, + }); + } + + function selectNetwork(bssid) { + selectedBssid = bssid; + + // Update row selection + elements.networkList?.querySelectorAll('.network-row').forEach(row => { + row.classList.toggle('selected', row.dataset.bssid === bssid); + }); + + // Update detail panel + updateDetailPanel(bssid); + + // Highlight on radar + if (typeof WiFiProximityRadar !== 'undefined') { + WiFiProximityRadar.highlightNetwork(bssid); + } + } + + // ========================================================================== + // Detail Panel + // ========================================================================== + + function updateDetailPanel(bssid, options = {}) { + const { refreshClients = true } = options; + if (!elements.detailDrawer) return; + + const network = networks.get(bssid); + if (!network) { + closeDetail(); + return; + } + + // Update drawer header + if (elements.detailEssid) { + elements.detailEssid.textContent = network.display_name || network.essid || '[Hidden SSID]'; + } + if (elements.detailBssid) { + elements.detailBssid.textContent = network.bssid; + } + + // Update detail stats + if (elements.detailRssi) { + elements.detailRssi.textContent = network.rssi_current ? `${network.rssi_current} dBm` : '--'; + } + if (elements.detailChannel) { + elements.detailChannel.textContent = network.channel || '--'; + } + if (elements.detailBand) { + elements.detailBand.textContent = network.band || '--'; + } + if (elements.detailSecurity) { + elements.detailSecurity.textContent = network.security || '--'; + } + if (elements.detailCipher) { + elements.detailCipher.textContent = network.cipher || '--'; + } + if (elements.detailVendor) { + elements.detailVendor.textContent = network.vendor || 'Unknown'; + } + if (elements.detailClients) { + elements.detailClients.textContent = network.client_count || '0'; + } + if (elements.detailFirstSeen) { + elements.detailFirstSeen.textContent = formatTime(network.first_seen); + } + + // Show the drawer + elements.detailDrawer.classList.add('open'); + + // Fetch and display clients for this network + if (refreshClients) { + fetchClientsForNetwork(network.bssid); + } + } + + function closeDetail() { + selectedBssid = null; + if (elements.detailDrawer) { + elements.detailDrawer.classList.remove('open'); + } + elements.networkList?.querySelectorAll('.network-row').forEach(row => { + row.classList.remove('selected'); + }); + } + + // ========================================================================== + // 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; + + if (isAgentMode) { + // Route through agent proxy + response = await fetch(`/controller/agents/${currentAgent}/wifi/v2/clients?bssid=${encodeURIComponent(bssid)}&associated=true`); + } else { + 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; + } + + 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'; + } + } + } + + function renderClientList(clientList, bssid) { + const container = elements.detailClientList?.querySelector('.wifi-client-list'); + const countBadge = document.getElementById('wifiClientCountBadge'); + + if (!container) return; + + // Update count badge + if (countBadge) { + countBadge.textContent = clientList.length; + } + + // Render client cards + container.innerHTML = clientList.map(client => { + const rssi = client.rssi_current; + const signalClass = rssi >= -50 ? 'signal-strong' : + rssi >= -70 ? 'signal-medium' : + rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; + + // Format last seen time + const lastSeen = client.last_seen ? formatTime(client.last_seen) : '--'; + + // Build probed SSIDs badges + let probesHtml = ''; + if (client.probed_ssids && client.probed_ssids.length > 0) { + const probes = client.probed_ssids.slice(0, 5); // Show max 5 + probesHtml = ` +
+ ${probes.map(ssid => `${escapeHtml(ssid)}`).join('')} + ${client.probed_ssids.length > 5 ? `+${client.probed_ssids.length - 5}` : ''} +
+ `; + } + + return ` +
+
+ ${escapeHtml(client.mac)} + ${escapeHtml(client.vendor || 'Unknown vendor')} + ${probesHtml} +
+
+ ${rssi !== null && rssi !== undefined ? rssi + ' dBm' : '--'} + ${lastSeen} +
+
+ `; + }).join(''); + } + + function updateClientInList(client) { + // Check if this client belongs to the currently selected network + if (!selectedBssid || client.associated_bssid !== selectedBssid) { + return; + } + + const container = elements.detailClientList?.querySelector('.wifi-client-list'); + if (!container) return; + + const existingCard = container.querySelector(`[data-mac="${client.mac}"]`); + + if (existingCard) { + // Update existing card's RSSI and last seen + const rssiEl = existingCard.querySelector('.wifi-client-rssi'); + const lastSeenEl = existingCard.querySelector('.wifi-client-lastseen'); + + if (rssiEl && client.rssi_current !== null && client.rssi_current !== undefined) { + const rssi = client.rssi_current; + const signalClass = rssi >= -50 ? 'signal-strong' : + rssi >= -70 ? 'signal-medium' : + rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; + rssiEl.textContent = rssi + ' dBm'; + rssiEl.className = 'wifi-client-rssi ' + signalClass; + } + + if (lastSeenEl && client.last_seen) { + lastSeenEl.textContent = formatTime(client.last_seen); + } + } else { + // New client for this network - re-fetch the full list + fetchClientsForNetwork(selectedBssid); + } + } + + // ========================================================================== + // Statistics + // ========================================================================== + + function updateStats() { + const networksList = Array.from(networks.values()); + + // Update counts in status bar + if (elements.networkCount) { + elements.networkCount.textContent = networks.size; + } + if (elements.clientCount) { + elements.clientCount.textContent = clients.size; + } + if (elements.hiddenCount) { + const hidden = networksList.filter(n => n.is_hidden).length; + elements.hiddenCount.textContent = hidden; + } + + // Update security counts + const securityCounts = { wpa3: 0, wpa2: 0, wep: 0, open: 0 }; + networksList.forEach(n => { + const sec = (n.security || '').toLowerCase(); + if (sec.includes('wpa3')) securityCounts.wpa3++; + else if (sec.includes('wpa2') || sec.includes('wpa')) securityCounts.wpa2++; + else if (sec.includes('wep')) securityCounts.wep++; + else if (sec === 'open' || sec === '') securityCounts.open++; + }); + + if (elements.openCount) elements.openCount.textContent = securityCounts.open; + + // Update zone summary + const zoneCounts = { immediate: 0, near: 0, far: 0 }; + networksList.forEach(n => { + const rssi = n.rssi_current; + if (rssi >= -50) zoneCounts.immediate++; + else if (rssi >= -70) zoneCounts.near++; + else zoneCounts.far++; + }); + + if (elements.zoneImmediate) elements.zoneImmediate.textContent = zoneCounts.immediate; + if (elements.zoneNear) elements.zoneNear.textContent = zoneCounts.near; + if (elements.zoneFar) elements.zoneFar.textContent = zoneCounts.far; + } + + // ========================================================================== + // Proximity Radar + // ========================================================================== + + function initProximityRadar() { + if (!elements.proximityRadar) return; + + // Initialize radar component + if (typeof ProximityRadar !== 'undefined') { + ProximityRadar.init('wifiProximityRadar', { + mode: 'wifi', + size: 280, + onDeviceClick: (bssid) => selectNetwork(bssid), + }); + } + } + + function updateProximityRadar() { + if (typeof ProximityRadar === 'undefined') return; + + // Convert networks to radar-compatible format + const devices = Array.from(networks.values()).map(n => ({ + device_key: n.bssid, + device_id: n.bssid, + name: n.essid || '[Hidden]', + rssi_current: n.rssi_current, + rssi_ema: n.rssi_ema, + proximity_band: n.proximity_band, + estimated_distance_m: n.estimated_distance_m, + is_new: n.is_new, + heuristic_flags: n.heuristic_flags || [], + })); + + ProximityRadar.updateDevices(devices); + } + + // ========================================================================== + // Channel Chart + // ========================================================================== + + function initChannelChart() { + if (!elements.channelChart) return; + + // Initialize channel chart component + if (typeof ChannelChart !== 'undefined') { + ChannelChart.init('wifiChannelChart'); + } + + // Band tabs + if (elements.channelBandTabs) { + elements.channelBandTabs.addEventListener('click', (e) => { + if (e.target.matches('.channel-band-tab')) { + const band = e.target.dataset.band; + elements.channelBandTabs.querySelectorAll('.channel-band-tab').forEach(t => { + t.classList.toggle('active', t.dataset.band === band); + }); + updateChannelChart(band); + } + }); + } + } + + function calculateChannelStats() { + // Calculate channel stats from current networks + const stats = {}; + const networksList = Array.from(networks.values()); + + // Initialize all channels + // 2.4 GHz: channels 1-13 + for (let ch = 1; ch <= 13; ch++) { + stats[ch] = { channel: ch, band: '2.4GHz', ap_count: 0, client_count: 0, utilization_score: 0 }; + } + // 5 GHz: common channels + [36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165].forEach(ch => { + stats[ch] = { channel: ch, band: '5GHz', ap_count: 0, client_count: 0, utilization_score: 0 }; + }); + + // Count APs per channel + networksList.forEach(net => { + const ch = parseInt(net.channel); + if (stats[ch]) { + stats[ch].ap_count++; + stats[ch].client_count += (net.client_count || 0); + } + }); + + // Calculate utilization score (0-1) + const maxAPs = Math.max(1, ...Object.values(stats).map(s => s.ap_count)); + Object.values(stats).forEach(s => { + s.utilization_score = s.ap_count / maxAPs; + }); + + return Object.values(stats).filter(s => s.ap_count > 0 || [1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161, 165].includes(s.channel)); + } + + function updateChannelChart(band) { + if (typeof ChannelChart === 'undefined') return; + + // Use the currently active band tab if no band specified + if (!band) { + const activeTab = elements.channelBandTabs && elements.channelBandTabs.querySelector('.channel-band-tab.active'); + band = activeTab ? activeTab.dataset.band : '2.4'; + } + + // Recalculate channel stats from networks if needed + if (channelStats.length === 0 && networks.size > 0) { + channelStats = calculateChannelStats(); + } + + // Filter stats by band + const bandFilter = band === '2.4' ? '2.4GHz' : band === '5' ? '5GHz' : '6GHz'; + const filteredStats = channelStats.filter(s => s.band === bandFilter); + const filteredRecs = recommendations.filter(r => r.band === bandFilter); + + ChannelChart.update(filteredStats, filteredRecs); + } + + // ========================================================================== + // Export + // ========================================================================== + + async function exportData(format) { + try { + const response = await fetch(`${CONFIG.apiBase}/export?format=${format}&type=all`); + if (!response.ok) throw new Error('Export failed'); + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `wifi_scan_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + console.error('[WiFiMode] Export error:', error); + showError('Export failed: ' + error.message); + } + } + + // ========================================================================== + // Utilities + // ========================================================================== + + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function formatTime(isoString) { + if (!isoString) return '-'; + const date = new Date(isoString); + return date.toLocaleTimeString(); + } + + function showError(message) { + // Use global notification if available + if (typeof showNotification === 'function') { + showNotification('WiFi Error', message, 'error'); + } else { + console.error('[WiFiMode]', message); + } + } + + function showInfo(message) { + if (typeof showNotification === 'function') { + showNotification('WiFi', message, 'info'); + } else { + console.log('[WiFiMode]', message); + } + } + + // ========================================================================== + // Agent Handling + // ========================================================================== + + /** + * Handle agent change - refresh interfaces and optionally clear data. + * Called when user selects a different agent. + */ + function handleAgentChange() { + const currentAgentId = typeof currentAgent !== 'undefined' ? currentAgent : 'local'; + + // Check if agent actually changed + if (lastAgentId === currentAgentId) return; + + console.log('[WiFiMode] Agent changed from', lastAgentId, 'to', currentAgentId); + + // Stop UI polling only - don't stop the actual scan on the agent + // The agent should continue running independently + if (isScanning) { + stopAgentDeepScanPolling(); + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + if (eventSource) { + eventSource.close(); + eventSource = null; + } + setScanning(false); + } + + // Clear existing data when switching agents (unless "Show All" is enabled) + if (!showAllAgentsMode) { + clearData(); + showInfo(`Switched to ${getCurrentAgentName()} - previous data cleared`); + } + + // Refresh capabilities for new agent + checkCapabilities(); + + // Check if new agent already has a scan running + checkScanStatus(); + + lastAgentId = currentAgentId; + } + + /** + * Clear all collected data. + */ + function clearData() { + networks.clear(); + clients.clear(); + probeRequests = []; + channelStats = []; + recommendations = []; + if (selectedBssid) { + closeDetail(); + } + scheduleRender({ table: true, stats: true, radar: true, chart: true }); + } + + /** + * Toggle "Show All Agents" mode. + * When enabled, displays combined WiFi results from all agents. + */ + function toggleShowAllAgents(enabled) { + showAllAgentsMode = enabled; + console.log('[WiFiMode] Show all agents mode:', enabled); + + if (enabled) { + // If currently scanning, switch to multi-agent stream + if (isScanning && eventSource) { + eventSource.close(); + startEventStream(); + } + showInfo('Showing WiFi networks from all agents'); + } else { + // Filter to current agent only + filterToCurrentAgent(); + } + } + + /** + * Filter networks to only show those from current agent. + */ + function filterToCurrentAgent() { + const agentName = getCurrentAgentName(); + const toRemove = []; + + networks.forEach((network, bssid) => { + if (network._agent && network._agent !== agentName) { + toRemove.push(bssid); + } + }); + + toRemove.forEach(bssid => networks.delete(bssid)); + + // Also filter clients + const clientsToRemove = []; + clients.forEach((client, mac) => { + if (client._agent && client._agent !== agentName) { + clientsToRemove.push(mac); + } + }); + clientsToRemove.forEach(mac => clients.delete(mac)); + if (selectedBssid && !networks.has(selectedBssid)) { + closeDetail(); + } + scheduleRender({ table: true, stats: true, radar: true, chart: true }); + } + + /** + * Refresh WiFi interfaces from current agent. + * Called when agent changes. + */ + async function refreshInterfaces() { + await checkCapabilities(); + } + + // ========================================================================== + // Public API + // ========================================================================== + + return { + init, + startQuickScan, + startDeepScan, + stopScan, + selectNetwork, + closeDetail, + setFilter: setNetworkFilter, + exportData, + checkCapabilities, + + // Agent handling + handleAgentChange, + clearData, + toggleShowAllAgents, + refreshInterfaces, + + // Getters + getNetworks: () => Array.from(networks.values()), + getClients: () => Array.from(clients.values()), + getProbes: () => [...probeRequests], + isScanning: () => isScanning, + getScanMode: () => scanMode, + isShowAllAgents: () => showAllAgentsMode, + + // Callbacks + 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 +document.addEventListener('DOMContentLoaded', () => { + // Only init if we're in WiFi mode + if (typeof currentMode !== 'undefined' && currentMode === 'wifi') { + WiFiMode.init(); + } +});