// Application State const state = { authenticated: false, user: null, map: null, markers: {}, trails: {}, // Track position trail polylines for each node nodeLastHeard: {}, // Track last heard times for flash detection refreshIntervals: [], timeUpdateInterval: null, lastSuccessfulUpdate: Date.now(), lastDataReceived: Date.now(), // Track when actual data was received mqttConnected: true, connectionWarningShown: false, mapUserMoved: false, // Track if user has moved the map knownMessages: new Set(), // Track known message IDs knownNodes: new Map(), // Track known nodes with their data reconnectAttempts: 0, maxReconnectAttempts: 5, reconnectDelay: 5000, // 5 seconds isReconnecting: false, channelConfig: {} // Channel name configuration }; // Handle authentication errors (401) function handleAuthenticationError() { if (!state.authenticated) return; // Already logged out console.log('Session expired - logging out'); state.authenticated = false; state.user = null; stopRefreshIntervals(); // Show notification alert('Your session has expired. Please log in again.'); // Redirect to login ui.showScreen('login-screen'); } // Handle connection lost (network errors) function handleConnectionLost() { if (state.isReconnecting) return; // Already trying to reconnect state.isReconnecting = true; // Show notification if (!state.connectionWarningShown) { showNotification('Connection Lost', 'Attempting to reconnect to server...', 'warning'); } // Try to reconnect attemptReconnect(); } // Attempt to reconnect to the server async function attemptReconnect() { if (state.reconnectAttempts >= state.maxReconnectAttempts) { state.isReconnecting = false; showNotification('Connection Failed', 'Unable to reconnect. Please refresh the page.', 'error'); return; } state.reconnectAttempts++; console.log(`Reconnection attempt ${state.reconnectAttempts} of ${state.maxReconnectAttempts}`); // Wait before attempting await new Promise(resolve => setTimeout(resolve, state.reconnectDelay)); try { // Try to check auth status to test connection const response = await fetch('/api/auth/status', { credentials: 'include' }); if (response.ok) { console.log('Reconnected to server'); state.reconnectAttempts = 0; state.isReconnecting = false; showNotification('Connection Restored', 'Successfully reconnected to server', 'success'); // Reload current tab data const activeTab = document.querySelector('.nav-tab.active')?.getAttribute('data-tab'); if (activeTab === 'overview') loadOverview(); if (activeTab === 'messages') loadMessages(); if (activeTab === 'nodes') loadNodes(); if (activeTab === 'map') loadMap(); } else if (response.status === 401) { // Session expired handleAuthenticationError(); state.isReconnecting = false; } else { // Still can't connect, try again attemptReconnect(); } } catch (error) { // Still can't connect, try again attemptReconnect(); } } // API Helper const api = { async request(url, options = {}) { try { const response = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...options.headers }, credentials: 'include' }); // Handle 401 Unauthorized - session expired or invalid if (response.status === 401) { console.warn('Authentication required - session expired'); handleAuthenticationError(); throw new Error('Authentication required'); } if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Request failed' })); throw new Error(error.error || 'Request failed'); } return response.json(); } catch (error) { // Check if it's a network error (connection lost) if (error.message === 'Failed to fetch' || error.name === 'TypeError') { console.warn('Connection to server lost - attempting to reconnect'); handleConnectionLost(); } throw error; } }, async login(username, password) { return this.request('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }); }, async logout() { return this.request('/api/logout', { method: 'POST' }); }, async checkAuth() { return this.request('/api/auth/status'); }, async getStats() { return this.request('/api/stats'); }, async getNodes() { return this.request('/api/nodes'); }, async getNode(nodeId) { return this.request(`/api/nodes/${nodeId}`); }, async getPositions() { return this.request('/api/positions'); }, async getPositionTrails(limit = 10) { return this.request(`/api/positions/trails/all?limit=${limit}`); }, async getMessages(limit = 100) { return this.request(`/api/messages?limit=${limit}`); }, async sendMessage(from, text, channel) { return this.request('/api/messages/send', { method: 'POST', body: JSON.stringify({ from, text, channel }) }); }, async purgeData(hours) { return this.request('/api/purge', { method: 'POST', body: JSON.stringify({ hours }) }); }, async getMqttStatus() { return this.request('/api/mqtt/status'); }, async getChannelConfig() { return this.request('/api/config/channels'); } }; // UI Helper const ui = { showScreen(screenId) { document.querySelectorAll('.screen').forEach(screen => { screen.classList.add('hidden'); }); document.getElementById(screenId).classList.remove('hidden'); }, showTab(tabName) { document.querySelectorAll('.nav-tab').forEach(tab => { tab.classList.remove('active'); }); document.querySelectorAll('.tab-content').forEach(content => { content.classList.remove('active'); }); document.querySelector(`[data-tab="${tabName}"]`)?.classList.add('active'); document.getElementById(`${tabName}-tab`)?.classList.add('active'); // Reinitialize map when tab becomes visible if (tabName === 'map') { setTimeout(() => { if (state.map) { state.map.invalidateSize(); } else { initMap(); } }, 50); } }, showError(elementId, message) { const element = document.getElementById(elementId); if (element) { element.textContent = message; element.style.display = 'block'; } }, hideError(elementId) { const element = document.getElementById(elementId); if (element) { element.textContent = ''; element.style.display = 'none'; } }, formatRelativeTime(dateString) { if (!dateString) return 'Never'; // Handle SQLite datetime format (YYYY-MM-DD HH:MM:SS) // Convert to ISO format if needed let dateStr = dateString; if (dateString.includes(' ') && !dateString.includes('T')) { dateStr = dateString.replace(' ', 'T') + 'Z'; } const date = new Date(dateStr); // Check if date is valid if (isNaN(date.getTime())) { console.warn('Invalid date:', dateString); return 'Invalid date'; } const now = new Date(); const diff = Math.floor((now - date) / 1000); // seconds if (diff < 1) return '0s ago'; if (diff < 60) return `${diff}s ago`; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; return date.toLocaleDateString(); }, formatBytes(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; }, getMarkerOpacity(lastHeardDate) { if (!lastHeardDate) return 0.3; const now = new Date(); const lastHeard = new Date(lastHeardDate); const hoursSince = (now - lastHeard) / (1000 * 60 * 60); if (hoursSince < 1) return 1.0; if (hoursSince < 6) return 0.8; if (hoursSince < 24) return 0.6; if (hoursSince < 72) return 0.4; return 0.2; }, getMarkerColor(lastHeardDate) { if (!lastHeardDate) return '#a50026'; // Darkest red for unknown // Handle SQLite datetime format let dateStr = lastHeardDate; if (lastHeardDate.includes(' ') && !lastHeardDate.includes('T')) { dateStr = lastHeardDate.replace(' ', 'T') + 'Z'; } const now = new Date(); const lastHeard = new Date(dateStr); const minutesSince = (now - lastHeard) / (1000 * 60); // Custom color scheme: Pure green -> Yellow -> Orange -> Red const colors = [ '#00ff00', // Pure green - 0-45 min '#9db800', // Darker yellow-green - 45-90 min '#cccc00', // Darker yellow - 90-135 min '#fee08b', // Yellow-orange - 135-180 min '#fdae61', // Orange - 180-225 min '#f46d43', // Light red - 225-270 min '#d73027', // Red - 270-315 min '#a50026' // Dark red - 315-360 min (6h+) ]; // 8 buckets over 360 minutes = 45 minutes per bucket const bucketSize = 360 / 8; const bucketIndex = Math.min(7, Math.floor(minutesSince / bucketSize)); return colors[bucketIndex]; }, getChannelName(channelNumber) { return state.channelConfig[channelNumber] || `Channel ${channelNumber}`; }, // Hash a string to a color hashStringToColor(str) { if (!str) return '#2563eb'; // Default primary color // Simple hash function let hash = 0; for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); hash = hash & hash; // Convert to 32bit integer } // Convert to HSL for better color distribution // Use hue from hash, fixed saturation and lightness for readability const hue = Math.abs(hash % 360); const saturation = 65; // 65% saturation for vibrant colors const lightness = 45; // 45% lightness for good contrast with white text return `hsl(${hue}, ${saturation}%, ${lightness}%)`; }, getChannelColor(channelNumber) { const channelName = this.getChannelName(channelNumber); return this.hashStringToColor(channelName); } }; // Login Handler document.getElementById('login-form')?.addEventListener('submit', async (e) => { e.preventDefault(); ui.hideError('login-error'); const username = document.getElementById('username').value; const password = document.getElementById('password').value; try { const result = await api.login(username, password); if (result.success) { state.authenticated = true; state.user = result.user; initDashboard(); } } catch (error) { ui.showError('login-error', error.message); } }); // Logout Handler document.getElementById('logout-btn')?.addEventListener('click', async () => { try { await api.logout(); state.authenticated = false; state.user = null; stopRefreshIntervals(); ui.showScreen('login-screen'); } catch (error) { console.error('Logout error:', error); } }); // Tab Navigation document.querySelectorAll('.nav-tab').forEach(tab => { tab.addEventListener('click', () => { const tabName = tab.getAttribute('data-tab'); ui.showTab(tabName); // Load tab content if (tabName === 'overview') loadOverview(); if (tabName === 'map') loadMap(); if (tabName === 'messages') loadMessages(); if (tabName === 'nodes') loadNodes(); }); }); // Initialize Dashboard async function initDashboard() { ui.showScreen('dashboard-screen'); // Set user info document.getElementById('user-info').textContent = state.user.username; // Load channel configuration try { state.channelConfig = await api.getChannelConfig(); populateChannelSelector(); } catch (error) { console.error('Error loading channel config:', error); // Use defaults if config fails to load state.channelConfig = { 0: 'LongFast', 1: 'Aether', 2: 'Channel 2', 3: 'Channel 3', 4: 'Channel 4', 5: 'Channel 5', 6: 'Channel 6', 7: 'Channel 7' }; } // Update MQTT status updateMqttStatus(); // Load initial content loadOverview(); // Start auto-refresh startRefreshIntervals(); } // Populate channel selector dropdown function populateChannelSelector() { const channelSelect = document.getElementById('message-channel'); if (!channelSelect) return; // Clear existing options channelSelect.innerHTML = ''; // Add options from config Object.entries(state.channelConfig).forEach(([channelNum, channelName]) => { const option = document.createElement('option'); option.value = channelNum; option.textContent = channelName; channelSelect.appendChild(option); }); } // Update MQTT Status async function updateMqttStatus() { try { const status = await api.getMqttStatus(); const statusElement = document.querySelector('#mqtt-status .status-dot'); // Update connection state state.lastSuccessfulUpdate = Date.now(); state.connectionWarningShown = false; hideConnectionWarning(); if (status.connected) { statusElement?.classList.add('connected'); if (!state.mqttConnected) { // MQTT reconnected state.mqttConnected = true; showNotification('MQTT Connected', 'Connection to MQTT broker restored', 'success'); } } else { statusElement?.classList.remove('connected'); if (state.mqttConnected) { // MQTT disconnected state.mqttConnected = false; showNotification('MQTT Disconnected', 'Connection to MQTT broker lost', 'warning'); } } } catch (error) { console.error('Error fetching MQTT status:', error); checkConnectionHealth(); } } // Load Overview async function loadOverview() { try { const stats = await api.getStats(); document.getElementById('stat-nodes').textContent = stats.nodes; document.getElementById('stat-messages').textContent = stats.messages; document.getElementById('stat-positions').textContent = stats.positions; document.getElementById('stat-db-size').textContent = ui.formatBytes(stats.databaseSize); // Load recent messages const messages = await api.getMessages(10); displayMessages(messages, 'recent-messages'); // Update last data received timestamp state.lastDataReceived = Date.now(); // Reset reconnect attempts on successful load if (state.reconnectAttempts > 0) { state.reconnectAttempts = 0; state.isReconnecting = false; } } catch (error) { console.error('Error loading overview:', error); } } // Load Messages (24 hours or 1000 messages, whichever is less) async function loadMessages() { try { const messages = await api.getMessages(1000); displayMessages(messages, 'message-history'); state.lastDataReceived = Date.now(); // Reset reconnect attempts on successful load if (state.reconnectAttempts > 0) { state.reconnectAttempts = 0; state.isReconnecting = false; } } catch (error) { console.error('Error loading messages:', error); } } // Display Messages function displayMessages(messages, containerId) { const container = document.getElementById(containerId); if (!container) return; if (messages.length === 0) { container.innerHTML = '
💬

No messages yet

'; return; } // Build a map of message IDs to messages const messageMap = new Map(); messages.forEach(msg => { const msgId = msg.id || `${msg.from_node}-${msg.created_at}`; messageMap.set(msgId, msg); }); // Get existing message elements const existingMessages = container.querySelectorAll('.message-item'); const existingIds = new Set(); existingMessages.forEach(elem => { const msgId = elem.getAttribute('data-message-id'); if (msgId) existingIds.add(msgId); }); // Remove messages that are no longer in the list (older than limit) existingMessages.forEach(elem => { const msgId = elem.getAttribute('data-message-id'); if (msgId && !messageMap.has(msgId)) { elem.remove(); } }); // Add new messages or update existing ones messages.forEach((msg, index) => { const msgId = msg.id || `${msg.from_node}-${msg.created_at}`; if (!existingIds.has(msgId)) { // Create new message element const channelColor = msg.channel !== null && msg.channel !== undefined ? ui.getChannelColor(msg.channel) : '#2563eb'; const messageHtml = `
${escapeHtml(msg.from_long_name || msg.from_short_name || msg.from_node)} ${ui.formatRelativeTime(msg.created_at)}
${escapeHtml(msg.text || '')}
${msg.channel !== null && msg.channel !== undefined ? `${ui.getChannelName(msg.channel)}` : ''} ${msg.rx_snr ? ` | SNR: ${msg.rx_snr.toFixed(1)} dB` : ''} ${msg.rx_rssi ? ` | RSSI: ${msg.rx_rssi} dBm` : ''}
`; // Insert at the beginning (newest first) if (index === 0) { container.insertAdjacentHTML('afterbegin', messageHtml); } else { container.insertAdjacentHTML('beforeend', messageHtml); } } }); // Reorder if necessary (messages should be newest first) const allMessages = Array.from(container.querySelectorAll('.message-item')); if (allMessages.length !== messages.length || allMessages.length > messages.length) { // Prune to keep only the message limit const limit = messages.length; allMessages.slice(limit).forEach(elem => elem.remove()); } } // Send Message document.getElementById('send-message-form')?.addEventListener('submit', async (e) => { e.preventDefault(); const text = document.getElementById('message-text').value; const from = parseInt(document.getElementById('message-from').value); const channel = parseInt(document.getElementById('message-channel').value); try { await api.sendMessage(from, text, channel); document.getElementById('message-text').value = ''; // Reload messages after a short delay setTimeout(() => loadMessages(), 1000); } catch (error) { alert('Error sending message: ' + error.message); } }); // Refresh Messages Button document.getElementById('refresh-messages-btn')?.addEventListener('click', loadMessages); // Load Nodes async function loadNodes() { try { const nodes = await api.getNodes(); displayNodes(nodes); state.lastDataReceived = Date.now(); // Reset reconnect attempts on successful load if (state.reconnectAttempts > 0) { state.reconnectAttempts = 0; state.isReconnecting = false; } } catch (error) { console.error('Error loading nodes:', error); } } // Display Nodes function displayNodes(nodes) { const container = document.getElementById('nodes-grid'); if (!container) return; if (nodes.length === 0) { container.innerHTML = '
📡

No nodes found

'; return; } // Clear and rebuild to ensure proper sorting container.innerHTML = ''; // Add nodes in the order they come from API (already sorted) nodes.forEach(node => { const lastHeard = node.last_heard ? new Date(node.last_heard) : null; const isOnline = lastHeard && (Date.now() - lastHeard.getTime()) < 900000; // 15 minutes const nodeHtml = `
${escapeHtml(node.long_name || node.short_name || 'Unknown')}
${escapeHtml(node.node_id)}
${node.latitude && node.longitude ? ` 🌍 ` : ''} ${isOnline ? 'Online' : 'Offline'}
${node.hardware_model ? `
Model:${escapeHtml(String(node.hardware_model))}
` : ''} ${node.battery_level ? `
Battery:${node.battery_level}%
` : ''} ${node.last_heard ? `
Last Heard:${ui.formatRelativeTime(node.last_heard)}
` : ''}
`; container.insertAdjacentHTML('beforeend', nodeHtml); // Add click handler for the new card const newCard = container.lastElementChild; if (newCard) { newCard.addEventListener('click', () => { window.showNodeDetail(node.node_id); }); } }); } // Show Node Detail (make it globally accessible for Leaflet popups) window.showNodeDetail = async function(nodeId) { try { const node = await api.getNode(nodeId); const modal = document.getElementById('node-modal'); const detailContainer = document.getElementById('node-detail'); detailContainer.innerHTML = `
Node ID ${escapeHtml(node.node_id)}
${node.short_name ? `
Short Name ${escapeHtml(node.short_name)}
` : ''} ${node.long_name ? `
Long Name ${escapeHtml(node.long_name)}
` : ''} ${node.hardware_model ? `
Hardware Model ${escapeHtml(String(node.hardware_model))}
` : ''} ${node.role ? `
Role ${escapeHtml(String(node.role))}
` : ''} ${node.firmware_version ? `
Firmware ${escapeHtml(node.firmware_version)}
` : ''} ${node.latitude && node.longitude ? `
Location ${node.latitude.toFixed(6)}, ${node.longitude.toFixed(6)} 🌍
` : ''} ${node.battery_level ? `
Battery Level ${node.battery_level}%
` : ''} ${node.voltage ? `
Voltage ${node.voltage.toFixed(2)}V
` : ''} ${node.channel_utilization ? `
Channel Utilization ${node.channel_utilization.toFixed(1)}%
` : ''} ${node.last_heard ? `
Last Heard ${ui.formatRelativeTime(node.last_heard)}
` : ''} `; modal?.classList.remove('hidden'); } catch (error) { console.error('Error loading node detail:', error); alert('Error loading node details'); } } // Close Node Modal document.querySelector('.modal-close')?.addEventListener('click', () => { document.getElementById('node-modal')?.classList.add('hidden'); }); document.getElementById('node-modal')?.addEventListener('click', (e) => { if (e.target.id === 'node-modal') { document.getElementById('node-modal')?.classList.add('hidden'); } }); // Refresh Nodes Button document.getElementById('refresh-nodes-btn')?.addEventListener('click', loadNodes); // Initialize Map function initMap() { if (state.map) return; const mapElement = document.getElementById('map'); if (!mapElement) return; state.map = L.map('map').setView([39.8283, -98.5795], 4); // Center of USA // Use CartoDB Positron for minimalist black and white tiles L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap contributors, © CARTO', maxZoom: 19, subdomains: 'abcd' }).addTo(state.map); // Track when user moves the map state.map.on('movestart', () => { state.mapUserMoved = true; }); // Force resize after initialization setTimeout(() => state.map.invalidateSize(), 100); } // Load Map async function loadMap() { if (!state.map) { initMap(); } try { const [positions, trails] = await Promise.all([ api.getPositions(), api.getPositionTrails(10) ]); displayPositions(positions); displayTrails(trails); state.lastDataReceived = Date.now(); // Reset reconnect attempts on successful load if (state.reconnectAttempts > 0) { state.reconnectAttempts = 0; state.isReconnecting = false; } } catch (error) { console.error('Error loading map:', error); } } // Display Positions on Map function displayPositions(positions) { if (!state.map) { initMap(); } if (!state.map) return; if (positions.length === 0) { return; } // Track which nodes we've seen in this update const seenNodes = new Set(); // Update or add markers for each position positions.forEach(pos => { seenNodes.add(pos.node_id); const opacity = ui.getMarkerOpacity(pos.last_heard || pos.timestamp); const color = ui.getMarkerColor(pos.last_heard || pos.timestamp); // Check if node is newly heard (last_heard changed recently) const currentLastHeard = pos.last_heard || pos.timestamp; const previousLastHeard = state.nodeLastHeard[pos.node_id]; const isNewlyHeard = previousLastHeard && currentLastHeard !== previousLastHeard; // Update tracked last heard time state.nodeLastHeard[pos.node_id] = currentLastHeard; // Add pulsing animation for newly heard nodes const pulseAnimation = isNewlyHeard ? 'animation: markerPulse 2s ease-out;' : ''; const icon = L.divIcon({ className: 'custom-marker', html: `
`, iconSize: [16, 16], iconAnchor: [8, 8] }); const popupContent = `
${escapeHtml(pos.long_name || pos.short_name || pos.node_id)}
${escapeHtml(pos.node_id)}
Lat: ${pos.latitude.toFixed(6)}
Lon: ${pos.longitude.toFixed(6)}
${pos.altitude ? `Alt: ${pos.altitude}m
` : ''} ${ui.formatRelativeTime(pos.timestamp)}
View Details 🌍
`; // Update existing marker or create new one if (state.markers[pos.node_id]) { const marker = state.markers[pos.node_id]; const currentLatLng = marker.getLatLng(); // Update position if changed if (currentLatLng.lat !== pos.latitude || currentLatLng.lng !== pos.longitude) { marker.setLatLng([pos.latitude, pos.longitude]); } // Update icon opacity marker.setIcon(icon); // Update popup content without destroying the marker marker.getPopup().setContent(popupContent); } else { // Create new marker const marker = L.marker([pos.latitude, pos.longitude], { icon }) .addTo(state.map) .bindPopup(popupContent); state.markers[pos.node_id] = marker; } }); // Remove markers for nodes that are no longer in the position list (older than retention) Object.keys(state.markers).forEach(nodeId => { if (!seenNodes.has(nodeId)) { state.markers[nodeId].remove(); delete state.markers[nodeId]; } }); // Only fit bounds if user hasn't moved the map and this is the first load if (!state.mapUserMoved && positions.length > 0 && Object.keys(state.markers).length === positions.length) { const bounds = positions.map(pos => [pos.latitude, pos.longitude]); state.map.fitBounds(bounds, { padding: [50, 50] }); } // Force resize setTimeout(() => state.map.invalidateSize(), 100); } // Display Position Trails on Map function displayTrails(trails) { if (!state.map || !trails || trails.length === 0) return; // Group trails by node_id const trailsByNode = {}; trails.forEach(pos => { if (!trailsByNode[pos.node_id]) { trailsByNode[pos.node_id] = []; } trailsByNode[pos.node_id].push(pos); }); // Track which nodes we've seen const seenNodes = new Set(); // Create or update polylines for each node Object.entries(trailsByNode).forEach(([nodeId, positions]) => { seenNodes.add(nodeId); // Only draw trail if we have at least 2 positions if (positions.length < 2) return; // Sort by timestamp (oldest to newest) for drawing the line positions.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); // Create coordinate array for polyline const latLngs = positions.map(pos => [pos.latitude, pos.longitude]); // Remove existing trail if it exists if (state.trails[nodeId]) { state.trails[nodeId].remove(); } // Create new polyline (trail) const polyline = L.polyline(latLngs, { color: '#666', weight: 2, opacity: 0.5, dashArray: '5, 5' }).addTo(state.map); state.trails[nodeId] = polyline; }); // Remove trails for nodes that are no longer in the list Object.keys(state.trails).forEach(nodeId => { if (!seenNodes.has(nodeId)) { state.trails[nodeId].remove(); delete state.trails[nodeId]; } }); } // Refresh Map Button document.getElementById('refresh-map-btn')?.addEventListener('click', loadMap); // Purge Data document.getElementById('purge-form')?.addEventListener('submit', async (e) => { e.preventDefault(); const hours = parseInt(document.getElementById('purge-hours').value); // Format time period for display let timePeriod; if (hours < 24) { timePeriod = `${hours} hour${hours !== 1 ? 's' : ''}`; } else { const days = hours / 24; timePeriod = `${days} day${days !== 1 ? 's' : ''}`; } if (!confirm(`Are you sure you want to delete data older than ${timePeriod}? This cannot be undone.`)) { return; } try { const result = await api.purgeData(hours); alert(`Successfully purged:\n${result.deleted.messages} messages\n${result.deleted.positions} positions\n${result.deleted.telemetry} telemetry records\n${result.deleted.nodes} nodes`); // Reload stats loadOverview(); } catch (error) { alert('Error purging data: ' + error.message); } }); // Update all relative times function updateRelativeTimes() { document.querySelectorAll('.relative-time, .message-time').forEach(element => { const timestamp = element.getAttribute('data-timestamp'); if (timestamp) { element.textContent = ui.formatRelativeTime(timestamp); } }); } // Update data last received timer function updateDataTimer() { const timerElement = document.getElementById('data-timer'); if (!timerElement) return; const timeSinceData = Math.floor((Date.now() - state.lastDataReceived) / 1000); let timeText; if (timeSinceData < 1) { timeText = '0s ago'; } else if (timeSinceData < 60) { timeText = `${timeSinceData}s ago`; } else if (timeSinceData < 3600) { timeText = `${Math.floor(timeSinceData / 60)}m ago`; } else if (timeSinceData < 86400) { timeText = `${Math.floor(timeSinceData / 3600)}h ago`; } else { timeText = `${Math.floor(timeSinceData / 86400)}d ago`; } timerElement.textContent = timeText; // Visual warning if data is stale const dataLastReceivedElem = document.getElementById('data-last-received'); if (dataLastReceivedElem) { if (timeSinceData > 30) { dataLastReceivedElem.style.color = '#ff9800'; // Orange } else if (timeSinceData > 60) { dataLastReceivedElem.style.color = '#f44336'; // Red } else { dataLastReceivedElem.style.color = ''; // Default } } } // Update node online/offline badges function updateNodeBadges() { document.querySelectorAll('.node-card').forEach(card => { const lastHeardElement = card.querySelector('.relative-time'); if (!lastHeardElement) return; const lastHeardTimestamp = lastHeardElement.getAttribute('data-timestamp'); if (!lastHeardTimestamp) return; const lastHeard = new Date(lastHeardTimestamp); const isOnline = (Date.now() - lastHeard.getTime()) < 900000; // 15 minutes const badge = card.querySelector('.node-badge'); if (badge) { badge.className = `node-badge ${isOnline ? 'online' : 'offline'}`; badge.textContent = isOnline ? 'Online' : 'Offline'; } }); } // Update map marker opacity and relative times in popups function updateMapMarkerOpacity() { if (!state.map || Object.keys(state.markers).length === 0) return; Object.entries(state.markers).forEach(([nodeId, marker]) => { // Get the marker's popup content to extract timestamp const popupContent = marker.getPopup()?.getContent(); if (!popupContent) return; // Extract timestamp from popup const timestampMatch = popupContent.match(/data-timestamp="([^"]+)"/); if (!timestampMatch) return; const timestamp = timestampMatch[1]; const opacity = ui.getMarkerOpacity(timestamp); const color = ui.getMarkerColor(timestamp); // Update marker icon with new opacity and color (only if it changed significantly) const currentIcon = marker.getIcon(); const currentOpacity = currentIcon?.options?.html?.match(/opacity: ([\d.]+)/)?.[1]; const currentColor = currentIcon?.options?.html?.match(/background-color: ([^;]+);/)?.[1]; if (!currentOpacity || Math.abs(parseFloat(currentOpacity) - opacity) > 0.05 || currentColor !== color) { const icon = L.divIcon({ className: 'custom-marker', html: `
`, iconSize: [16, 16], iconAnchor: [8, 8] }); marker.setIcon(icon); } // Update relative time in popup without recreating the popup const relativeTime = ui.formatRelativeTime(timestamp); const updatedContent = popupContent.replace( /.*?<\/span>/, `${relativeTime}` ); // Only update if content changed if (updatedContent !== popupContent) { marker.getPopup().setContent(updatedContent); } }); } // Check connection health function checkConnectionHealth() { const timeSinceLastUpdate = Date.now() - state.lastSuccessfulUpdate; // If we haven't had a successful update in 10 seconds, show warning if (timeSinceLastUpdate > 10000 && !state.connectionWarningShown) { state.connectionWarningShown = true; showConnectionWarning(); } } // Show connection warning function showConnectionWarning() { const warningHtml = `
⚠️ Connection issue detected - Data may be stale
`; if (!document.getElementById('connection-warning')) { document.body.insertAdjacentHTML('beforeend', warningHtml); } } // Hide connection warning function hideConnectionWarning() { const warning = document.getElementById('connection-warning'); if (warning) { warning.remove(); } } // Show notification function showNotification(title, message, type = 'info') { const colors = { success: '#4CAF50', warning: '#ff9800', error: '#f44336', info: '#2196F3' }; const notificationHtml = `
${escapeHtml(title)}
${escapeHtml(message)}
`; const notification = document.createElement('div'); notification.innerHTML = notificationHtml; document.body.appendChild(notification.firstElementChild); // Auto-dismiss after 5 seconds setTimeout(() => { const el = document.querySelector('.notification'); if (el) { el.style.animation = 'slideIn 0.3s ease-out reverse'; setTimeout(() => el.remove(), 300); } }, 5000); } // Auto-refresh function startRefreshIntervals() { // Update relative times every second state.timeUpdateInterval = setInterval(updateRelativeTimes, 1000); // Update data timer every second state.refreshIntervals.push( setInterval(updateDataTimer, 1000) ); // Update node online/offline badges every second state.refreshIntervals.push( setInterval(updateNodeBadges, 1000) ); // Check connection health every 5 seconds state.refreshIntervals.push( setInterval(checkConnectionHealth, 5000) ); // Update map marker opacity every 5 seconds state.refreshIntervals.push( setInterval(updateMapMarkerOpacity, 5000) ); // Refresh current tab data every 3 seconds (faster for live feel) state.refreshIntervals.push( setInterval(() => { const activeTab = document.querySelector('.nav-tab.active')?.getAttribute('data-tab'); if (activeTab === 'overview') loadOverview(); if (activeTab === 'messages') loadMessages(); if (activeTab === 'nodes') loadNodes(); if (activeTab === 'map') loadMap(); }, 3000) ); // Refresh MQTT status every 3 seconds state.refreshIntervals.push( setInterval(updateMqttStatus, 3000) ); } function stopRefreshIntervals() { if (state.timeUpdateInterval) { clearInterval(state.timeUpdateInterval); state.timeUpdateInterval = null; } state.refreshIntervals.forEach(interval => clearInterval(interval)); state.refreshIntervals = []; } // Utility function to escape HTML function escapeHtml(text) { if (text === null || text === undefined) return ''; const div = document.createElement('div'); div.textContent = String(text); return div.innerHTML; } // Check authentication on page load (async function() { try { const authStatus = await api.checkAuth(); if (authStatus.authenticated) { state.authenticated = true; state.user = authStatus.user; initDashboard(); } else { ui.showScreen('login-screen'); } } catch (error) { ui.showScreen('login-screen'); } })();