Improve cross-app UX: accessibility, mode consistency, and render performance

This commit is contained in:
Smittix
2026-02-19 22:32:08 +00:00
parent cfe03317c9
commit 963bcdf9fa
11 changed files with 1051 additions and 507 deletions

View File

@@ -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;

View File

@@ -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',

View File

@@ -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;

View File

@@ -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();
}
});
}

View File

@@ -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();
});

View File

@@ -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 = '<div style="color:#666;padding:10px;text-align:center;font-size:11px;">Start scanning to detect trackers</div>';
} else if (deviceStats.trackers.length === 0) {
trackerList.innerHTML = '<div style="color:#22c55e;padding:10px;text-align:center;font-size:11px;">No trackers detected</div>';
} 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
? '<div style="font-size:9px;color:#888;margin-top:3px;font-style:italic;">' +
evidence.map(e => '• ' + escapeHtml(e)).join('<br>') +
'</div>'
: '';
const deviceIdEscaped = escapeHtml(t.device_id).replace(/'/g, "\\'");
return '<div class="bt-tracker-item" style="padding:8px;border-bottom:1px solid rgba(255,255,255,0.05);cursor:pointer;" onclick="BluetoothMode.selectDevice(\'' + deviceIdEscaped + '\')">' +
'<div style="display:flex;justify-content:space-between;align-items:center;">' +
'<div style="display:flex;align-items:center;gap:6px;">' +
'<span style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:2px 5px;border-radius:3px;font-weight:600;">' + confidence.toUpperCase() + '</span>' +
'<span style="color:#fff;font-size:11px;">' + escapeHtml(trackerType) + '</span>' +
'</div>' +
'<div style="display:flex;align-items:center;gap:8px;">' +
(riskScore >= 0.3 ? '<span style="color:' + riskColor + ';font-size:9px;font-weight:600;">RISK ' + Math.round(riskScore * 100) + '%</span>' : '') +
'<span style="color:#666;font-size:10px;">' + (t.rssi_current || '--') + ' dBm</span>' +
'</div>' +
'</div>' +
'<div style="display:flex;justify-content:space-between;margin-top:3px;">' +
'<span style="font-size:9px;color:#888;font-family:monospace;">' + (t.address_type === 'uuid' ? formatAddress(t) : t.address) + '</span>' +
'<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' +
'</div>' +
evidenceHtml +
'</div>';
}).join('');
}
}
}
if (trackerList) {
if (devices.size === 0) {
if (typeof renderCollectionState === 'function') {
renderCollectionState(trackerList, { type: 'empty', message: 'Start scanning to detect trackers' });
} else {
trackerList.innerHTML = '<div class="app-collection-state is-empty">Start scanning to detect trackers</div>';
}
} else if (deviceStats.trackers.length === 0) {
if (typeof renderCollectionState === 'function') {
renderCollectionState(trackerList, { type: 'empty', message: 'No trackers detected' });
} else {
trackerList.innerHTML = '<div class="app-collection-state is-empty">No trackers detected</div>';
}
} 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
? `<div class="bt-tracker-evidence">${evidence.map((e) => `${escapeHtml(e)}`).join('<br>')}</div>`
: '';
const riskClass = riskScore >= 0.5 ? 'high' : riskScore >= 0.3 ? 'medium' : 'low';
const riskHtml = riskScore >= 0.3
? `<span class="bt-tracker-risk bt-risk-${riskClass}">RISK ${Math.round(riskScore * 100)}%</span>`
: '';
return `
<div class="bt-tracker-item bt-tracker-confidence-${escapeHtml(confidence)}" data-device-id="${escapeAttr(t.device_id)}" role="button" tabindex="0" data-keyboard-activate="true">
<div class="bt-tracker-row-top">
<div class="bt-tracker-left">
<span class="bt-tracker-confidence">${escapeHtml(confidence.toUpperCase())}</span>
<span class="bt-tracker-type">${escapeHtml(trackerType)}</span>
</div>
<div class="bt-tracker-right">
${riskHtml}
<span class="bt-tracker-rssi">${t.rssi_current != null ? t.rssi_current : '--'} dBm</span>
</div>
</div>
<div class="bt-tracker-row-bottom">
<span class="bt-tracker-address">${escapeHtml(t.address_type === 'uuid' ? formatAddress(t) : (t.address || '--'))}</span>
<span class="bt-tracker-seen">Seen ${t.seen_count || 0}x</span>
</div>
${evidenceHtml}
</div>
`;
}).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 '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '" data-bt-device-id="' + escapeHtml(device.device_id) + '" data-is-new="' + isNew + '" data-has-name="' + hasName + '" data-rssi="' + (rssi || -100) + '" data-is-tracker="' + isTracker + '" data-search="' + escapeAttr(searchIndex) + '" onclick="BluetoothMode.selectDevice(\'' + deviceIdEscaped + '\')" style="border-left-color:' + borderColor + ';">' +
'<div class="bt-row-main">' +
'<div class="bt-row-left">' +
protoBadge +
// Row border color - highlight trackers in red/orange
const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' :
isTracker ? '#f97316' : rssiColor;
return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '" data-bt-device-id="' + escapeAttr(device.device_id) + '" data-is-new="' + isNew + '" data-has-name="' + hasName + '" data-rssi="' + (rssi || -100) + '" data-is-tracker="' + isTracker + '" data-search="' + escapeAttr(searchIndex) + '" role="button" tabindex="0" data-keyboard-activate="true" style="border-left-color:' + borderColor + ';">' +
'<div class="bt-row-main">' +
'<div class="bt-row-left">' +
protoBadge +
'<span class="bt-device-name">' + name + '</span>' +
trackerBadge +
irkBadge +
@@ -1395,13 +1480,13 @@ const BluetoothMode = (function() {
'</div>' +
statusDot +
'</div>' +
'</div>' +
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
'<div class="bt-row-actions">' +
'<button class="bt-locate-btn" data-locate-id="' + escapeHtml(device.device_id) + '" onclick="event.stopPropagation(); BluetoothMode.locateById(this.dataset.locateId)">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>' +
'Locate</button>' +
'</div>' +
'</div>' +
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
'<div class="bt-row-actions">' +
'<button type="button" class="bt-locate-btn" data-locate-id="' + escapeAttr(device.device_id) + '">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>' +
'Locate</button>' +
'</div>' +
'</div>';
}
@@ -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();
}
/**

View File

@@ -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 = `<tr class="wifi-network-placeholder"><td colspan="7"><div class="placeholder-text">${escapeHtml(message)}</div></td></tr>`;
}
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 ? '<span class="badge badge-hidden">Hidden</span>' : '';
const newBadge = network.is_new ? '<span class="badge badge-new">New</span>' : '';
@@ -1075,22 +1157,25 @@ const WiFiMode = (function() {
const agentName = network._agent || 'Local';
const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote';
return `
<tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}"
data-bssid="${escapeHtml(network.bssid)}"
onclick="WiFiMode.selectNetwork('${escapeHtml(network.bssid)}')">
<td class="col-essid">
<span class="essid">${escapeHtml(network.display_name || network.essid || '[Hidden]')}</span>
${hiddenBadge}${newBadge}
</td>
return `
<tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}"
data-bssid="${escapeHtml(network.bssid)}"
role="button"
tabindex="0"
data-keyboard-activate="true"
aria-label="Select network ${escapeHtml(network.display_name || network.essid || '[Hidden]')}">
<td class="col-essid">
<span class="essid">${escapeHtml(network.display_name || network.essid || '[Hidden]')}</span>
${hiddenBadge}${newBadge}
</td>
<td class="col-bssid"><code>${escapeHtml(network.bssid)}</code></td>
<td class="col-channel">${network.channel || '-'}</td>
<td class="col-rssi">
<span class="rssi-value ${signalClass}">${rssi !== null ? rssi : '-'}</span>
</td>
<td class="col-security">
<span class="security-badge ${securityClass}">${escapeHtml(network.security)}</span>
</td>
<td class="col-rssi">
<span class="rssi-value ${signalClass}">${rssi != null ? rssi : '-'}</span>
</td>
<td class="col-security">
<span class="security-badge ${securityClass}">${escapeHtml(security)}</span>
</td>
<td class="col-clients">${network.client_count || 0}</td>
<td class="col-agent">
<span class="agent-badge ${agentClass}">${escapeHtml(agentName)}</span>
@@ -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.

View File

@@ -52,27 +52,43 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/aprs.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/tscm.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/analytics.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/device-cards.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/proximity-viz.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/spy-stations.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/meshtastic.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/weather-satellite.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv-general.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/gps.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/space-weather.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/ux-platform.css') }}">
<script>
window.INTERCEPT_MODE_STYLE_MAP = {
aprs: "{{ url_for('static', filename='css/modes/aprs.css') }}",
tscm: "{{ url_for('static', filename='css/modes/tscm.css') }}",
analytics: "{{ url_for('static', filename='css/modes/analytics.css') }}",
spystations: "{{ url_for('static', filename='css/modes/spy-stations.css') }}",
meshtastic: "{{ url_for('static', filename='css/modes/meshtastic.css') }}",
sstv: "{{ url_for('static', filename='css/modes/sstv.css') }}",
weathersat: "{{ url_for('static', filename='css/modes/weather-satellite.css') }}",
sstv_general: "{{ url_for('static', filename='css/modes/sstv-general.css') }}",
gps: "{{ url_for('static', filename='css/modes/gps.css') }}",
subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9",
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}"
};
window.INTERCEPT_MODE_STYLE_LOADED = {};
window.ensureModeStyles = function(mode) {
const href = window.INTERCEPT_MODE_STYLE_MAP ? window.INTERCEPT_MODE_STYLE_MAP[mode] : null;
if (!href) return;
if (window.INTERCEPT_MODE_STYLE_LOADED[href]) return;
window.INTERCEPT_MODE_STYLE_LOADED[href] = true;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.dataset.modeStyle = mode;
document.head.appendChild(link);
};
</script>
</head>
<body>
@@ -921,30 +937,10 @@
<div class="bt-main-area">
<!-- Left side panels -->
<div class="bt-side-panels">
<div class="wifi-visual-panel bt-side-panel">
<div class="wifi-visual-panel bt-side-panel bt-tracker-panel">
<h5>Tracker Detection</h5>
<div id="btTrackerList" style="font-size: 11px; max-height: 200px; overflow-y: auto;">
<div style="color: var(--text-dim); padding: 10px; text-align: center;">Monitoring for AirTags, Tiles...</div>
</div>
</div>
<div class="wifi-visual-panel bt-side-panel">
<h5>Signal Distribution</h5>
<div class="bt-signal-dist" id="btSignalDist">
<div class="signal-range"><span>Strong (-50+)</span>
<div class="signal-bar-bg">
<div class="signal-bar strong" id="btSignalStrong" style="width: 0%;"></div>
</div><span id="btSignalStrongCount">0</span>
</div>
<div class="signal-range"><span>Medium (-70)</span>
<div class="signal-bar-bg">
<div class="signal-bar medium" id="btSignalMedium" style="width: 0%;"></div>
</div><span id="btSignalMediumCount">0</span>
</div>
<div class="signal-range"><span>Weak (-90)</span>
<div class="signal-bar-bg">
<div class="signal-bar weak" id="btSignalWeak" style="width: 0%;"></div>
</div><span id="btSignalWeakCount">0</span>
</div>
<div id="btTrackerList" class="bt-tracker-list">
<div class="app-collection-state is-empty">Monitoring for AirTags, Tiles...</div>
</div>
</div>
</div>
@@ -999,6 +995,26 @@
<span class="bt-summary-value" id="btSummaryStrongest">--</span>
</div>
</div>
<div class="bt-list-signal-strip">
<div class="bt-list-signal-title">Signal Distribution</div>
<div class="bt-signal-dist bt-signal-dist-compact" id="btSignalDist">
<div class="signal-range"><span>Strong</span>
<div class="signal-bar-bg">
<div class="signal-bar strong" id="btSignalStrong" style="width: 0%;"></div>
</div><span id="btSignalStrongCount">0</span>
</div>
<div class="signal-range"><span>Medium</span>
<div class="signal-bar-bg">
<div class="signal-bar medium" id="btSignalMedium" style="width: 0%;"></div>
</div><span id="btSignalMediumCount">0</span>
</div>
<div class="signal-range"><span>Weak</span>
<div class="signal-bar-bg">
<div class="signal-bar weak" id="btSignalWeak" style="width: 0%;"></div>
</div><span id="btSignalWeakCount">0</span>
</div>
</div>
</div>
<div class="bt-device-toolbar">
<input type="search" id="btDeviceSearch" class="bt-device-search" placeholder="Filter by name, MAC, manufacturer...">
</div>
@@ -1010,9 +1026,7 @@
<button class="bt-filter-btn" data-filter="trackers">Trackers</button>
</div>
<div class="wifi-device-list-content" id="btDeviceListContent">
<div style="color: var(--text-dim); text-align: center; padding: 30px;">
Start scanning to discover Bluetooth devices
</div>
<div class="app-collection-state is-empty">Start scanning to discover Bluetooth devices</div>
</div>
</div>
</div>
@@ -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);

View File

@@ -4,20 +4,20 @@
#}
<!-- Help Modal -->
<div id="helpModal" class="help-modal" onclick="if(event.target === this) hideHelp()">
<div class="help-content">
<button class="help-close" onclick="hideHelp()">&times;</button>
<h2>iNTERCEPT Help</h2>
<div class="help-tabs">
<button class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')">Icons</button>
<button class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')">Modes</button>
<button class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')">WiFi</button>
<button class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')">Tips</button>
</div>
<!-- Icons Section -->
<div id="help-icons" class="help-section active">
<div id="helpModal" class="help-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="helpModalTitle" onclick="if(event.target === this) hideHelp()">
<div class="help-content" tabindex="-1">
<button type="button" class="help-close" onclick="hideHelp()" aria-label="Close help">&times;</button>
<h2 id="helpModalTitle">iNTERCEPT Help</h2>
<div class="help-tabs" role="tablist" aria-label="Help sections">
<button type="button" class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')" role="tab" aria-controls="help-icons" aria-selected="true">Icons</button>
<button type="button" class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')" role="tab" aria-controls="help-modes" aria-selected="false">Modes</button>
<button type="button" class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')" role="tab" aria-controls="help-wifi" aria-selected="false">WiFi</button>
<button type="button" class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')" role="tab" aria-controls="help-tips" aria-selected="false">Tips</button>
</div>
<!-- Icons Section -->
<div id="help-icons" class="help-section active" role="tabpanel">
<h3>Stats Bar Icons</h3>
<div class="icon-grid">
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span><span class="desc">POCSAG messages decoded</span></div>
@@ -62,7 +62,7 @@
</div>
<!-- Modes Section -->
<div id="help-modes" class="help-section">
<div id="help-modes" class="help-section" role="tabpanel" hidden>
<h3>Pager Mode</h3>
<ul class="tip-list">
<li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li>
@@ -254,7 +254,7 @@
</div>
<!-- WiFi Section -->
<div id="help-wifi" class="help-section">
<div id="help-wifi" class="help-section" role="tabpanel" hidden>
<h3>Monitor Mode</h3>
<ul class="tip-list">
<li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li>
@@ -302,7 +302,7 @@
</div>
<!-- Tips Section -->
<div id="help-tips" class="help-section">
<div id="help-tips" class="help-section" role="tabpanel" hidden>
<h3>General Tips</h3>
<ul class="tip-list">
<li><strong>Collapsible sections:</strong> Click any section header (&nabla;) to collapse/expand</li>
@@ -360,41 +360,62 @@
<script>
// Help modal functions - defined here so all pages have them
(function() {
// Only define if not already defined (index.html defines its own)
if (typeof window.showHelp === 'undefined') {
window.showHelp = function() {
document.getElementById('helpModal').classList.add('active');
document.body.style.overflow = 'hidden';
};
}
if (typeof window.hideHelp === 'undefined') {
window.hideHelp = function() {
document.getElementById('helpModal').classList.remove('active');
document.body.style.overflow = '';
};
}
if (typeof window.switchHelpTab === 'undefined') {
window.switchHelpTab = function(tab) {
document.querySelectorAll('.help-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.help-section').forEach(s => s.classList.remove('active'));
document.querySelector('.help-tab[data-tab="' + tab + '"]').classList.add('active');
document.getElementById('help-' + tab).classList.add('active');
};
}
(function() {
let lastHelpFocusEl = null;
// Only define if not already defined (index.html defines its own)
if (typeof window.showHelp === 'undefined') {
window.showHelp = function() {
const modal = document.getElementById('helpModal');
lastHelpFocusEl = document.activeElement;
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
const content = modal.querySelector('.help-content');
if (content) content.focus();
document.body.style.overflow = 'hidden';
};
}
if (typeof window.hideHelp === 'undefined') {
window.hideHelp = function() {
const modal = document.getElementById('helpModal');
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
if (lastHelpFocusEl && typeof lastHelpFocusEl.focus === 'function') {
lastHelpFocusEl.focus();
}
};
}
if (typeof window.switchHelpTab === 'undefined') {
window.switchHelpTab = function(tab) {
document.querySelectorAll('.help-tab').forEach(t => {
const isActive = t.dataset.tab === tab;
t.classList.toggle('active', isActive);
t.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
document.querySelectorAll('.help-section').forEach(s => {
const isActive = s.id === ('help-' + tab);
s.classList.toggle('active', isActive);
s.hidden = !isActive;
});
};
}
// Keyboard shortcuts for help (only add once)
if (!window._helpKeyboardSetup) {
window._helpKeyboardSetup = true;
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') hideHelp();
// Open help with F1 or ? key (when not typing in an input)
var helpModal = document.getElementById('helpModal');
if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) {
e.preventDefault();
showHelp();
if (!window._helpKeyboardSetup) {
window._helpKeyboardSetup = true;
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const modal = document.getElementById('helpModal');
if (modal && modal.classList.contains('active')) hideHelp();
}
// Open help with F1 or ? key (when not typing in an input)
var helpModal = document.getElementById('helpModal');
if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) {
e.preventDefault();
showHelp();
}
});
}

View File

@@ -17,17 +17,17 @@
{% macro mode_item(mode, label, icon_svg, href=None) -%}
{%- set is_active = 'active' if active_mode == mode else '' -%}
{%- if href %}
<a href="{{ href }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;">
<a href="{{ href }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
<span class="nav-label">{{ label }}</span>
</a>
{%- elif is_index_page %}
<button class="mode-nav-btn {{ is_active }}" onclick="switchMode('{{ mode }}')">
<button type="button" class="mode-nav-btn {{ is_active }}" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode" onclick="switchMode('{{ mode }}')">
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
<span class="nav-label">{{ label }}</span>
</button>
{%- else %}
<a href="/?mode={{ mode }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;">
<a href="/?mode={{ mode }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
<span class="nav-label">{{ label }}</span>
</a>
@@ -37,15 +37,15 @@
{% macro mobile_item(mode, label, icon_svg, href=None) -%}
{%- set is_active = 'active' if active_mode == mode else '' -%}
{%- if href %}
<a href="{{ href }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;">
<a href="{{ href }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
</a>
{%- elif is_index_page %}
<button class="mobile-nav-btn {{ is_active }}" data-mode="{{ mode }}" onclick="switchMode('{{ mode }}')">
<button type="button" class="mobile-nav-btn {{ is_active }}" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode" onclick="switchMode('{{ mode }}')">
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
</button>
{%- else %}
<a href="/?mode={{ mode }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;">
<a href="/?mode={{ mode }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
</a>
{%- endif %}
@@ -55,7 +55,7 @@
<nav class="mode-nav" id="mainNav">
{# Signals Group #}
<div class="mode-nav-dropdown" data-group="signals">
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('signals')"{% endif %}>
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('signals')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M22 12h-1"/><path d="M1 12h1"/></svg></span>
<span class="nav-label">Signals</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
@@ -72,7 +72,7 @@
{# Tracking Group #}
<div class="mode-nav-dropdown" data-group="tracking">
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('tracking')"{% endif %}>
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('tracking')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></span>
<span class="nav-label">Tracking</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
@@ -88,7 +88,7 @@
{# Space Group #}
<div class="mode-nav-dropdown" data-group="space">
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('space')"{% endif %}>
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('space')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span>
<span class="nav-label">Space</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
@@ -109,7 +109,7 @@
{# Wireless Group #}
<div class="mode-nav-dropdown" data-group="wireless">
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('wireless')"{% endif %}>
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('wireless')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="nav-label">Wireless</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
@@ -125,7 +125,7 @@
{# Intel Group #}
<div class="mode-nav-dropdown" data-group="intel">
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('intel')"{% endif %}>
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('intel')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="nav-label">Intel</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
@@ -160,11 +160,11 @@
</a>
<div class="nav-divider"></div>
<div class="nav-tools">
<button class="nav-tool-btn" onclick="toggleAnimations()" title="Toggle Animations">
<button type="button" class="nav-tool-btn" onclick="toggleAnimations()" title="Toggle Animations" aria-label="Toggle animations">
<span class="icon-effects-on icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></span>
<span class="icon-effects-off icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/><line x1="2" y1="2" x2="22" y2="22"/></svg></span>
</button>
<button class="nav-tool-btn" onclick="toggleTheme()" title="Toggle Light/Dark Theme">
<button type="button" class="nav-tool-btn" onclick="toggleTheme()" title="Toggle Light/Dark Theme" aria-label="Toggle theme">
<span class="icon-moon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span>
<span class="icon-sun icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
</button>
@@ -174,11 +174,11 @@
<a href="/controller/manage" class="nav-tool-btn" title="Manage Remote Agents" style="text-decoration: none;">
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span>
</a>
<button class="nav-tool-btn" onclick="showSettings()" title="Settings">
<button type="button" class="nav-tool-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
</button>
<button class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation">?</button>
<button class="nav-tool-btn" onclick="logout(event)" title="Logout">
<button type="button" class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation" aria-label="Open help">?</button>
<button type="button" class="nav-tool-btn" onclick="logout(event)" title="Logout" aria-label="Logout">
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
</button>
</div>
@@ -257,7 +257,8 @@
const current = html.getAttribute('data-animations') || 'on';
const next = current === 'on' ? 'off' : 'on';
html.setAttribute('data-animations', next);
localStorage.setItem('animations', next);
localStorage.setItem('intercept-animations', next);
localStorage.removeItem('animations');
};
}
@@ -316,7 +317,12 @@
document.documentElement.setAttribute('data-theme', savedTheme);
}
const savedAnimations = localStorage.getItem('intercept-animations');
const legacyAnimations = localStorage.getItem('animations');
const savedAnimations = localStorage.getItem('intercept-animations') || legacyAnimations;
if (legacyAnimations && !localStorage.getItem('intercept-animations')) {
localStorage.setItem('intercept-animations', legacyAnimations);
localStorage.removeItem('animations');
}
if (savedAnimations) {
document.documentElement.setAttribute('data-animations', savedAnimations);
}

View File

@@ -1,27 +1,27 @@
<!-- Settings Modal -->
<div id="settingsModal" class="settings-modal" onclick="if(event.target === this) hideSettings()">
<div class="settings-content">
<div id="settingsModal" class="settings-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="settingsModalTitle" onclick="if(event.target === this) hideSettings()">
<div class="settings-content" tabindex="-1">
<div class="settings-header">
<h2>
<h2 id="settingsModalTitle">
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
Settings
</h2>
<button class="settings-close" onclick="hideSettings()">&times;</button>
<button type="button" class="settings-close" onclick="hideSettings()" aria-label="Close settings">&times;</button>
</div>
<div class="settings-tabs">
<button class="settings-tab active" data-tab="offline" onclick="switchSettingsTab('offline')">Offline</button>
<button class="settings-tab" data-tab="location" onclick="switchSettingsTab('location')">Location</button>
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
<button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button>
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button>
<button class="settings-tab" data-tab="alerts" onclick="switchSettingsTab('alerts')">Alerts</button>
<button class="settings-tab" data-tab="recording" onclick="switchSettingsTab('recording')">Recording</button>
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
<div class="settings-tabs" role="tablist" aria-label="Settings sections">
<button type="button" class="settings-tab active" data-tab="offline" onclick="switchSettingsTab('offline')" role="tab" aria-controls="settings-offline" aria-selected="true">Offline</button>
<button type="button" class="settings-tab" data-tab="location" onclick="switchSettingsTab('location')" role="tab" aria-controls="settings-location" aria-selected="false">Location</button>
<button type="button" class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')" role="tab" aria-controls="settings-display" aria-selected="false">Display</button>
<button type="button" class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')" role="tab" aria-controls="settings-updates" aria-selected="false">Updates</button>
<button type="button" class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')" role="tab" aria-controls="settings-tools" aria-selected="false">Tools</button>
<button type="button" class="settings-tab" data-tab="alerts" onclick="switchSettingsTab('alerts')" role="tab" aria-controls="settings-alerts" aria-selected="false">Alerts</button>
<button type="button" class="settings-tab" data-tab="recording" onclick="switchSettingsTab('recording')" role="tab" aria-controls="settings-recording" aria-selected="false">Recording</button>
<button type="button" class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')" role="tab" aria-controls="settings-about" aria-selected="false">About</button>
</div>
<!-- Offline Section -->
<div id="settings-offline" class="settings-section active">
<div id="settings-offline" class="settings-section active" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">Offline Mode</div>