/** * GNSS Guard Server - Dashboard JavaScript * Multi-asset monitoring with 72h route visualization */ // Global state let map = null; let currentAsset = null; let currentData = null; let assets = []; let routeMarkers = []; let sourceMarkers = {}; let showRouteEnabled = true; let lastFetchSucceeded = false; let lastValidationTimestamp = null; let isInitialMapLoad = true; // Only fit bounds on initial load or asset change // Time mode state let timeMode = 'now'; // 'now' or 'select' let selectedTimestamp = null; // Unix timestamp when in 'select' mode let autoRefreshInterval = null; // ============================================================================= // AUTO-REFRESH PAGE (every 1 hour to pick up deployments) // ============================================================================= const PAGE_LOAD_TIME = Date.now(); const AUTO_REFRESH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour let lastVisibilityCheck = Date.now(); function checkAutoRefresh() { const elapsed = Date.now() - PAGE_LOAD_TIME; if (elapsed >= AUTO_REFRESH_INTERVAL_MS) { console.log('Auto-refreshing page after 1 hour...'); window.location.reload(); } } // Check for refresh on visibility change (tab becomes active) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { const now = Date.now(); // Only check if at least 10 seconds since last check (prevents rapid refreshes) if (now - lastVisibilityCheck > 10000) { lastVisibilityCheck = now; checkAutoRefresh(); } } }); // Periodic check every 5 minutes while tab is active setInterval(checkAutoRefresh, 5 * 60 * 1000); // Marker icons for sources const iconPrimary = makeIcon('violet'); const iconSecondary = makeIcon('grey'); const iconAis = makeIcon('blue'); const iconStarlinkGps = makeIcon('yellow'); const iconStarlinkLocation = makeIcon('green'); const sourceConfig = { 'nmea_primary': { icon: iconPrimary, name: 'Primary GPS' }, 'nmea_secondary': { icon: iconSecondary, name: 'Secondary GPS' }, 'tm_ais': { icon: iconAis, name: 'TM AIS GPS' }, 'starlink_gps': { icon: iconStarlinkGps, name: 'Starlink GPS' }, 'starlink_location': { icon: iconStarlinkLocation, name: 'Starlink Location' } }; // Initialize on DOM ready document.addEventListener('DOMContentLoaded', () => { initMap(); initTabs(); initTimePicker(); loadAssets(); // Auto-refresh every 10 seconds (only when in 'now' mode) startAutoRefresh(); }); // ============================================================================= // MAP INITIALIZATION // ============================================================================= function initMap() { map = L.map('map', { zoomControl: true }).setView([34.665151, 33.016326], 11); // CartoDB Dark tiles L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19, attribution: '© OpenStreetMap & CARTO' }).addTo(map); // Recalculate marker offsets when zoom changes map.on('zoomend', () => { if (currentData) { updateMap(currentData); } }); } function makeIcon(color) { return new L.Icon({ iconUrl: `https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-${color}.png`, shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png', iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41] }); } // ============================================================================= // TABS (Mobile) // ============================================================================= function initTabs() { const tabButtons = document.querySelectorAll('.tab-btn'); tabButtons.forEach(btn => { btn.addEventListener('click', () => { const tabName = btn.dataset.tab; // Update button states tabButtons.forEach(b => b.classList.remove('active')); btn.classList.add('active'); // Update tab content document.querySelectorAll('.tab-content').forEach(tab => { tab.classList.remove('active'); }); document.getElementById(`tab-${tabName}`).classList.add('active'); // Invalidate map size when showing map tab if (tabName === 'map' && map) { setTimeout(() => map.invalidateSize(), 100); } }); }); } // ============================================================================= // TIME SELECTOR // ============================================================================= function initTimePicker() { // Set default datetime value to now const now = new Date(); const localDatetime = formatDatetimeLocal(now); const desktopPicker = document.getElementById('selectedDatetime'); const mobilePicker = document.getElementById('mobileSelectedDatetime'); if (desktopPicker) desktopPicker.value = localDatetime; if (mobilePicker) mobilePicker.value = localDatetime; } function formatDatetimeLocal(date) { // Format date as YYYY-MM-DDTHH:mm for datetime-local input const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day}T${hours}:${minutes}`; } function setTimeMode(mode) { timeMode = mode; // Update radio buttons (sync both desktop and mobile) document.querySelectorAll('input[name="timeMode"], input[name="timeModeM"]').forEach(radio => { radio.checked = radio.value === mode; }); // Show/hide datetime picker const pickers = ['datetimePicker', 'mobileDatetimePicker']; const displays = ['selectedTimeDisplay', 'mobileSelectedTimeDisplay']; pickers.forEach(id => { const el = document.getElementById(id); if (el) el.classList.toggle('hidden', mode === 'now'); }); if (mode === 'now') { // Hide the selected time display when switching to 'now' displays.forEach(id => { const el = document.getElementById(id); if (el) el.classList.add('hidden'); }); // Clear selected timestamp selectedTimestamp = null; // Reset map to fit bounds when switching to 'now' isInitialMapLoad = true; // Restart auto-refresh and fetch current data startAutoRefresh(); fetchData(); loadRouteData(); } else { // Stop auto-refresh when viewing historical data stopAutoRefresh(); } } function onDatetimeChange() { // Sync desktop and mobile pickers const desktopPicker = document.getElementById('selectedDatetime'); const mobilePicker = document.getElementById('mobileSelectedDatetime'); // Get the value from whichever picker was changed const value = desktopPicker?.value || mobilePicker?.value; if (desktopPicker) desktopPicker.value = value; if (mobilePicker) mobilePicker.value = value; } function applySelectedTime() { const desktopPicker = document.getElementById('selectedDatetime'); const value = desktopPicker?.value; if (!value) { alert('Please select a date and time'); return; } // Convert to Unix timestamp const date = new Date(value); selectedTimestamp = date.getTime() / 1000; // Update display const displayText = date.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }); const displays = ['selectedTimeDisplay', 'mobileSelectedTimeDisplay']; const textEls = ['selectedTimeText', 'mobileSelectedTimeText']; displays.forEach(id => { const el = document.getElementById(id); if (el) el.classList.remove('hidden'); }); textEls.forEach(id => { const el = document.getElementById(id); if (el) el.textContent = displayText; }); // Reset map to fit bounds when applying new time isInitialMapLoad = true; // Fetch historical data fetchData(); loadRouteData(); logEvent('info', `Viewing data at ${displayText}`); } function startAutoRefresh() { if (autoRefreshInterval) return; // Already running autoRefreshInterval = setInterval(() => { if (timeMode === 'now') { fetchData(); } }, 10000); } function stopAutoRefresh() { if (autoRefreshInterval) { clearInterval(autoRefreshInterval); autoRefreshInterval = null; } } function resetTimeMode() { // Reset to 'now' mode (called when switching assets) timeMode = 'now'; selectedTimestamp = null; // Update UI document.querySelectorAll('input[name="timeMode"], input[name="timeModeM"]').forEach(radio => { radio.checked = radio.value === 'now'; }); const pickers = ['datetimePicker', 'mobileDatetimePicker']; const displays = ['selectedTimeDisplay', 'mobileSelectedTimeDisplay']; pickers.forEach(id => { const el = document.getElementById(id); if (el) el.classList.add('hidden'); }); displays.forEach(id => { const el = document.getElementById(id); if (el) el.classList.add('hidden'); }); // Reset datetime picker to current time const now = new Date(); const localDatetime = formatDatetimeLocal(now); const desktopPicker = document.getElementById('selectedDatetime'); const mobilePicker = document.getElementById('mobileSelectedDatetime'); if (desktopPicker) desktopPicker.value = localDatetime; if (mobilePicker) mobilePicker.value = localDatetime; // Restart auto-refresh startAutoRefresh(); } // ============================================================================= // ASSET MANAGEMENT // ============================================================================= async function loadAssets() { try { const response = await fetch('/api/dashboard/assets'); if (!response.ok) throw new Error('Failed to load assets'); assets = await response.json(); renderAssetList(); populateMobileDropdown(); // Auto-select last asset if available (most recently added) if (assets.length > 0) { selectAsset(assets[assets.length - 1].name); } } catch (error) { console.error('Error loading assets:', error); document.getElementById('assetList').innerHTML = '
Failed to load assets
'; } } function renderAssetList() { const container = document.getElementById('assetList'); if (assets.length === 0) { container.innerHTML = '
No assets registered
'; return; } container.innerHTML = assets.map(asset => { // Determine status class: // - online + valid = green (online) // - online + invalid + distance alert = red (alert) // - online + invalid + no distance alert = amber (degraded) // - offline = gray (no class) let statusClass = ''; if (asset.is_online) { if (asset.is_valid === true) { statusClass = 'online'; // green } else if (asset.is_valid === false) { statusClass = asset.has_distance_alert ? 'alert' : 'degraded'; // red or amber } else { statusClass = 'online'; // null/unknown - assume ok } } const isActive = currentAsset === asset.name; return `
${asset.name}
${asset.is_online ? 'Online' : 'Offline'}
`; }).join(''); } function populateMobileDropdown() { const select = document.getElementById('mobileAssetSelect'); select.innerHTML = '' + assets.map(asset => ``).join(''); if (currentAsset) { select.value = currentAsset; } } function selectAsset(assetName) { if (!assetName) return; currentAsset = assetName; // Update UI renderAssetList(); document.getElementById('mobileAssetSelect').value = assetName; // Reset time mode to 'now' when switching assets resetTimeMode(); // Clear current data and fetch new currentData = null; clearSourceMarkers(); clearRouteMarkers(); isInitialMapLoad = true; // Reset to fit bounds for new asset // Show loading state immediately while fetching showLoadingState(); fetchData(); loadRouteData(); } // ============================================================================= // DATA FETCHING // ============================================================================= async function fetchData() { if (!currentAsset) return; try { // Build URL with optional timestamp parameter let url = `/api/dashboard/asset/${currentAsset}/status`; if (timeMode === 'select' && selectedTimestamp) { url += `?at=${selectedTimestamp}`; } const response = await fetch(url); if (!response.ok) { showDegradedState(`Server error: ${response.status}`); return; } const data = await response.json(); if (data.error) { showDegradedState(data.error); return; } currentData = data; lastFetchSucceeded = true; updateUI(data); updateMap(data); // Log event if validation timestamp changed (only in 'now' mode) if (timeMode === 'now' && data.validation_timestamp !== lastValidationTimestamp) { lastValidationTimestamp = data.validation_timestamp; if (data.has_alert && !data.is_valid && data.max_distance_km !== null) { logEvent('crit', `Alert: distance ${data.max_distance_km.toFixed(1)} km`); } else if (!data.is_valid) { logEvent('warn', 'Validation issue detected'); } else { logEvent('info', 'Cloud status OK'); } } } catch (error) { console.error('Fetch error:', error); showDegradedState('Connection failed: ' + error.message); } } async function loadRouteData() { if (!currentAsset) return; try { // Build URL with optional until parameter let url = `/api/dashboard/asset/${currentAsset}/route?hours=72`; if (timeMode === 'select' && selectedTimestamp) { url += `&until=${selectedTimestamp}`; } const response = await fetch(url); if (!response.ok) return; const routeData = await response.json(); renderRoute(routeData); } catch (error) { console.error('Error loading route:', error); } } // ============================================================================= // UI UPDATES // ============================================================================= /** * Update both GNSS status pills (desktop and mobile) */ function updateStatusPills(status, text) { const pills = [ document.getElementById('desktopStatusPill'), document.getElementById('mobileStatusPill') ]; pills.forEach(pill => { if (!pill) return; pill.classList.remove('ok', 'warn', 'crit'); pill.textContent = text; if (status) { pill.classList.add(status); } }); } function updateUI(data) { // Update GNSS status pills if (data.has_alert && data.max_distance_km !== null) { updateStatusPills('crit', 'GNSS Integrity: At Risk'); } else if (!data.is_valid) { updateStatusPills('warn', 'GNSS Integrity: Degraded'); } else { updateStatusPills('ok', 'GNSS Integrity: Stable'); } // Update alert banner const alertBanner = document.getElementById('alertBanner'); const alertDistance = document.getElementById('alert-distance-value'); if (data.has_alert && data.max_distance_km !== null) { alertBanner.classList.remove('hidden'); alertDistance.textContent = `${data.max_distance_km.toFixed(1)} km`; } else { alertBanner.classList.add('hidden'); } // Update sources - pass distance alert state const hasDistanceAlert = data.has_alert && data.max_distance_km !== null; renderSources(data.sources, hasDistanceAlert); } function renderSources(sources, hasDistanceAlert = false) { const container = document.getElementById('sourcesContainer'); const sourceOrder = ['nmea_primary', 'nmea_secondary', 'tm_ais', 'starlink_gps', 'starlink_location']; container.innerHTML = sourceOrder.map(sourceName => { const source = sources[sourceName]; if (!source) return ''; let cardClass = 'ok'; let badgeClass = 'badge-healthy'; let badgeText = 'HEALTHY'; let coordsText = 'Loading...'; let updateText = '-'; let updateClass = ''; if (!source.enabled) { cardClass = 'offline'; badgeClass = 'badge-offline'; badgeText = 'NOT CONFIGURED'; coordsText = 'No data source configured.'; } else if (source.status === 'missing') { cardClass = 'crit'; badgeClass = 'badge-danger'; badgeText = 'MISSING'; coordsText = 'No coordinates received.'; updateClass = 'stale-text'; } else if (source.status === 'stale' || source.is_stale) { cardClass = 'stale'; badgeClass = 'badge-stale'; badgeText = 'STALE'; if (source.coordinates) { coordsText = `${source.coordinates.latitude.toFixed(6)}, ${source.coordinates.longitude.toFixed(6)}`; } updateClass = 'stale-text'; } else { if (source.coordinates) { coordsText = `${source.coordinates.latitude.toFixed(6)}, ${source.coordinates.longitude.toFixed(6)}`; } // If distance alert and source has coordinates, mark as AT RISK if (hasDistanceAlert && source.coordinates) { cardClass = 'crit'; badgeClass = 'badge-danger'; badgeText = 'AT RISK'; } } if (source.last_update_unix) { updateText = formatRelativeTime(source.last_update_unix); } return `
${source.display_name}
${badgeText}
Lat/Lon: ${coordsText}
Updated: ${updateText}
`; }).join(''); } /** * Show loading state while fetching data for a new asset */ function showLoadingState() { // Show neutral loading status updateStatusPills(null, 'GNSS Integrity: Loading...'); // Hide alert banner document.getElementById('alertBanner').classList.add('hidden'); // Show placeholder source cards renderPlaceholderSources('loading'); } /** * Show state when asset has never pushed any validation data */ function showNoDataState() { lastFetchSucceeded = false; // Show neutral "no data" status updateStatusPills(null, 'GNSS Integrity: No Data'); // Hide alert banner document.getElementById('alertBanner').classList.add('hidden'); // Show placeholder source cards indicating awaiting data renderPlaceholderSources('nodata'); logEvent('warn', 'Asset has not pushed any validation data yet'); } /** * Render placeholder cards for all sources * @param {string} mode - 'loading' or 'nodata' */ function renderPlaceholderSources(mode) { const container = document.getElementById('sourcesContainer'); const sourceNames = { 'nmea_primary': 'Primary GPS', 'nmea_secondary': 'Secondary GPS', 'tm_ais': 'TM AIS GPS', 'starlink_gps': 'Starlink GPS', 'starlink_location': 'Starlink Location' }; const sourceOrder = ['nmea_primary', 'nmea_secondary', 'tm_ais', 'starlink_gps', 'starlink_location']; const isLoading = mode === 'loading'; const badgeText = isLoading ? 'LOADING' : 'AWAITING'; const coordsText = isLoading ? 'Loading...' : 'Awaiting first update...'; const updateText = isLoading ? '...' : '—'; container.innerHTML = sourceOrder.map(sourceName => { return `
${sourceNames[sourceName]}
${badgeText}
Lat/Lon: ${coordsText}
Updated: ${updateText}
`; }).join(''); } function showDegradedState(errorMessage) { lastFetchSucceeded = false; // Check if this is a "no data" error if (errorMessage && errorMessage.includes('No validation data')) { showNoDataState(); return; } // Update status pills to degraded state updateStatusPills('warn', 'GNSS Integrity: Degraded'); // Mark all update times as stale document.querySelectorAll('.card-line').forEach(line => { if (line.textContent.includes('Updated')) { const span = line.querySelector('span'); if (span) span.classList.add('stale-text'); } }); logEvent('crit', errorMessage); } // ============================================================================= // MAP UPDATES // ============================================================================= // Calculate offset for markers to spread them in a circle when close together function calculateMarkerOffsets(sourceCoords, zoomLevel) { if (Object.keys(sourceCoords).length <= 1) { // Single marker, no offset needed const result = {}; for (const [name, coord] of Object.entries(sourceCoords)) { result[name] = { lat: coord.lat, lon: coord.lon, offsetLat: 0, offsetLon: 0 }; } return result; } // Calculate centroid let sumLat = 0, sumLon = 0, count = 0; for (const coord of Object.values(sourceCoords)) { sumLat += coord.lat; sumLon += coord.lon; count++; } const centroidLat = sumLat / count; const centroidLon = sumLon / count; // Check if markers are close together (within ~50 meters) const closeThreshold = 0.0005; // ~50m in degrees let maxDist = 0; for (const coord of Object.values(sourceCoords)) { const dist = Math.sqrt( Math.pow(coord.lat - centroidLat, 2) + Math.pow(coord.lon - centroidLon, 2) ); maxDist = Math.max(maxDist, dist); } // If markers are spread out enough, don't offset if (maxDist > closeThreshold) { const result = {}; for (const [name, coord] of Object.entries(sourceCoords)) { result[name] = { lat: coord.lat, lon: coord.lon, offsetLat: 0, offsetLon: 0 }; } return result; } // Calculate offset radius based on zoom level (smaller offset when zoomed in) // At zoom 15, offset ~30m; at zoom 10, offset ~100m const baseOffset = 0.0003; // ~30m base offset const zoomFactor = Math.pow(2, 15 - Math.min(zoomLevel, 18)); const offsetRadius = baseOffset * zoomFactor; // Arrange markers in a circle around centroid const result = {}; const sourceNames = Object.keys(sourceCoords); const angleStep = (2 * Math.PI) / sourceNames.length; sourceNames.forEach((name, index) => { const angle = angleStep * index - Math.PI / 2; // Start from top const offsetLat = offsetRadius * Math.cos(angle); const offsetLon = offsetRadius * Math.sin(angle) * 1.5; // Adjust for latitude distortion result[name] = { lat: centroidLat + offsetLat, lon: centroidLon + offsetLon, offsetLat: offsetLat, offsetLon: offsetLon, originalLat: sourceCoords[name].lat, originalLon: sourceCoords[name].lon }; }); return result; } function updateMap(data) { clearSourceMarkers(); const sources = data.sources || {}; const allCoords = []; const sourceCoords = {}; // First pass: collect all valid coordinates Object.entries(sources).forEach(([sourceName, sourceData]) => { if (sourceData.coordinates && sourceData.coordinates.latitude && sourceData.coordinates.longitude) { const lat = sourceData.coordinates.latitude; const lon = sourceData.coordinates.longitude; if (sourceConfig[sourceName]) { sourceCoords[sourceName] = { lat, lon }; allCoords.push([lat, lon]); } } }); // Calculate offsets for overlapping markers const zoomLevel = map.getZoom() || 13; const offsetPositions = calculateMarkerOffsets(sourceCoords, zoomLevel); // Second pass: add markers with calculated positions Object.entries(sources).forEach(([sourceName, sourceData]) => { if (sourceData.coordinates && sourceData.coordinates.latitude && sourceData.coordinates.longitude) { const config = sourceConfig[sourceName]; const position = offsetPositions[sourceName]; if (config && position) { // Build popup with original coordinates const origLat = sourceData.coordinates.latitude; const origLon = sourceData.coordinates.longitude; const popupContent = `${config.name}
Lat: ${origLat.toFixed(6)}
Lon: ${origLon.toFixed(6)}`; const marker = L.marker([position.lat, position.lon], { icon: config.icon }) .bindPopup(popupContent) .addTo(map); sourceMarkers[sourceName] = marker; } } }); // Fit map to show all markers (only on initial load or asset change, not on refresh) if (isInitialMapLoad) { if (allCoords.length > 0) { const bounds = L.latLngBounds(allCoords); map.fitBounds(bounds, { padding: [50, 50], // Add padding around markers maxZoom: 15 // Don't zoom in too much when markers are close }); } else if (currentData && currentData.map_center && currentData.map_center.latitude && currentData.map_center.longitude) { // Fallback to center if no markers map.setView([currentData.map_center.latitude, currentData.map_center.longitude], 13); } isInitialMapLoad = false; // Don't auto-zoom on subsequent refreshes } } function clearSourceMarkers() { Object.values(sourceMarkers).forEach(marker => { map.removeLayer(marker); }); sourceMarkers = {}; } // ============================================================================= // ROUTE VISUALIZATION // ============================================================================= function renderRoute(routeData) { clearRouteMarkers(); if (!showRouteEnabled || !routeData || routeData.length === 0) return; // Create small circle markers for route points routeData.forEach(point => { let color; let statusText; switch (point.status) { case 'valid': color = '#1fad3a'; statusText = 'Valid'; break; case 'degraded': color = '#ffa726'; statusText = 'Degraded'; break; case 'alert': color = '#c62828'; statusText = 'Alert'; break; default: color = '#9aa3b8'; statusText = 'Unknown'; } const marker = L.circleMarker([point.latitude, point.longitude], { radius: 5, fillColor: color, color: color, weight: 1, opacity: 0.8, fillOpacity: 0.6 }).addTo(map); // Create detailed popup const popupContent = `
${point.sources_missing?.length ? `` : ''} ${point.sources_stale?.length ? `` : ''} ${point.max_distance_m > point.threshold_m ? `` : ''}
`; marker.bindPopup(popupContent); routeMarkers.push(marker); }); } function clearRouteMarkers() { routeMarkers.forEach(marker => { map.removeLayer(marker); }); routeMarkers = []; } function toggleRoute() { showRouteEnabled = document.getElementById('showRoute').checked; if (showRouteEnabled) { loadRouteData(); } else { clearRouteMarkers(); } } // ============================================================================= // EVENT LOGGING // ============================================================================= function logEvent(level, message) { const log = document.getElementById('eventLog'); const now = new Date(); const time = now.toTimeString().slice(0, 8); const levelMap = { 'info': 'INFO', 'warn': 'WARN', 'crit': 'CRIT' }; const event = document.createElement('div'); event.className = `event level-${level}`; event.innerHTML = `${levelMap[level]} [${time}] ${message}`; // Insert after title const title = log.querySelector('.event-log-title'); if (title.nextSibling) { log.insertBefore(event, title.nextSibling); } else { log.appendChild(event); } // Keep only 3 events const events = log.querySelectorAll('.event'); while (events.length > 3) { const lastEvent = log.querySelector('.event:last-of-type'); if (lastEvent) lastEvent.remove(); else break; } } // ============================================================================= // UTILITIES // ============================================================================= function formatRelativeTime(unixTimestamp) { const now = Date.now() / 1000; const diff = now - unixTimestamp; if (diff < 60) return `${Math.floor(diff)}s ago`; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; return `${Math.floor(diff / 86400)}d ago`; } function formatTimestamp(isoString) { const date = new Date(isoString); return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }); } // ============================================================================= // AUTHENTICATION // ============================================================================= async function logout() { try { await fetch('/logout', { method: 'POST' }); window.location.href = '/login'; } catch (error) { console.error('Logout error:', error); window.location.href = '/login'; } }