From fd42ffd987a05b20b908e55fca15c5dc21a0ef80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juanjo=20Guti=C3=A9rrez?= Date: Fri, 6 Feb 2026 14:52:58 +0100 Subject: [PATCH] Add details for station --- static/script.js | 541 +++++++++++++++++++++++++++++++++---------- static/styles.css | 229 +++++++++++++++--- templates/index.html | 51 ++-- 3 files changed, 648 insertions(+), 173 deletions(-) diff --git a/static/script.js b/static/script.js index fb4a610..ea0e132 100644 --- a/static/script.js +++ b/static/script.js @@ -3,131 +3,153 @@ const API_BASE_URL = '/api/v1/sta'; const REFRESH_INTERVAL = 2000; +const DETAILS_REFRESH_INTERVAL = + 1000; // Refresh selected station details more frequently let isRefreshing = false; +let selectedStation = null; +let detailsRefreshInterval = null; // DOM Elements const stationTableBody = document.getElementById('station-tbody'); const errorMessage = document.getElementById('error-message'); const lastUpdate = document.getElementById('last-update'); +const detailsPanel = document.getElementById('station-details-panel'); +const detailsContent = document.getElementById('station-details-content'); // Initialize the application document.addEventListener('DOMContentLoaded', function() { - // Load initial data - refreshStations(); - - setInterval(refreshStations, REFRESH_INTERVAL); + // Load initial data + refreshStations(); + + setInterval(refreshStations, REFRESH_INTERVAL); }); // Refresh station data async function refreshStations() { - if (isRefreshing) return; // Prevent concurrent refreshes - - isRefreshing = true; - - try { - // Fetch station list - const stations = await fetchStations(); - - if (stations.length === 0) { - showNoStations(); - } else { - // Fetch details for each station and populate table - await populateStationTable(stations); - } - - // Update last refresh time - lastUpdate.textContent = `Last update: ${new Date().toLocaleTimeString()}`; - - // Hide any previous error messages - hideError(); - - } catch (error) { - console.error('Error refreshing stations:', error); - showError('Failed to load station data. Please check if the API is running.'); - } finally { - isRefreshing = false; + if (isRefreshing) + return; // Prevent concurrent refreshes + + isRefreshing = true; + + try { + // Fetch station list + const stations = await fetchStations(); + + if (stations.length === 0) { + showNoStations(); + } else { + // Fetch details for each station and populate table + await populateStationTable(stations); } + + // Update last refresh time + lastUpdate.textContent = `Last update: ${new Date().toLocaleTimeString()}`; + + // Hide any previous error messages + hideError(); + + } catch (error) { + console.error('Error refreshing stations:', error); + showError( + 'Failed to load station data. Please check if the API is running.'); + } finally { + isRefreshing = false; + } } // Fetch list of connected stations async function fetchStations() { - const response = await fetch(`${API_BASE_URL}/list`); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return await response.json(); + const response = await fetch(`${API_BASE_URL}/list`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); } // Populate table with station data async function populateStationTable(stations) { - // Process each station concurrently - const stationPromises = stations.map(async (mac) => { - try { - const details = await fetchStationDetails(mac); - return { mac, details, error: null }; - } catch (error) { - return { mac, details: null, error: error.message }; - } - }); - - const stationResults = await Promise.all(stationPromises); - - const rows = []; - // Add rows to table - stationResults.forEach(({ mac, details, error }) => { - const row = document.createElement('tr'); - - if (error) { - row.innerHTML = ` + // Process each station concurrently + const stationPromises = stations.map(async (mac) => { + try { + const details = await fetchStationDetails(mac); + return {mac, details, error : null}; + } catch (error) { + return {mac, details : null, error : error.message}; + } + }); + + const stationResults = await Promise.all(stationPromises); + + const rows = []; + // Add rows to table + stationResults.forEach(({mac, details, error}) => { + const row = document.createElement('tr'); + row.dataset.mac = mac; // Store MAC address for selection + + if (error) { + row.innerHTML = ` ${mac} Error: ${error} `; - } else { - // Calculate total bytes - const rxBytes = parseInt(details.rx_bytes) || 0; - const txBytes = parseInt(details.tx_bytes) || 0; - - // Determine signal strength category - const signal = parseInt(details.signal) || 0; - let signalClass = 'status-established'; - if (signal < -80) signalClass = 'status-new'; - else if (signal < -60) signalClass = 'status-medium'; - - row.innerHTML = ` - ${mac} + } else { + // Calculate total bytes + const rxBytes = parseInt(details.rx_bytes) || 0; + const txBytes = parseInt(details.tx_bytes) || 0; + + // Determine signal strength category + const signal = parseInt(details.signal) || 0; + let signalClass = 'status-established'; + if (signal < -80) + signalClass = 'status-new'; + else if (signal < -60) + signalClass = 'status-medium'; + + row.innerHTML = ` + ${mac} ${signal} dBm - ${formatDataRate(details.rx_rate_info)}/${formatDataRate(details.tx_rate_info)} + ${formatDataRate(details.rx_rate_info)}/${ + formatDataRate(details.tx_rate_info)} ${formatBytes(rxBytes)}/${formatBytes(txBytes)} `; - } - rows.push(row); - }); - stationTableBody.replaceChildren(...rows); + } + + // Add click handler for station selection + row.addEventListener('click', () => selectStation(mac, details, error)); + + // Highlight selected station + if (mac === selectedStation) { + row.classList.add('selected'); + } + + rows.push(row); + }); + stationTableBody.replaceChildren(...rows); } // Fetch details for a specific station async function fetchStationDetails(mac) { - const response = await fetch(`${API_BASE_URL}/details/${mac}`); - - if (!response.ok) { - if (response.status === 404) { - throw new Error('Station not found'); - } else if (response.status === 400) { - throw new Error('Invalid MAC address'); - } else { - throw new Error(`HTTP error! status: ${response.status}`); - } + const response = await fetch(`${API_BASE_URL}/details/${mac}`); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Station not found'); + } else if (response.status === 400) { + throw new Error('Invalid MAC address'); + } else { + throw new Error(`HTTP error! status: ${response.status}`); } - - return await response.json(); + } + + return await response.json(); } // Show message when no stations are connected function showNoStations() { - stationTableBody.innerHTML = ` + stationTableBody.innerHTML = ` No stations connected @@ -136,49 +158,332 @@ function showNoStations() { // Show error message function showError(message) { - errorMessage.querySelector('p').textContent = message; - errorMessage.classList.remove('hidden'); + errorMessage.querySelector('p').textContent = message; + errorMessage.classList.remove('hidden'); } // Hide error message -function hideError() { - errorMessage.classList.add('hidden'); -} +function hideError() { errorMessage.classList.add('hidden'); } // Format data rate (assuming it's in Mbps or similar) function formatDataRate(rateStr) { - if (!rateStr) return 'N/A'; - - const rate = parseInt(rateStr); - if (isNaN(rate)) return rateStr; - - // Convert to Mbps if it seems to be in a different unit - if (rate > 1000) { - return `${(rate / 1000).toFixed(1)} Mbps`; - } - return `${rate} Mbps`; + if (!rateStr) + return 'N/A'; + + const rate = parseInt(rateStr); + if (isNaN(rate)) + return rateStr; + + // Convert to Mbps if it seems to be in a different unit + if (rate > 1000) { + return `${(rate / 1000).toFixed(1)} Mbps`; + } + return `${rate} Mbps`; } // Format bytes to human readable format function formatBytes(bytes) { - if (bytes === 0) return '0 B'; - if (!bytes) return 'N/A'; - - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + if (bytes === 0) + return '0 B'; + if (!bytes) + return 'N/A'; + + const k = 1024; + const sizes = [ 'B', 'KB', 'MB', 'GB' ]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } // Get CSS class for connection status function getConnectionStatusClass(connectedTimeStr) { - if (!connectedTimeStr) return 'status-established'; - - const seconds = parseInt(connectedTimeStr); - if (isNaN(seconds)) return 'status-established'; - - if (seconds < 60) return 'status-new'; - else if (seconds < 300) return 'status-medium'; - else return 'status-established'; + if (!connectedTimeStr) + return 'status-established'; + + const seconds = parseInt(connectedTimeStr); + if (isNaN(seconds)) + return 'status-established'; + + if (seconds < 60) + return 'status-new'; + else if (seconds < 300) + return 'status-medium'; + else + return 'status-established'; } + +// Select a station and show its details +function selectStation(mac, details, error) { + // Update selected station + selectedStation = mac; + + // Highlight selected row + const rows = stationTableBody.querySelectorAll('tr'); + rows.forEach(row => { + if (row.dataset.mac === mac) { + row.classList.add('selected'); + } else { + row.classList.remove('selected'); + } + }); + + // Show details panel + if (error) { + showStationDetailsError(mac, error); + } else { + showStationDetails(mac, details); + } + + // Start continuous details refresh + startDetailsRefresh(mac); +} + +// Friendly names mapping for known fields +const FIELD_FRIENDLY_NAMES = { + '80211RSNAStatsSTAAddress' : 'RSNA Stats STA Address', + '80211RSNAStatsSelectedPairwiseCipher' : 'RSNA Selected Pairwise Cipher', + '80211RSNAStatsTKIPLocalMICFailures' : 'RSNA TKIP Local MIC Failures', + '80211RSNAStatsTKIPRemoteMICFailures' : 'RSNA TKIP Remote MIC Failures', + '80211RSNAStatsVersion' : 'RSNA Stats Version', + 'AKMSuiteSelector' : 'AKM Suite Selector', + 'aid' : 'Association ID', + 'auth_alg' : 'Authentication Algorithm', + 'auth_failures' : 'Authentication Failures', + 'authenticated' : 'Authenticated', + 'authorized' : 'Authorized', + 'capability' : 'Capability', + 'channel_width' : 'Channel Width', + 'connected_time' : 'Connected Time', + 'dot11RSNAStatsSTAAddress' : 'Dot11 RSNA Stats STA Address', + 'dot11RSNAStatsSelectedPairwiseCipher' : + 'Dot11 RSNA Selected Pairwise Cipher', + 'dot11RSNAStatsTKIPLocalMICFailures' : 'Dot11 RSNA TKIP Local MIC Failures', + 'dot11RSNAStatsTKIPRemoteMICFailures' : 'Dot11 RSNA TKIP Remote MIC Failures', + 'dot11RSNAStatsVersion' : 'Dot11 RSNA Stats Version', + 'ext_capab' : 'Extended Capabilities', + 'flags' : 'Flags', + 'hostapdMFPR' : 'Hostapd MFPR', + 'hostapdWPAPTKGroupState' : 'Hostapd WPA PTK Group State', + 'hostapdWPAPTKState' : 'Hostapd WPA PTK State', + 'ht_caps_info' : 'HT Capabilities Info', + 'ht_operation' : 'HT Operation', + 'inactive_msec' : 'Inactive Time (ms)', + 'listen_interval' : 'Listen Interval', + 'max_amsdu_length' : 'Max AMSDU Length', + 'noise' : 'Noise Level', + 'num_ps_buf_frames' : 'Number of PS Buffer Frames', + 'ps_buf_frames_freed' : 'PS Buffer Frames Freed', + 'ps_buffering' : 'Power Save Buffering', + 'rx_bitrate' : 'RX Bitrate', + 'rx_bytes' : 'RX Bytes', + 'rx_packets' : 'RX Packets', + 'rx_rate_info' : 'RX Rate Info', + 'short_preamble' : 'Short Preamble', + 'signal' : 'Signal Strength', + 'snr' : 'Signal-to-Noise Ratio', + 'supp_channels' : 'Supported Channels', + 'supp_op_classes' : 'Supported Operating Classes', + 'supported_rates' : 'Supported Rates', + 'timeout_next' : 'Next Timeout Action', + 'tx_ampdu_len' : 'TX AMPDU Length', + 'tx_ampdu_pkts' : 'TX AMPDU Packets', + 'tx_bitrate' : 'TX Bitrate', + 'tx_bytes' : 'TX Bytes', + 'tx_failed' : 'TX Failed', + 'tx_packets' : 'TX Packets', + 'tx_rate_info' : 'TX Rate Info', + 'vht_caps_info' : 'VHT Capabilities Info', + 'vht_operation' : 'VHT Operation', + 'wmm_ie' : 'WMM IE', + 'wpa' : 'WPA Version' +}; + +// Show station details in the details panel +function showStationDetails(mac, details) { + // Build details HTML with all fields + let detailsHTML = ` +
+
MAC Address
+
${mac}
+
+ `; + + // Separate known important fields from others + const importantFields = [ + 'connected_time', 'signal', 'rx_packets', 'tx_packets', 'rx_bytes', + 'tx_bytes', 'rx_rate_info', 'tx_rate_info', 'flags', 'capability', + 'auth_alg' + ]; + + // Display important fields first + importantFields.forEach(field => { + if (details.hasOwnProperty(field)) { + const friendlyName = FIELD_FRIENDLY_NAMES[field] || formatFieldName(field); + const value = formatFieldValue(field, details[field]); + const displayValue = value.toString(); + + // Check if field name or value is too long for tooltip + const needsTooltip = friendlyName.length > 25 || displayValue.length > 30; + + let labelHTML = friendlyName; + if (friendlyName.length > 25) { + labelHTML = `${friendlyName.substring(0, 22)}...${friendlyName}`; + } + + let valueHTML = displayValue; + if (displayValue.length > 30) { + valueHTML = `${displayValue.substring(0, 27)}...${displayValue}`; + } + + detailsHTML += ` +
+
${labelHTML}
+
${valueHTML}
+
`; + } + }); + + // Display all other fields + const otherFields = + Object.keys(details).filter(key => !importantFields.includes(key)); + if (otherFields.length > 0) { + // Group fields alphabetically for better organization + otherFields.sort().forEach(key => { + const friendlyName = FIELD_FRIENDLY_NAMES[key] || formatFieldName(key); + const value = formatFieldValue(key, details[key]); + const displayValue = value.toString(); + + // Check if field name or value is too long for tooltip + const needsTooltip = friendlyName.length > 25 || displayValue.length > 30; + + let labelHTML = friendlyName; + if (friendlyName.length > 25) { + labelHTML = `${friendlyName.substring(0, 22)}...${friendlyName}`; + } + + let valueHTML = displayValue; + if (displayValue.length > 30) { + valueHTML = `${displayValue.substring(0, 27)}...${displayValue}`; + } + + detailsHTML += ` +
+
${labelHTML}
+
${valueHTML}
+
`; + }); + } + + detailsContent.innerHTML = detailsHTML; +} + +// Format field names to be more readable +function formatFieldName(fieldName) { + // Convert camelCase to Title Case + let formatted = fieldName.replace(/([A-Z])/g, ' $1'); + // Replace underscores with spaces + formatted = formatted.replace(/_/g, ' '); + // Capitalize first letter + formatted = formatted.charAt(0).toUpperCase() + formatted.slice(1); + return formatted; +} + +// Format field values based on their type +function formatFieldValue(fieldName, value) { + if (value === undefined || value === null) { + return 'N/A'; + } + + // Handle numeric fields + if (fieldName.includes('bytes')) { + const numValue = parseInt(value); + return isNaN(numValue) ? value : formatBytes(numValue); + } + + if (fieldName.includes('rate') && fieldName.includes('info')) { + return formatDataRate(value); + } + + if (fieldName.includes('time')) { + const numValue = parseInt(value); + return isNaN(numValue) ? value : formatConnectedTime(value); + } + + if (fieldName.includes('signal') || fieldName.includes('noise') || + fieldName.includes('snr')) { + const numValue = parseInt(value); + return isNaN(numValue) ? value : `${numValue} dBm`; + } + + // Handle flag-like fields + if (typeof value === 'string' && (value === 'true' || value === 'false')) { + return value.charAt(0).toUpperCase() + value.slice(1); + } + + // Handle hex values + if (typeof value === 'string' && value.match(/^[0-9a-fA-F\-]+$/)) { + return value.toUpperCase(); + } + + return value.toString(); +} + +// Show error in details panel +function showStationDetailsError(mac, error) { + detailsContent.innerHTML = ` +
+
MAC Address
+
${mac}
+
+
+
Error
+
${error}
+
+ `; +} + +// Format connected time to human readable format +function formatConnectedTime(secondsStr) { + if (!secondsStr) + return 'Unknown'; + + const seconds = parseInt(secondsStr); + if (isNaN(seconds)) + return secondsStr; + + if (seconds < 60) { + return `${seconds} seconds`; + } else if (seconds < 3600) { + const minutes = Math.floor(seconds / 60); + return `${minutes} minute${minutes !== 1 ? 's' : ''}`; + } else { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours} hour${hours !== 1 ? 's' : ''} ${minutes} minute${ + minutes !== 1 ? 's' : ''}`; + } +} + +// Start continuous details refresh for selected station +function startDetailsRefresh(mac) { + stopDetailsRefresh(); // Clear any existing interval + + detailsRefreshInterval = setInterval(async () => { + if (selectedStation) { + try { + const details = await fetchStationDetails(selectedStation); + showStationDetails(selectedStation, details); + } catch (error) { + showStationDetailsError(selectedStation, error.message); + } + } + }, DETAILS_REFRESH_INTERVAL); +} + +// Stop continuous details refresh +function stopDetailsRefresh() { + if (detailsRefreshInterval) { + clearInterval(detailsRefreshInterval); + detailsRefreshInterval = null; + } +} \ No newline at end of file diff --git a/static/styles.css b/static/styles.css index 72bdb69..04d7498 100644 --- a/static/styles.css +++ b/static/styles.css @@ -40,38 +40,17 @@ header h1 { color: #666; } -/* Controls */ -.controls { +/* Main layout */ +.main-layout { display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - background: white; - padding: 15px 20px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + gap: 20px; + min-height: 500px; } -.btn-primary { - background-color: #3498db; - color: white; - border: none; - padding: 10px 20px; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - transition: background-color 0.3s; -} - -.btn-primary:hover { - background-color: #2980b9; -} - -.auto-refresh label { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; +/* Adjust layout for always-visible details panel */ +.content-area { + flex: 1; + min-width: 300px; } /* Table styles */ @@ -108,6 +87,24 @@ tr:hover { background-color: #f8f9fa; } +tr.selected { + background-color: #e3f2fd; + cursor: pointer; +} + +tr.selected:hover { + background-color: #bbdefb; +} + +tr.selected td:first-child { + border-left: 4px solid #2196f3; +} + +tr:not(.selected):hover { + background-color: #f8f9fa; + cursor: pointer; +} + /* Status colors */ .status-new { background-color: #e74c3c; @@ -139,6 +136,146 @@ tr:hover { text-align: center; } +/* Details panel */ +.details-panel { + width: 400px; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + display: flex; + flex-direction: column; +} + +.details-header { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #34495e; + color: white; + padding: 15px 20px; + border-radius: 8px 8px 0 0; +} + +.details-header h2 { + margin: 0; + font-size: 1.2em; +} + +.close-btn { + background: none; + border: none; + color: white; + font-size: 1.5em; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; +} + +.close-btn:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +.details-content { + padding: 20px; + flex: 1; + overflow-y: auto; + max-height: 500px; +} + +.detail-item { + display: flex; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid #ecf0f1; + align-items: flex-start; +} + +.detail-item:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.detail-label { + font-weight: 600; + color: #2c3e50; + min-width: 150px; + padding-right: 15px; + text-align: left; + flex-shrink: 0; +} + +.detail-value { + color: #34495e; + flex: 1; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + position: relative; +} + +/* Tooltip styling */ +.tooltip { + position: relative; + display: inline-block; + border-bottom: 1px dotted #95a5a6; + cursor: help; +} + +.tooltip .tooltiptext { + visibility: hidden; + width: 200px; + background-color: #34495e; + color: #fff; + text-align: left; + border-radius: 6px; + padding: 8px; + position: absolute; + z-index: 1; + bottom: 125%; + left: 50%; + transform: translateX(-50%); + opacity: 0; + transition: opacity 0.3s; + font-size: 0.9em; + font-weight: normal; + word-wrap: break-word; + white-space: normal; +} + +.tooltip .tooltiptext::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #34495e transparent transparent transparent; +} + +.tooltip:hover .tooltiptext { + visibility: visible; + opacity: 1; +} + +/* Mono font for technical values */ +.detail-value.mono { + font-family: monospace; + font-size: 0.9em; +} + +.detail-value.mono { + font-family: monospace; + font-size: 0.9em; +} + /* Loading and error states */ .loading { text-align: center; @@ -170,6 +307,34 @@ footer { } /* Responsive design */ +@media (max-width: 1024px) { + .main-layout { + flex-direction: column; + } + + .details-panel { + width: 100%; + } + + .container { + padding: 10px; + } + + table { + font-size: 0.9em; + } + + th, td { + padding: 8px 10px; + } + + .status-bar { + flex-direction: column; + gap: 5px; + text-align: center; + } +} + @media (max-width: 768px) { .container { padding: 10px; @@ -183,12 +348,6 @@ footer { padding: 8px 10px; } - .controls { - flex-direction: column; - gap: 10px; - text-align: center; - } - .status-bar { flex-direction: column; gap: 5px; diff --git a/templates/index.html b/templates/index.html index 95a8005..c39a710 100644 --- a/templates/index.html +++ b/templates/index.html @@ -15,27 +15,38 @@ -
-
- - - - - - - - - - - - - - -
MAC AddressSignal StrengthRate(rx/tx)Bytes(rx/tx)
Loading station data...
+
+
+
+ + + + + + + + + + + + + + +
MAC AddressSignal StrengthRate(rx/tx)Bytes(rx/tx)
Loading station data...
+
+ +
-
@@ -46,4 +57,4 @@ - + \ No newline at end of file