diff --git a/static/css/index.css b/static/css/index.css index e8a03b2..ccb6642 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -3586,6 +3586,7 @@ header h1 .tagline { .wifi-networks-table-wrapper { flex: 1; overflow-y: auto; + overscroll-behavior: contain; } .wifi-networks-table { @@ -3694,6 +3695,22 @@ header h1 .tagline { color: var(--text-dim); } +.app-collection-state-row td { + text-align: center; + padding: 0; +} + +.app-collection-state { + color: var(--text-dim); + padding: 16px 12px; + font-size: 11px; + text-align: center; +} + +.app-collection-state.is-loading { + color: var(--accent-cyan); +} + /* WiFi Radar Panel (CENTER) */ .wifi-radar-panel { display: flex; @@ -4082,14 +4099,14 @@ header h1 .tagline { display: flex; gap: 12px; flex: 1; - min-height: 380px; + min-height: 420px; } .bt-side-panels { display: flex; flex-direction: column; gap: 12px; - width: 240px; + width: 300px; flex-shrink: 0; } @@ -4097,6 +4114,21 @@ header h1 .tagline { flex: 1; min-height: 0; overflow: hidden; + display: flex; + flex-direction: column; +} + +.bt-tracker-panel h5 { + margin-bottom: 8px; +} + +.bt-tracker-list { + font-size: 11px; + flex: 1; + min-height: 0; + overflow-y: auto; + padding-right: 2px; + overscroll-behavior: contain; } .bt-radar-panel { @@ -4603,6 +4635,7 @@ header h1 .tagline { min-height: 0; padding: 8px 10px 12px; background: var(--bg-primary); + overscroll-behavior: contain; } .bt-device-list .wifi-device-list-header { @@ -4661,6 +4694,44 @@ header h1 .tagline { text-overflow: ellipsis; } +.bt-list-signal-strip { + padding: 8px 12px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-primary); +} + +.bt-list-signal-title { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.45px; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 6px; +} + +.bt-signal-dist-compact { + gap: 6px; + padding: 0; +} + +.bt-signal-dist-compact .signal-range { + gap: 8px; +} + +.bt-signal-dist-compact .signal-range span:first-child { + width: 50px; + font-size: 9px; +} + +.bt-signal-dist-compact .signal-range span:last-child { + width: 22px; + font-size: 10px; +} + +.bt-signal-dist-compact .signal-bar-bg { + height: 10px; +} + .bt-device-toolbar { padding: 8px 12px; border-bottom: 1px solid var(--border-color); @@ -4719,13 +4790,111 @@ header h1 .tagline { } .bt-tracker-item { + padding: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); transition: background 0.15s ease; + cursor: pointer; } .bt-tracker-item:hover { background: rgba(239, 68, 68, 0.08); } +.bt-tracker-item:focus-visible { + outline: 1px solid var(--accent-cyan); + outline-offset: -1px; +} + +.bt-tracker-row-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.bt-tracker-left, +.bt-tracker-right { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.bt-tracker-confidence { + font-size: 9px; + padding: 2px 5px; + border-radius: 3px; + font-weight: 700; + letter-spacing: 0.2px; +} + +.bt-tracker-confidence-high .bt-tracker-confidence { + color: #ef4444; + background: rgba(239, 68, 68, 0.2); +} + +.bt-tracker-confidence-medium .bt-tracker-confidence { + color: #f97316; + background: rgba(249, 115, 22, 0.2); +} + +.bt-tracker-confidence-low .bt-tracker-confidence { + color: #eab308; + background: rgba(234, 179, 8, 0.2); +} + +.bt-tracker-type { + font-size: 11px; + color: var(--text-primary); + font-weight: 500; +} + +.bt-tracker-risk { + font-size: 9px; + font-weight: 700; +} + +.bt-risk-high { + color: #ef4444; +} + +.bt-risk-medium { + color: #f97316; +} + +.bt-risk-low { + color: var(--text-dim); +} + +.bt-tracker-rssi, +.bt-tracker-seen { + font-size: 10px; + color: var(--text-dim); +} + +.bt-tracker-row-bottom { + display: flex; + justify-content: space-between; + margin-top: 3px; + gap: 10px; +} + +.bt-tracker-address { + font-size: 9px; + color: var(--text-dim); + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bt-tracker-evidence { + margin-top: 3px; + font-size: 9px; + color: var(--text-dim); + font-style: italic; +} + /* Bluetooth Signal Distribution */ .bt-signal-dist { display: flex; @@ -4804,6 +4973,11 @@ header h1 .tagline { border-color: var(--accent-cyan); } +.bt-device-row:focus-visible { + outline: 1px solid var(--accent-cyan); + outline-offset: 1px; +} + .bt-row-main { display: flex; justify-content: space-between; @@ -4941,6 +5115,10 @@ header h1 .tagline { padding: 4px 4px 0 42px; } +.bt-device-filter-state { + margin-top: 8px; +} + /* Bluetooth Device Modal */ .bt-modal-overlay { position: fixed; @@ -5159,6 +5337,14 @@ header h1 .tagline { min-height: 0; } + .bt-side-panels { + width: 100%; + } + + .bt-tracker-list { + max-height: 280px; + } + .bt-device-list { width: 100%; min-width: auto; diff --git a/static/js/core/command-palette.js b/static/js/core/command-palette.js index 8f8e1c2..0938480 100644 --- a/static/js/core/command-palette.js +++ b/static/js/core/command-palette.js @@ -8,7 +8,7 @@ const CommandPalette = (function() { let activeIndex = 0; let filteredItems = []; - const modeCommands = [ + const fallbackModeCommands = [ { mode: 'pager', label: 'Pager' }, { mode: 'sensor', label: '433MHz Sensors' }, { mode: 'rtlamr', label: 'Meters' }, @@ -30,6 +30,38 @@ const CommandPalette = (function() { { mode: 'spaceweather', label: 'Space Weather' }, ]; + function getModeCommands() { + const commands = []; + const seenModes = new Set(); + + const catalog = window.interceptModeCatalog; + if (catalog && typeof catalog === 'object') { + for (const [mode, meta] of Object.entries(catalog)) { + if (!mode || seenModes.has(mode)) continue; + const label = String((meta && meta.label) || mode).trim(); + commands.push({ mode, label }); + seenModes.add(mode); + } + if (commands.length > 0) return commands; + } + + const navNodes = document.querySelectorAll('.mode-nav-btn[data-mode], .mobile-nav-btn[data-mode]'); + navNodes.forEach((node) => { + if (node.tagName === 'A') { + const href = String(node.getAttribute('href') || ''); + if (href.includes('/dashboard')) return; + } + const mode = String(node.dataset.mode || '').trim(); + if (!mode || seenModes.has(mode)) return; + const label = String(node.dataset.modeLabel || node.textContent || mode).trim(); + commands.push({ mode, label }); + seenModes.add(mode); + }); + if (commands.length > 0) return commands; + + return fallbackModeCommands.slice(); + } + function init() { buildDOM(); registerHotkeys(); @@ -189,7 +221,7 @@ const CommandPalette = (function() { }, ]; - for (const modeEntry of modeCommands) { + for (const modeEntry of getModeCommands()) { commands.push({ title: `Switch Mode: ${modeEntry.label}`, description: 'Navigate directly to mode', diff --git a/static/js/core/run-state.js b/static/js/core/run-state.js index 900f27f..468b9a1 100644 --- a/static/js/core/run-state.js +++ b/static/js/core/run-state.js @@ -3,6 +3,12 @@ const RunState = (function() { const REFRESH_MS = 5000; const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'dmr', 'subghz']; + const MODE_ALIASES = { + bt: 'bluetooth', + bt_locate: 'bluetooth', + btlocate: 'bluetooth', + aircraft: 'adsb', + }; const modeLabels = { pager: 'Pager', @@ -69,7 +75,7 @@ const RunState = (function() { const original = window.switchMode; const wrapped = function(mode) { if (mode) { - activeMode = String(mode); + activeMode = normalizeMode(String(mode)); } const result = original.apply(this, arguments); markActiveChip(); @@ -110,7 +116,7 @@ const RunState = (function() { return; } - const processes = data.processes || {}; + const processes = normalizeProcesses(data.processes || {}); for (const mode of CHIP_MODES) { const isRunning = Boolean(processes[mode]); chipsContainer.appendChild(buildChip(modeLabels[mode] || mode.toUpperCase(), isRunning, mode)); @@ -146,7 +152,7 @@ const RunState = (function() { document.querySelectorAll('#runStateChips .run-state-chip').forEach((chip) => { chip.classList.remove('active'); - if (chip.dataset.mode && chip.dataset.mode === activeMode) { + if (chip.dataset.mode && chip.dataset.mode === normalizeMode(activeMode)) { chip.classList.add('active'); } }); @@ -154,7 +160,11 @@ const RunState = (function() { function inferCurrentMode() { const modeParam = new URLSearchParams(window.location.search).get('mode'); - if (modeParam) return modeParam; + if (modeParam) return normalizeMode(modeParam); + + if (typeof window.currentMode === 'string' && window.currentMode) { + return normalizeMode(window.currentMode); + } const indicator = document.getElementById('activeModeIndicator'); if (!indicator) return 'pager'; @@ -163,6 +173,7 @@ const RunState = (function() { const normalized = text.toLowerCase(); if (normalized.includes('wifi')) return 'wifi'; if (normalized.includes('bluetooth')) return 'bluetooth'; + if (normalized.includes('bt locate')) return 'bluetooth'; if (normalized.includes('ads-b')) return 'adsb'; if (normalized.includes('ais')) return 'ais'; if (normalized.includes('acars')) return 'acars'; @@ -175,6 +186,29 @@ const RunState = (function() { return 'pager'; } + function normalizeMode(mode) { + const value = String(mode || '').trim().toLowerCase(); + if (!value) return 'pager'; + return MODE_ALIASES[value] || value; + } + + function normalizeProcesses(raw) { + const processes = Object.assign({}, raw || {}); + processes.bluetooth = Boolean( + processes.bluetooth || + processes.bt || + processes.bt_scan || + processes.btlocate || + processes.bt_locate + ); + processes.wifi = Boolean( + processes.wifi || + processes.wifi_scan || + processes.wlan + ); + return processes; + } + function extractMessage(err) { if (!err) return 'Unknown error'; if (typeof err === 'string') return err; diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js index 85c3ea0..fe9561d 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -435,10 +435,16 @@ const Settings = { }; // Settings modal functions +let lastSettingsFocusEl = null; + function showSettings() { const modal = document.getElementById('settingsModal'); if (modal) { + lastSettingsFocusEl = document.activeElement; modal.classList.add('active'); + modal.setAttribute('aria-hidden', 'false'); + const content = modal.querySelector('.settings-content'); + if (content) content.focus(); Settings.init().then(() => { Settings.checkAssets(); }); @@ -449,18 +455,27 @@ function hideSettings() { const modal = document.getElementById('settingsModal'); if (modal) { modal.classList.remove('active'); + modal.setAttribute('aria-hidden', 'true'); + if (lastSettingsFocusEl && typeof lastSettingsFocusEl.focus === 'function') { + lastSettingsFocusEl.focus(); + } } } function switchSettingsTab(tabName) { // Update tab buttons document.querySelectorAll('.settings-tab').forEach(tab => { - tab.classList.toggle('active', tab.dataset.tab === tabName); + const isActive = tab.dataset.tab === tabName; + tab.classList.toggle('active', isActive); + tab.setAttribute('aria-selected', isActive ? 'true' : 'false'); }); // Update sections document.querySelectorAll('.settings-section').forEach(section => { - section.classList.toggle('active', section.id === `settings-${tabName}`); + const isActive = section.id === `settings-${tabName}`; + section.classList.toggle('active', isActive); + section.hidden = !isActive; + section.setAttribute('role', 'tabpanel'); }); // Load tools/dependencies when that tab is selected @@ -560,6 +575,7 @@ function loadSettingsTools() { // Initialize settings on page load document.addEventListener('DOMContentLoaded', () => { Settings.init(); + switchSettingsTab('offline'); }); // ============================================================================= @@ -919,12 +935,17 @@ const _originalSwitchSettingsTab = typeof switchSettingsTab !== 'undefined' ? sw function switchSettingsTab(tabName) { // Update tab buttons document.querySelectorAll('.settings-tab').forEach(tab => { - tab.classList.toggle('active', tab.dataset.tab === tabName); + const isActive = tab.dataset.tab === tabName; + tab.classList.toggle('active', isActive); + tab.setAttribute('aria-selected', isActive ? 'true' : 'false'); }); // Update sections document.querySelectorAll('.settings-section').forEach(section => { - section.classList.toggle('active', section.id === `settings-${tabName}`); + const isActive = section.id === `settings-${tabName}`; + section.classList.toggle('active', isActive); + section.hidden = !isActive; + section.setAttribute('role', 'tabpanel'); }); // Load content based on tab @@ -1026,3 +1047,14 @@ function setAnimationsEnabled(enabled) { } localStorage.setItem('intercept-animations', enabled ? 'on' : 'off'); } + +if (!window._settingsEscapeHandlerBound) { + window._settingsEscapeHandlerBound = true; + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + const modal = document.getElementById('settingsModal'); + if (modal && modal.classList.contains('active')) { + hideSettings(); + } + }); +} diff --git a/static/js/core/ui-feedback.js b/static/js/core/ui-feedback.js index 4132888..c39d53f 100644 --- a/static/js/core/ui-feedback.js +++ b/static/js/core/ui-feedback.js @@ -177,6 +177,37 @@ const AppFeedback = (function() { return text.includes('script error') || text.includes('resizeobserver loop limit exceeded'); } + function renderCollectionState(container, options) { + if (!container) return null; + const opts = options || {}; + const type = String(opts.type || 'empty').toLowerCase(); + const message = String(opts.message || (type === 'loading' ? 'Loading...' : 'No data available')); + const className = opts.className || `app-collection-state is-${type}`; + + container.innerHTML = ''; + + if (container.tagName === 'TBODY') { + const row = document.createElement('tr'); + row.className = 'app-collection-state-row'; + const cell = document.createElement('td'); + const columns = Number.isFinite(opts.columns) ? opts.columns : 1; + cell.colSpan = Math.max(1, columns); + const state = document.createElement('div'); + state.className = className; + state.textContent = message; + cell.appendChild(state); + row.appendChild(cell); + container.appendChild(row); + return row; + } + + const state = document.createElement('div'); + state.className = className; + state.textContent = message; + container.appendChild(state); + return state; + } + function isNetworkError(message) { const text = String(message || '').toLowerCase(); return text.includes('networkerror') || text.includes('failed to fetch') || text.includes('timeout'); @@ -192,6 +223,7 @@ const AppFeedback = (function() { toast, reportError, removeToast, + renderCollectionState, }; })(); @@ -207,6 +239,10 @@ window.reportActionableError = function(context, error, options) { return AppFeedback.reportError(context, error, options); }; +window.renderCollectionState = function(container, options) { + return AppFeedback.renderCollectionState(container, options); +}; + document.addEventListener('DOMContentLoaded', () => { AppFeedback.init(); }); diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index c6aa57b..e791063 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -38,6 +38,11 @@ const BluetoothMode = (function() { let currentDeviceFilter = 'all'; let currentSearchTerm = ''; let visibleDeviceCount = 0; + let pendingDeviceFlush = false; + let selectedDeviceNeedsRefresh = false; + let filterListenersBound = false; + let listListenersBound = false; + const pendingDeviceIds = new Set(); // Agent support let showAllAgentsMode = false; @@ -111,8 +116,9 @@ const BluetoothMode = (function() { // Initialize legacy heatmap (zone counts) initHeatmap(); - // Initialize device list filters - initDeviceFilters(); + // Initialize device list filters + initDeviceFilters(); + initListInteractions(); // Set initial panel states updateVisualizationPanels(); @@ -122,6 +128,7 @@ const BluetoothMode = (function() { * Initialize device list filter buttons */ function initDeviceFilters() { + if (filterListenersBound) return; const filterContainer = document.getElementById('btDeviceFilters'); if (filterContainer) { filterContainer.addEventListener('click', (e) => { @@ -148,6 +155,35 @@ const BluetoothMode = (function() { applyDeviceFilter(); }); } + filterListenersBound = true; + } + + function initListInteractions() { + if (listListenersBound) return; + if (deviceContainer) { + deviceContainer.addEventListener('click', (event) => { + const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]'); + if (locateBtn) { + event.preventDefault(); + locateById(locateBtn.dataset.locateId); + return; + } + + const row = event.target.closest('.bt-device-row[data-bt-device-id]'); + if (!row) return; + selectDevice(row.dataset.btDeviceId); + }); + } + + const trackerList = document.getElementById('btTrackerList'); + if (trackerList) { + trackerList.addEventListener('click', (event) => { + const row = event.target.closest('.bt-tracker-item[data-device-id]'); + if (!row) return; + selectDevice(row.dataset.deviceId); + }); + } + listListenersBound = true; } /** @@ -192,6 +228,18 @@ const BluetoothMode = (function() { visibleDeviceCount = visibleCount; + let stateEl = deviceContainer.querySelector('.bt-device-filter-state'); + if (visibleCount === 0 && devices.size > 0) { + if (!stateEl) { + stateEl = document.createElement('div'); + stateEl.className = 'bt-device-filter-state app-collection-state is-empty'; + deviceContainer.appendChild(stateEl); + } + stateEl.textContent = 'No devices match current filters'; + } else if (stateEl) { + stateEl.remove(); + } + // Update visible count updateFilteredCount(); } @@ -915,14 +963,25 @@ const BluetoothMode = (function() { function setScanning(scanning) { isScanning = scanning; - if (startBtn) startBtn.style.display = scanning ? 'none' : 'block'; - if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none'; - - if (scanning && deviceContainer) { - deviceContainer.innerHTML = ''; - devices.clear(); - resetStats(); - } + if (startBtn) startBtn.style.display = scanning ? 'none' : 'block'; + if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none'; + + if (scanning && deviceContainer) { + pendingDeviceIds.clear(); + selectedDeviceNeedsRefresh = false; + pendingDeviceFlush = false; + if (typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'loading', message: 'Scanning for Bluetooth devices...' }); + } else { + deviceContainer.innerHTML = ''; + } + devices.clear(); + resetStats(); + } else if (!scanning && deviceContainer && devices.size === 0) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' }); + } + } const statusDot = document.getElementById('statusDot'); const statusText = document.getElementById('statusText'); @@ -1087,17 +1146,43 @@ const BluetoothMode = (function() { }, pollInterval); } - function handleDeviceUpdate(device) { - devices.set(device.device_id, device); - renderDevice(device); - updateDeviceCount(); - updateStatsFromDevices(); - updateVisualizationPanels(); - updateProximityZones(); - - // Update new proximity radar - updateRadar(); - } + function handleDeviceUpdate(device) { + devices.set(device.device_id, device); + pendingDeviceIds.add(device.device_id); + if (selectedDeviceId === device.device_id) { + selectedDeviceNeedsRefresh = true; + } + scheduleDeviceFlush(); + } + + function scheduleDeviceFlush() { + if (pendingDeviceFlush) return; + pendingDeviceFlush = true; + + requestAnimationFrame(() => { + pendingDeviceFlush = false; + + pendingDeviceIds.forEach((deviceId) => { + const device = devices.get(deviceId); + if (device) { + renderDevice(device, false); + } + }); + pendingDeviceIds.clear(); + + applyDeviceFilter(); + updateDeviceCount(); + updateStatsFromDevices(); + updateVisualizationPanels(); + updateProximityZones(); + updateRadar(); + + if (selectedDeviceNeedsRefresh && selectedDeviceId && devices.has(selectedDeviceId)) { + showDeviceDetail(selectedDeviceId); + } + selectedDeviceNeedsRefresh = false; + }); + } /** * Update stats from all devices @@ -1171,83 +1256,83 @@ const BluetoothMode = (function() { // Tracker Detection - Enhanced display with confidence and evidence const trackerList = document.getElementById('btTrackerList'); - if (trackerList) { - if (devices.size === 0) { - trackerList.innerHTML = '
Start scanning to detect trackers
'; - } else if (deviceStats.trackers.length === 0) { - trackerList.innerHTML = '
No trackers detected
'; - } else { - // Sort by risk score (highest first), then confidence - const sortedTrackers = [...deviceStats.trackers].sort((a, b) => { - const riskA = a.risk_score || 0; - const riskB = b.risk_score || 0; - if (riskB !== riskA) return riskB - riskA; - const confA = a.tracker_confidence_score || 0; - const confB = b.tracker_confidence_score || 0; - return confB - confA; - }); - - trackerList.innerHTML = sortedTrackers.map(t => { - // Get tracker type badge color based on confidence - const confidence = t.tracker_confidence || 'low'; - const confColor = confidence === 'high' ? '#ef4444' : - confidence === 'medium' ? '#f97316' : '#eab308'; - const confBg = confidence === 'high' ? 'rgba(239,68,68,0.2)' : - confidence === 'medium' ? 'rgba(249,115,22,0.2)' : 'rgba(234,179,8,0.2)'; - - // Risk score indicator - const riskScore = t.risk_score || 0; - const riskColor = riskScore >= 0.5 ? '#ef4444' : riskScore >= 0.3 ? '#f97316' : '#666'; - - // Tracker type label - const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker'; - - // Build evidence tooltip (first 2 items) - const evidence = (t.tracker_evidence || []).slice(0, 2); - const evidenceHtml = evidence.length > 0 - ? '
' + - evidence.map(e => '• ' + escapeHtml(e)).join('
') + - '
' - : ''; - - const deviceIdEscaped = escapeHtml(t.device_id).replace(/'/g, "\\'"); - - return '
' + - '
' + - '
' + - '' + confidence.toUpperCase() + '' + - '' + escapeHtml(trackerType) + '' + - '
' + - '
' + - (riskScore >= 0.3 ? 'RISK ' + Math.round(riskScore * 100) + '%' : '') + - '' + (t.rssi_current || '--') + ' dBm' + - '
' + - '
' + - '
' + - '' + (t.address_type === 'uuid' ? formatAddress(t) : t.address) + '' + - 'Seen ' + (t.seen_count || 0) + 'x' + - '
' + - evidenceHtml + - '
'; - }).join(''); - } - } - - } + if (trackerList) { + if (devices.size === 0) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(trackerList, { type: 'empty', message: 'Start scanning to detect trackers' }); + } else { + trackerList.innerHTML = '
Start scanning to detect trackers
'; + } + } else if (deviceStats.trackers.length === 0) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(trackerList, { type: 'empty', message: 'No trackers detected' }); + } else { + trackerList.innerHTML = '
No trackers detected
'; + } + } else { + // Sort by risk score (highest first), then confidence + const sortedTrackers = [...deviceStats.trackers].sort((a, b) => { + const riskA = a.risk_score || 0; + const riskB = b.risk_score || 0; + if (riskB !== riskA) return riskB - riskA; + const confA = a.tracker_confidence_score || 0; + const confB = b.tracker_confidence_score || 0; + return confB - confA; + }); + + trackerList.innerHTML = sortedTrackers.map((t) => { + const confidence = t.tracker_confidence || 'low'; + const riskScore = t.risk_score || 0; + const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker'; + const evidence = (t.tracker_evidence || []).slice(0, 2); + const evidenceHtml = evidence.length > 0 + ? `
${evidence.map((e) => `• ${escapeHtml(e)}`).join('
')}
` + : ''; + const riskClass = riskScore >= 0.5 ? 'high' : riskScore >= 0.3 ? 'medium' : 'low'; + const riskHtml = riskScore >= 0.3 + ? `RISK ${Math.round(riskScore * 100)}%` + : ''; + + return ` +
+
+
+ ${escapeHtml(confidence.toUpperCase())} + ${escapeHtml(trackerType)} +
+
+ ${riskHtml} + ${t.rssi_current != null ? t.rssi_current : '--'} dBm +
+
+
+ ${escapeHtml(t.address_type === 'uuid' ? formatAddress(t) : (t.address || '--'))} + Seen ${t.seen_count || 0}x +
+ ${evidenceHtml} +
+ `; + }).join(''); + } + } + + } function updateDeviceCount() { updateFilteredCount(); } - function renderDevice(device) { + function renderDevice(device, reapplyFilter = true) { if (!deviceContainer) { deviceContainer = document.getElementById('btDeviceListContent'); if (!deviceContainer) return; } - - const escapedId = CSS.escape(device.device_id); - const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]'); - const cardHtml = createSimpleDeviceCard(device); + + deviceContainer.querySelectorAll('.app-collection-state, .bt-device-filter-state').forEach((el) => el.remove()); + + const escapedId = CSS.escape(device.device_id); + const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]'); + const cardHtml = createSimpleDeviceCard(device); if (existingCard) { existingCard.outerHTML = cardHtml; @@ -1255,8 +1340,9 @@ const BluetoothMode = (function() { deviceContainer.insertAdjacentHTML('afterbegin', cardHtml); } - // Re-apply filter after rendering - applyDeviceFilter(); + if (reapplyFilter) { + applyDeviceFilter(); + } } function createSimpleDeviceCard(device) { @@ -1277,12 +1363,11 @@ const BluetoothMode = (function() { // RSSI typically ranges from -100 (weak) to -30 (very strong) const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0; - const displayName = device.name || formatDeviceId(device.address); - const name = escapeHtml(displayName); - const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown')); + const displayName = device.name || formatDeviceId(device.address); + const name = escapeHtml(displayName); + const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown')); const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; const seenCount = device.seen_count || 0; - const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'"); const searchIndex = [ displayName, device.address, @@ -1373,14 +1458,14 @@ const BluetoothMode = (function() { } const secondaryInfo = secondaryParts.join(' · '); - // Row border color - highlight trackers in red/orange - const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' : - isTracker ? '#f97316' : rssiColor; - - return '
' + - '
' + - '
' + - protoBadge + + // Row border color - highlight trackers in red/orange + const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' : + isTracker ? '#f97316' : rssiColor; + + return '
' + + '
' + + '
' + + protoBadge + '' + name + '' + trackerBadge + irkBadge + @@ -1395,13 +1480,13 @@ const BluetoothMode = (function() { '
' + statusDot + '
' + - '
' + - '
' + secondaryInfo + '
' + - '
' + - '' + - '
' + + '
' + + '
' + secondaryInfo + '
' + + '
' + + '' + + '
' + '
'; } @@ -1532,18 +1617,22 @@ const BluetoothMode = (function() { /** * Clear all collected data. */ - function clearData() { - devices.clear(); - resetStats(); - - if (deviceContainer) { - deviceContainer.innerHTML = ''; - } - - updateDeviceCount(); - updateProximityZones(); - updateRadar(); - } + function clearData() { + devices.clear(); + pendingDeviceIds.clear(); + pendingDeviceFlush = false; + selectedDeviceNeedsRefresh = false; + resetStats(); + clearSelection(); + + if (deviceContainer) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' }); + } else { + deviceContainer.innerHTML = ''; + } + } + } /** * Toggle "Show All Agents" mode. @@ -1578,19 +1667,27 @@ const BluetoothMode = (function() { } }); - toRemove.forEach(deviceId => devices.delete(deviceId)); - - // Re-render device list - if (deviceContainer) { - deviceContainer.innerHTML = ''; - devices.forEach(device => renderDevice(device)); - } - - updateDeviceCount(); - updateStatsFromDevices(); - updateVisualizationPanels(); - updateProximityZones(); - updateRadar(); + toRemove.forEach(deviceId => devices.delete(deviceId)); + + // Re-render device list + if (deviceContainer) { + deviceContainer.innerHTML = ''; + devices.forEach(device => renderDevice(device, false)); + applyDeviceFilter(); + if (devices.size === 0 && typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'empty', message: 'No devices for current agent' }); + } + } + + if (selectedDeviceId && !devices.has(selectedDeviceId)) { + clearSelection(); + } + + updateDeviceCount(); + updateStatsFromDevices(); + updateVisualizationPanels(); + updateProximityZones(); + updateRadar(); } /** diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 53cf450..6294f84 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -120,10 +120,23 @@ const WiFiMode = (function() { let channelStats = []; let recommendations = []; - // UI state - let selectedNetwork = null; - let currentFilter = 'all'; - let currentSort = { field: 'rssi', order: 'desc' }; + // UI state + let selectedNetwork = null; + let currentFilter = 'all'; + let currentSort = { field: 'rssi', order: 'desc' }; + let renderFramePending = false; + const pendingRender = { + table: false, + stats: false, + radar: false, + chart: false, + detail: false, + }; + const listenersBound = { + scanTabs: false, + filters: false, + sort: false, + }; // Agent state let showAllAgentsMode = false; // Show combined results from all agents @@ -152,10 +165,11 @@ const WiFiMode = (function() { // Initialize components initScanModeTabs(); - initNetworkFilters(); - initSortControls(); - initProximityRadar(); - initChannelChart(); + initNetworkFilters(); + initSortControls(); + initProximityRadar(); + initChannelChart(); + scheduleRender({ table: true, stats: true, radar: true, chart: true }); // Check if already scanning checkScanStatus(); @@ -364,14 +378,16 @@ const WiFiMode = (function() { // Scan Mode Tabs // ========================================================================== - function initScanModeTabs() { - if (elements.scanModeQuick) { - elements.scanModeQuick.addEventListener('click', () => setScanMode('quick')); - } - if (elements.scanModeDeep) { - elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); - } - } + function initScanModeTabs() { + if (listenersBound.scanTabs) return; + if (elements.scanModeQuick) { + elements.scanModeQuick.addEventListener('click', () => setScanMode('quick')); + } + if (elements.scanModeDeep) { + elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); + } + listenersBound.scanTabs = true; + } function setScanMode(mode) { scanMode = mode; @@ -682,10 +698,10 @@ const WiFiMode = (function() { }, CONFIG.pollInterval); } - function processQuickScanResult(result) { - // Update networks - result.access_points.forEach(ap => { - networks.set(ap.bssid, ap); + function processQuickScanResult(result) { + // Update networks + result.access_points.forEach(ap => { + networks.set(ap.bssid, ap); }); // Update channel stats (calculate from networks if not provided by API) @@ -693,15 +709,12 @@ const WiFiMode = (function() { recommendations = result.recommendations || []; // If no channel stats from API, calculate from networks - if (channelStats.length === 0 && networks.size > 0) { - channelStats = calculateChannelStats(); - } - - // Update UI - updateNetworkTable(); - updateStats(); - updateProximityRadar(); - updateChannelChart(); + 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 => { @@ -910,22 +923,25 @@ const WiFiMode = (function() { } } - function handleNetworkUpdate(network) { - networks.set(network.bssid, network); - updateNetworkRow(network); - updateStats(); - updateProximityRadar(); - updateChannelChart(); - - if (onNetworkUpdate) onNetworkUpdate(network); - } - - function handleClientUpdate(client) { - clients.set(client.mac, client); - updateStats(); - - // Update client display if this client belongs to the selected network - updateClientInList(client); + function handleNetworkUpdate(network) { + networks.set(network.bssid, network); + scheduleRender({ + table: true, + stats: true, + radar: true, + chart: true, + detail: selectedNetwork === network.bssid, + }); + + if (onNetworkUpdate) onNetworkUpdate(network); + } + + function handleClientUpdate(client) { + clients.set(client.mac, client); + scheduleRender({ stats: true }); + + // Update client display if this client belongs to the selected network + updateClientInList(client); if (onClientUpdate) onClientUpdate(client); } @@ -939,32 +955,37 @@ const WiFiMode = (function() { if (onProbeRequest) onProbeRequest(probe); } - function handleHiddenRevealed(bssid, revealedSsid) { - const network = networks.get(bssid); - if (network) { - network.revealed_essid = revealedSsid; - network.display_name = `${revealedSsid} (revealed)`; - updateNetworkRow(network); - - // Show notification - showInfo(`Hidden SSID revealed: ${revealedSsid}`); - } - } + function handleHiddenRevealed(bssid, revealedSsid) { + const network = networks.get(bssid); + if (network) { + network.revealed_essid = revealedSsid; + network.display_name = `${revealedSsid} (revealed)`; + scheduleRender({ + table: true, + detail: selectedNetwork === bssid, + }); + + // Show notification + showInfo(`Hidden SSID revealed: ${revealedSsid}`); + } + } // ========================================================================== // Network Table // ========================================================================== - function initNetworkFilters() { - if (!elements.networkFilters) return; - - elements.networkFilters.addEventListener('click', (e) => { - if (e.target.matches('.wifi-filter-btn')) { - const filter = e.target.dataset.filter; - setNetworkFilter(filter); - } - }); - } + 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; @@ -979,10 +1000,11 @@ const WiFiMode = (function() { updateNetworkTable(); } - function initSortControls() { - if (!elements.networkTable) return; - - elements.networkTable.addEventListener('click', (e) => { + function initSortControls() { + if (listenersBound.sort) return; + if (!elements.networkTable) return; + + elements.networkTable.addEventListener('click', (e) => { const th = e.target.closest('th[data-sort]'); if (th) { const field = th.dataset.sort; @@ -992,16 +1014,54 @@ const WiFiMode = (function() { currentSort.field = field; currentSort.order = 'desc'; } - updateNetworkTable(); - } - }); - } - - function updateNetworkTable() { - if (!elements.networkTableBody) return; - - // Filter networks - let filtered = Array.from(networks.values()); + updateNetworkTable(); + } + }); + + if (elements.networkTableBody) { + elements.networkTableBody.addEventListener('click', (e) => { + const row = e.target.closest('tr[data-bssid]'); + if (!row) return; + selectNetwork(row.dataset.bssid); + }); + } + listenersBound.sort = true; + } + + function scheduleRender(flags = {}) { + pendingRender.table = pendingRender.table || Boolean(flags.table); + pendingRender.stats = pendingRender.stats || Boolean(flags.stats); + pendingRender.radar = pendingRender.radar || Boolean(flags.radar); + pendingRender.chart = pendingRender.chart || Boolean(flags.chart); + pendingRender.detail = pendingRender.detail || Boolean(flags.detail); + + if (renderFramePending) return; + renderFramePending = true; + + requestAnimationFrame(() => { + renderFramePending = false; + + if (pendingRender.table) updateNetworkTable(); + if (pendingRender.stats) updateStats(); + if (pendingRender.radar) updateProximityRadar(); + if (pendingRender.chart) updateChannelChart(); + if (pendingRender.detail && selectedNetwork) { + updateDetailPanel(selectedNetwork, { refreshClients: false }); + } + + pendingRender.table = false; + pendingRender.stats = false; + pendingRender.radar = false; + pendingRender.chart = false; + pendingRender.detail = false; + }); + } + + function updateNetworkTable() { + if (!elements.networkTableBody) return; + + // Filter networks + let filtered = Array.from(networks.values()); switch (currentFilter) { case 'hidden': @@ -1051,22 +1111,44 @@ const WiFiMode = (function() { return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; } else { return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; - } - }); + } + }); + + if (filtered.length === 0) { + let message = 'Start scanning to discover networks'; + let type = 'empty'; + if (isScanning) { + message = 'Scanning for networks...'; + type = 'loading'; + } else if (networks.size > 0) { + message = 'No networks match current filters'; + } + if (typeof renderCollectionState === 'function') { + renderCollectionState(elements.networkTableBody, { + type, + message, + columns: 7, + }); + } else { + elements.networkTableBody.innerHTML = `
${escapeHtml(message)}
`; + } + return; + } + + // Render table + elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); + } - // Render table - elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); - } - - function createNetworkRow(network) { - const rssi = network.rssi_current; - const signalClass = rssi >= -50 ? 'signal-strong' : - rssi >= -70 ? 'signal-medium' : - rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; - - const securityClass = network.security === 'Open' ? 'security-open' : - network.security === 'WEP' ? 'security-wep' : - network.security.includes('WPA3') ? 'security-wpa3' : 'security-wpa'; + function createNetworkRow(network) { + const rssi = network.rssi_current; + const security = network.security || 'Unknown'; + const signalClass = rssi >= -50 ? 'signal-strong' : + rssi >= -70 ? 'signal-medium' : + rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; + + const securityClass = security === 'Open' ? 'security-open' : + security === 'WEP' ? 'security-wep' : + security.includes('WPA3') ? 'security-wpa3' : 'security-wpa'; const hiddenBadge = network.is_hidden ? 'Hidden' : ''; const newBadge = network.is_new ? 'New' : ''; @@ -1075,22 +1157,25 @@ const WiFiMode = (function() { const agentName = network._agent || 'Local'; const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote'; - return ` - - - ${escapeHtml(network.display_name || network.essid || '[Hidden]')} - ${hiddenBadge}${newBadge} - + return ` + + + ${escapeHtml(network.display_name || network.essid || '[Hidden]')} + ${hiddenBadge}${newBadge} + ${escapeHtml(network.bssid)} ${network.channel || '-'} - - ${rssi !== null ? rssi : '-'} - - - ${escapeHtml(network.security)} - + + ${rssi != null ? rssi : '-'} + + + ${escapeHtml(security)} + ${network.client_count || 0} ${escapeHtml(agentName)} @@ -1099,15 +1184,12 @@ const WiFiMode = (function() { `; } - function updateNetworkRow(network) { - const row = elements.networkTableBody?.querySelector(`tr[data-bssid="${network.bssid}"]`); - if (row) { - row.outerHTML = createNetworkRow(network); - } else { - // Add new row - updateNetworkTable(); - } - } + function updateNetworkRow(network) { + scheduleRender({ + table: true, + detail: selectedNetwork === network.bssid, + }); + } function selectNetwork(bssid) { selectedNetwork = bssid; @@ -1130,8 +1212,9 @@ const WiFiMode = (function() { // Detail Panel // ========================================================================== - function updateDetailPanel(bssid) { - if (!elements.detailDrawer) return; + function updateDetailPanel(bssid, options = {}) { + const { refreshClients = true } = options; + if (!elements.detailDrawer) return; const network = networks.get(bssid); if (!network) { @@ -1176,9 +1259,11 @@ const WiFiMode = (function() { // Show the drawer elements.detailDrawer.classList.add('open'); - // Fetch and display clients for this network - fetchClientsForNetwork(network.bssid); - } + // Fetch and display clients for this network + if (refreshClients) { + fetchClientsForNetwork(network.bssid); + } + } function closeDetail() { selectedNetwork = null; @@ -1194,12 +1279,18 @@ const WiFiMode = (function() { // Client Display // ========================================================================== - async function fetchClientsForNetwork(bssid) { - if (!elements.detailClientList) return; - - try { - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - let response; + async function fetchClientsForNetwork(bssid) { + if (!elements.detailClientList) return; + const listContainer = elements.detailClientList.querySelector('.wifi-client-list'); + + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' }); + elements.detailClientList.style.display = 'block'; + } + + try { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + let response; if (isAgentMode) { // Route through agent proxy @@ -1208,28 +1299,44 @@ const WiFiMode = (function() { response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`); } - if (!response.ok) { - // Hide client list on error - elements.detailClientList.style.display = 'none'; - return; - } + if (!response.ok) { + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + return; + } const data = await response.json(); // Handle agent response format (may be nested in 'result') const result = isAgentMode && data.result ? data.result : data; const clientList = result.clients || []; - if (clientList.length > 0) { - renderClientList(clientList, bssid); - elements.detailClientList.style.display = 'block'; - } else { - elements.detailClientList.style.display = 'none'; - } - } catch (error) { - console.debug('[WiFiMode] Error fetching clients:', error); - elements.detailClientList.style.display = 'none'; - } - } + if (clientList.length > 0) { + renderClientList(clientList, bssid); + elements.detailClientList.style.display = 'block'; + } else { + const countBadge = document.getElementById('wifiClientCountBadge'); + if (countBadge) countBadge.textContent = '0'; + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + } + } catch (error) { + console.debug('[WiFiMode] Error fetching clients:', error); + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + } + } function renderClientList(clientList, bssid) { const container = elements.detailClientList?.querySelector('.wifi-client-list'); @@ -1586,17 +1693,16 @@ const WiFiMode = (function() { /** * Clear all collected data. */ - function clearData() { - networks.clear(); - clients.clear(); - probeRequests = []; - channelStats = []; - recommendations = []; - - updateNetworkTable(); - updateStats(); - updateProximityRadar(); - updateChannelChart(); + function clearData() { + networks.clear(); + clients.clear(); + probeRequests = []; + channelStats = []; + recommendations = []; + if (selectedNetwork) { + closeDetail(); + } + scheduleRender({ table: true, stats: true, radar: true, chart: true }); } /** @@ -1642,12 +1748,12 @@ const WiFiMode = (function() { clientsToRemove.push(mac); } }); - clientsToRemove.forEach(mac => clients.delete(mac)); - - updateNetworkTable(); - updateStats(); - updateProximityRadar(); - } + clientsToRemove.forEach(mac => clients.delete(mac)); + if (selectedNetwork && !networks.has(selectedNetwork)) { + closeDetail(); + } + scheduleRender({ table: true, stats: true, radar: true, chart: true }); + } /** * Refresh WiFi interfaces from current agent. diff --git a/templates/index.html b/templates/index.html index 38e2a3b..ecfc0ff 100644 --- a/templates/index.html +++ b/templates/index.html @@ -52,27 +52,43 @@ - - - - - - - - - - - - + @@ -921,30 +937,10 @@
-
+
Tracker Detection
-
-
Monitoring for AirTags, Tiles...
-
-
-
-
Signal Distribution
-
-
Strong (-50+) -
-
-
0 -
-
Medium (-70) -
-
-
0 -
-
Weak (-90) -
-
-
0 -
+
+
Monitoring for AirTags, Tiles...
@@ -999,6 +995,26 @@ --
+
+
Signal Distribution
+
+
Strong +
+
+
0 +
+
Medium +
+
+
0 +
+
Weak +
+
+
0 +
+
+
@@ -1010,9 +1026,7 @@
-
- Start scanning to discover Bluetooth devices -
+
Start scanning to discover Bluetooth devices
@@ -3394,12 +3408,30 @@ // Mode from query string (e.g., /?mode=wifi) let pendingStartMode = null; - const validModes = new Set([ - 'pager', 'sensor', 'rtlamr', 'aprs', 'listening', - 'spystations', 'meshtastic', 'wifi', 'bluetooth', 'bt_locate', - 'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'gps', 'websdr', 'subghz', - 'analytics', 'spaceweather' - ]); + const modeCatalog = { + pager: { label: 'Pager', indicator: 'PAGER', outputTitle: 'Pager Decoder', group: 'signals' }, + sensor: { label: '433MHz', indicator: '433MHZ', outputTitle: '433MHz Sensor Monitor', group: 'signals' }, + rtlamr: { label: 'Meters', indicator: 'METERS', outputTitle: 'Utility Meter Monitor', group: 'signals' }, + listening: { label: 'Listening Post', indicator: 'LISTENING POST', outputTitle: 'Listening Post', group: 'signals' }, + subghz: { label: 'SubGHz', indicator: 'SUBGHZ', outputTitle: 'SubGHz Transceiver', group: 'signals' }, + aprs: { label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking' }, + gps: { label: 'GPS', indicator: 'GPS', outputTitle: 'GPS Receiver', group: 'tracking' }, + satellite: { label: 'Satellite', indicator: 'SATELLITE', outputTitle: 'Satellite Monitor', group: 'space' }, + sstv: { label: 'ISS SSTV', indicator: 'ISS SSTV', outputTitle: 'ISS SSTV Decoder', group: 'space' }, + weathersat: { label: 'Weather Sat', indicator: 'WEATHER SAT', outputTitle: 'Weather Satellite Decoder', group: 'space' }, + sstv_general: { label: 'HF SSTV', indicator: 'HF SSTV', outputTitle: 'HF SSTV Decoder', group: 'space' }, + spaceweather: { label: 'Space Weather', indicator: 'SPACE WX', outputTitle: 'Space Weather Monitor', group: 'space' }, + wifi: { label: 'WiFi', indicator: 'WIFI', outputTitle: 'WiFi Scanner', group: 'wireless' }, + bluetooth: { label: 'Bluetooth', indicator: 'BLUETOOTH', outputTitle: 'Bluetooth Scanner', group: 'wireless' }, + bt_locate: { label: 'BT Locate', indicator: 'BT LOCATE', outputTitle: 'BT Locate — SAR Tracker', group: 'wireless' }, + meshtastic: { label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless' }, + tscm: { label: 'TSCM', indicator: 'TSCM', outputTitle: 'TSCM Counter-Surveillance', group: 'intel' }, + analytics: { label: 'Analytics', indicator: 'ANALYTICS', outputTitle: 'Cross-Mode Analytics', group: 'intel' }, + spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' }, + websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' }, + }; + const validModes = new Set(Object.keys(modeCatalog)); + window.interceptModeCatalog = Object.assign({}, modeCatalog); function getModeFromQuery() { const params = new URLSearchParams(window.location.search); @@ -3513,9 +3545,31 @@ indicator.appendChild(dot); indicator.appendChild(document.createTextNode(String(label || ''))); } + + function applyKeyboardAccessibility(root = document) { + const interactive = root.querySelectorAll('[onclick]:not(button):not(a):not(input):not(select):not(textarea)'); + interactive.forEach((el) => { + if (!el.hasAttribute('role')) el.setAttribute('role', 'button'); + if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0'); + el.setAttribute('data-keyboard-activate', 'true'); + }); + } + + if (!window._keyboardActivationBound) { + window._keyboardActivationBound = true; + document.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + const target = event.target && event.target.closest ? event.target.closest('[data-keyboard-activate="true"]') : null; + if (!target) return; + event.preventDefault(); + target.click(); + }); + } + // Update clock every second setInterval(updateHeaderClock, 1000); updateHeaderClock(); // Initial call + applyKeyboardAccessibility(); // Pager message filter functions function loadPagerFilters() { @@ -3861,20 +3915,11 @@ } function updateDropdownActiveState() { - // Map modes to their dropdown groups - const modeGroups = { - 'pager': 'signals', 'sensor': 'signals', 'rtlamr': 'signals', 'listening': 'signals', 'subghz': 'signals', - 'adsb': 'tracking', 'ais': 'tracking', 'aprs': 'tracking', 'gps': 'tracking', - 'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space', 'spaceweather': 'space', - 'wifi': 'wireless', 'bluetooth': 'wireless', 'bt_locate': 'wireless', 'meshtastic': 'wireless', - 'tscm': 'intel', 'analytics': 'intel', 'spystations': 'intel', 'websdr': 'intel' - }; - // Remove has-active from all dropdowns document.querySelectorAll('.mode-nav-dropdown').forEach(d => d.classList.remove('has-active')); // Add has-active to the dropdown containing the current mode - const activeGroup = modeGroups[currentMode]; + const activeGroup = modeCatalog[currentMode] ? modeCatalog[currentMode].group : null; if (activeGroup) { const dropdown = document.querySelector(`.mode-nav-dropdown[data-group="${activeGroup}"]`); if (dropdown) dropdown.classList.add('has-active'); @@ -3942,20 +3987,16 @@ closeAllDropdowns(); updateDropdownActiveState(); + if (typeof window.ensureModeStyles === 'function') { + window.ensureModeStyles(mode); + } + // Remove active from all nav buttons, then add to the correct one - document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active')); - const modeMap = { - 'pager': 'pager', 'sensor': '433', - 'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth', 'bt_locate': 'bt locate', - 'listening': 'listening', 'aprs': 'aprs', 'tscm': 'tscm', 'meshtastic': 'meshtastic', - 'dmr': 'dmr', 'websdr': 'websdr', 'sstv_general': 'hf sstv', - 'analytics': 'analytics' - }; document.querySelectorAll('.mode-nav-btn').forEach(btn => { - const label = btn.querySelector('.nav-label'); - if (label && label.textContent.toLowerCase().includes(modeMap[mode])) { - btn.classList.add('active'); - } + btn.classList.toggle('active', btn.dataset.mode === mode); + }); + document.querySelectorAll('.mobile-nav-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mode === mode); }); document.getElementById('pagerMode')?.classList.toggle('active', mode === 'pager'); document.getElementById('sensorMode')?.classList.toggle('active', mode === 'sensor'); @@ -4001,31 +4042,8 @@ if (satelliteDashboardBtn) satelliteDashboardBtn.style.display = mode === 'satellite' ? 'inline-flex' : 'none'; // Update active mode indicator - const modeNames = { - 'pager': 'PAGER', - 'sensor': '433MHZ', - 'rtlamr': 'METERS', - 'satellite': 'SATELLITE', - 'sstv': 'ISS SSTV', - 'weathersat': 'WEATHER SAT', - 'sstv_general': 'HF SSTV', - 'gps': 'GPS', - 'wifi': 'WIFI', - 'bluetooth': 'BLUETOOTH', - 'bt_locate': 'BT LOCATE', - 'listening': 'LISTENING POST', - 'aprs': 'APRS', - 'tscm': 'TSCM', - 'ais': 'AIS VESSELS', - 'spystations': 'SPY STATIONS', - 'meshtastic': 'MESHTASTIC', - 'dmr': 'DIGITAL VOICE', - 'websdr': 'WEBSDR', - 'subghz': 'SUBGHZ', - 'analytics': 'ANALYTICS', - 'spaceweather': 'SPACE WX' - }; - setActiveModeIndicator(modeNames[mode] || mode.toUpperCase()); + const modeMeta = modeCatalog[mode] || {}; + setActiveModeIndicator(modeMeta.indicator || mode.toUpperCase()); const wifiLayoutContainer = document.getElementById('wifiLayoutContainer'); const btLayoutContainer = document.getElementById('btLayoutContainer'); const satelliteVisuals = document.getElementById('satelliteVisuals'); @@ -4080,32 +4098,8 @@ if (sensorTimelineContainer) sensorTimelineContainer.style.display = mode === 'sensor' ? 'block' : 'none'; // Update output panel title based on mode - const titles = { - 'pager': 'Pager Decoder', - 'sensor': '433MHz Sensor Monitor', - 'rtlamr': 'Utility Meter Monitor', - 'satellite': 'Satellite Monitor', - 'sstv': 'ISS SSTV Decoder', - 'weathersat': 'Weather Satellite Decoder', - 'sstv_general': 'HF SSTV Decoder', - 'gps': 'GPS Receiver', - 'wifi': 'WiFi Scanner', - 'bluetooth': 'Bluetooth Scanner', - 'bt_locate': 'BT Locate — SAR Tracker', - 'listening': 'Listening Post', - 'aprs': 'APRS Tracker', - 'tscm': 'TSCM Counter-Surveillance', - 'ais': 'AIS Vessel Tracker', - 'spystations': 'Spy Stations', - 'meshtastic': 'Meshtastic Mesh Monitor', - 'dmr': 'Digital Voice Decoder', - 'websdr': 'HF/Shortwave WebSDR', - 'subghz': 'SubGHz Transceiver', - 'analytics': 'Cross-Mode Analytics', - 'spaceweather': 'Space Weather Monitor' - }; const outputTitle = document.getElementById('outputTitle'); - if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor'; + if (outputTitle) outputTitle.textContent = modeMeta.outputTitle || 'Signal Monitor'; // Initialize mode-specific timelines initializeModeTimeline(mode); diff --git a/templates/partials/help-modal.html b/templates/partials/help-modal.html index 230fe8a..4621d9b 100644 --- a/templates/partials/help-modal.html +++ b/templates/partials/help-modal.html @@ -4,20 +4,20 @@ #} -
-
- -

iNTERCEPT Help

- -
- - - - -
- - -
+