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 = '
${escapeHtml(network.bssid)}