<message>Update the _set_golden_from_path function to improve the handling of existing golden image files. Replace the existing unlink logic with a more robust method that safely removes files or broken symlinks using the missing_ok parameter. This change enhances the reliability of the backup upload process by ensuring that stale references are properly cleared before setting a new golden image path.
914 lines
32 KiB
JavaScript
914 lines
32 KiB
JavaScript
// TM GNSS Guard - Dashboard JavaScript
|
|
// Auto-refreshes data every 10 seconds and updates the UI
|
|
|
|
let map = null;
|
|
let markers = {};
|
|
let currentData = null;
|
|
let lastFetchSucceeded = false;
|
|
let lastValidationTimestamp = null;
|
|
|
|
// Route visualization
|
|
let routeMarkers = [];
|
|
let routeCoords = []; // Store route coordinates for bounds calculation
|
|
let showRouteEnabled = getRoutePreference(); // Load from localStorage
|
|
let isInitialMapLoad = true; // Only fit bounds on initial load
|
|
|
|
// Get route preference from localStorage (defaults to true if not set)
|
|
function getRoutePreference() {
|
|
if (!window.SHOW_ROUTE) return false;
|
|
const stored = localStorage.getItem('showRoute');
|
|
return stored === null ? true : stored === 'true';
|
|
}
|
|
|
|
// Save route preference to localStorage
|
|
function saveRoutePreference(enabled) {
|
|
localStorage.setItem('showRoute', enabled ? 'true' : 'false');
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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);
|
|
|
|
// Initialize map
|
|
function initMap() {
|
|
// Create map centered on default location
|
|
map = L.map('map', {
|
|
zoomControl: true
|
|
}).setView([34.665151, 33.016326], 11);
|
|
|
|
// Dark basemap
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Create marker icons (using local files)
|
|
function makeIcon(color) {
|
|
return new L.Icon({
|
|
iconUrl: `/static/markers/marker-icon-${color}.png`,
|
|
shadowUrl: '/static/markers/marker-shadow.png',
|
|
iconSize: [25, 41],
|
|
iconAnchor: [12, 41],
|
|
popupAnchor: [1, -34]
|
|
});
|
|
}
|
|
|
|
const iconPrimary = makeIcon('violet');
|
|
const iconPrimaryAlert = makeIcon('red');
|
|
const iconAIS = makeIcon('blue');
|
|
const iconStarlinkGps = makeIcon('yellow');
|
|
const iconStarlinkLocation = makeIcon('green');
|
|
const iconSecondary = makeIcon('grey');
|
|
|
|
// Format timestamp as relative time
|
|
function formatRelativeTime(timestampUnix) {
|
|
if (!timestampUnix) return '-';
|
|
|
|
const now = Date.now() / 1000;
|
|
const diff = now - timestampUnix;
|
|
|
|
if (diff < 0) return 'just now';
|
|
if (diff < 60) return `${Math.floor(diff)} sec ago`;
|
|
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`;
|
|
return `${Math.floor(diff / 86400)} days ago`;
|
|
}
|
|
|
|
// Format UTC timestamp for display
|
|
function formatUTCTimestamp(isoString) {
|
|
if (!isoString) return '-';
|
|
|
|
const date = new Date(isoString);
|
|
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
const year = date.getUTCFullYear();
|
|
const hours = String(date.getUTCHours()).padStart(2, '0');
|
|
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
|
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
|
|
|
|
return `${day}/${month}/${year} ${hours}:${minutes}:${seconds}`;
|
|
}
|
|
|
|
// Log event to event log
|
|
function logEvent(level, text) {
|
|
const eventLog = document.getElementById('eventLog');
|
|
if (!eventLog) return;
|
|
|
|
const div = document.createElement('div');
|
|
div.className = 'event level-' + level;
|
|
const spanLevel = document.createElement('span');
|
|
spanLevel.className = 'level';
|
|
spanLevel.textContent = level.toUpperCase();
|
|
div.appendChild(spanLevel);
|
|
const spanText = document.createElement('span');
|
|
const timestamp = new Date().toTimeString().slice(0, 8);
|
|
spanText.textContent = ' [' + timestamp + '] ' + text;
|
|
div.appendChild(spanText);
|
|
eventLog.appendChild(div);
|
|
|
|
// Limit to last 3 events (re-query each iteration since querySelectorAll is static)
|
|
while (eventLog.querySelectorAll('.event').length > 3) {
|
|
const firstEvent = eventLog.querySelector('.event');
|
|
if (firstEvent) {
|
|
eventLog.removeChild(firstEvent);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update source card
|
|
function updateSource(sourceName, sourceData) {
|
|
const card = document.getElementById(`card-${sourceName}`);
|
|
const badge = document.getElementById(`badge-${sourceName}`);
|
|
const coordsEl = document.getElementById(`coords-${sourceName}`);
|
|
const updateEl = document.getElementById(`update-${sourceName}`);
|
|
|
|
if (!card || !badge) return;
|
|
|
|
// Reset card classes
|
|
card.classList.remove('ok', 'warn', 'crit', 'stale', 'offline');
|
|
badge.className = 'badge';
|
|
|
|
if (!sourceData.enabled) {
|
|
// Not configured
|
|
card.classList.add('offline');
|
|
badge.classList.add('badge-offline');
|
|
badge.textContent = 'NOT CONFIGURED';
|
|
if (coordsEl) {
|
|
coordsEl.textContent = 'No data source configured.';
|
|
}
|
|
if (updateEl) {
|
|
updateEl.textContent = '-';
|
|
updateEl.className = '';
|
|
}
|
|
} else if (sourceData.status === 'missing') {
|
|
// Missing data
|
|
card.classList.add('crit');
|
|
badge.classList.add('badge-danger');
|
|
badge.textContent = 'MISSING';
|
|
if (coordsEl) {
|
|
coordsEl.textContent = 'MISSING';
|
|
coordsEl.className = 'alert';
|
|
}
|
|
if (updateEl) {
|
|
updateEl.textContent = formatRelativeTime(sourceData.last_update_unix);
|
|
updateEl.className = 'alert';
|
|
}
|
|
} else if (sourceData.status === 'stale' || sourceData.is_stale) {
|
|
// Stale data - has coordinates but timestamp is old
|
|
card.classList.add('stale');
|
|
badge.classList.add('badge-stale');
|
|
badge.textContent = 'STALE';
|
|
|
|
if (coordsEl && sourceData.coordinates) {
|
|
const lat = sourceData.coordinates.latitude;
|
|
const lon = sourceData.coordinates.longitude;
|
|
coordsEl.textContent = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
|
|
coordsEl.className = '';
|
|
}
|
|
if (updateEl) {
|
|
updateEl.textContent = formatRelativeTime(sourceData.last_update_unix);
|
|
updateEl.className = 'stale-text';
|
|
}
|
|
} else {
|
|
// Has valid data
|
|
card.classList.add('ok');
|
|
badge.classList.add('badge-healthy');
|
|
badge.textContent = 'HEALTHY';
|
|
|
|
if (coordsEl && sourceData.coordinates) {
|
|
const lat = sourceData.coordinates.latitude;
|
|
const lon = sourceData.coordinates.longitude;
|
|
coordsEl.textContent = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
|
|
coordsEl.className = '';
|
|
}
|
|
if (updateEl) {
|
|
updateEl.textContent = formatRelativeTime(sourceData.last_update_unix);
|
|
updateEl.className = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Update map markers
|
|
function updateMap(data) {
|
|
if (!data.map_center) return;
|
|
|
|
const sourceMapping = {
|
|
'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' }
|
|
};
|
|
|
|
// Collect all valid source coordinates
|
|
const sourceCoords = {};
|
|
const allCoords = [];
|
|
|
|
Object.keys(sourceMapping).forEach(sourceName => {
|
|
const sourceData = data.sources[sourceName];
|
|
if (sourceData && sourceData.enabled && sourceData.status !== 'missing' && sourceData.coordinates) {
|
|
const lat = sourceData.coordinates.latitude;
|
|
const lon = sourceData.coordinates.longitude;
|
|
sourceCoords[sourceName] = { lat, lon };
|
|
allCoords.push([lat, lon]);
|
|
}
|
|
});
|
|
|
|
// Calculate offsets for overlapping markers
|
|
const zoomLevel = map.getZoom() || 13;
|
|
const offsetPositions = calculateMarkerOffsets(sourceCoords, zoomLevel);
|
|
|
|
// Update or create markers for each source
|
|
Object.keys(sourceMapping).forEach(sourceName => {
|
|
const sourceData = data.sources[sourceName];
|
|
if (!sourceData || !sourceData.enabled || sourceData.status === 'missing' || !sourceData.coordinates) {
|
|
// Remove marker if it exists
|
|
if (markers[sourceName]) {
|
|
map.removeLayer(markers[sourceName]);
|
|
delete markers[sourceName];
|
|
}
|
|
return;
|
|
}
|
|
|
|
const mapping = sourceMapping[sourceName];
|
|
const position = offsetPositions[sourceName];
|
|
|
|
// Use alert icon for primary if there's an alert
|
|
let icon = mapping.icon;
|
|
if (sourceName === 'nmea_primary' && data.has_alert && !data.is_valid) {
|
|
icon = iconPrimaryAlert;
|
|
}
|
|
|
|
// Build popup with original coordinates
|
|
const origLat = sourceData.coordinates.latitude;
|
|
const origLon = sourceData.coordinates.longitude;
|
|
const popupContent = `<b>${mapping.name}</b><br>` +
|
|
`Lat: ${origLat.toFixed(6)}<br>` +
|
|
`Lon: ${origLon.toFixed(6)}`;
|
|
|
|
if (markers[sourceName]) {
|
|
markers[sourceName].setLatLng([position.lat, position.lon]).setIcon(icon);
|
|
markers[sourceName].setPopupContent(popupContent);
|
|
} else {
|
|
markers[sourceName] = L.marker([position.lat, position.lon], { icon: icon })
|
|
.addTo(map)
|
|
.bindPopup(popupContent);
|
|
}
|
|
});
|
|
|
|
// Fit map to show all markers (only on initial load, not on refresh)
|
|
// If route is enabled, wait for route data to load before fitting bounds
|
|
if (isInitialMapLoad && !window.SHOW_ROUTE) {
|
|
if (allCoords.length > 0) {
|
|
const bounds = L.latLngBounds(allCoords);
|
|
map.fitBounds(bounds, {
|
|
padding: [50, 50],
|
|
maxZoom: 15
|
|
});
|
|
} else if (data.map_center && data.map_center.latitude && data.map_center.longitude) {
|
|
map.setView([data.map_center.latitude, data.map_center.longitude], 13);
|
|
}
|
|
isInitialMapLoad = false;
|
|
}
|
|
}
|
|
|
|
// Track alarm state
|
|
let alarmActive = false;
|
|
let alarmAcknowledged = false;
|
|
|
|
// Update global status pill and border frame
|
|
function updateGlobalStatus(data) {
|
|
const statusPill = document.getElementById('globalStatusPill');
|
|
const statusText = statusPill ? statusPill.querySelector('.status-text') : null;
|
|
const borderFrame = document.getElementById('healthBorderFrame');
|
|
|
|
if (statusPill) {
|
|
statusPill.classList.remove('warn', 'crit', 'alarm-active', 'alarm-acknowledged');
|
|
}
|
|
if (borderFrame) {
|
|
borderFrame.classList.remove('warn', 'crit');
|
|
}
|
|
|
|
if (data.is_valid) {
|
|
if (statusText) {
|
|
statusText.textContent = 'GNSS Integrity: Stable';
|
|
} else if (statusPill) {
|
|
statusPill.textContent = 'GNSS Integrity: Stable';
|
|
}
|
|
// Border frame hidden (no class) when healthy
|
|
// Reset alarm state when status returns to healthy
|
|
alarmActive = false;
|
|
alarmAcknowledged = false;
|
|
} else if (data.has_alert && data.max_distance_km !== null) {
|
|
if (statusText) {
|
|
statusText.textContent = 'GNSS Integrity: At Risk';
|
|
} else if (statusPill) {
|
|
statusPill.textContent = 'GNSS Integrity: At Risk';
|
|
}
|
|
if (statusPill) {
|
|
statusPill.classList.add('crit');
|
|
}
|
|
if (borderFrame) {
|
|
borderFrame.classList.add('crit');
|
|
}
|
|
// Alarm should be active in this state
|
|
if (!alarmAcknowledged) {
|
|
alarmActive = true;
|
|
}
|
|
} else {
|
|
if (statusText) {
|
|
statusText.textContent = 'GNSS Integrity: Degraded';
|
|
} else if (statusPill) {
|
|
statusPill.textContent = 'GNSS Integrity: Degraded';
|
|
}
|
|
if (statusPill) {
|
|
statusPill.classList.add('warn');
|
|
}
|
|
if (borderFrame) {
|
|
borderFrame.classList.add('warn');
|
|
}
|
|
// Alarm should be active in this state
|
|
if (!alarmAcknowledged) {
|
|
alarmActive = true;
|
|
}
|
|
}
|
|
|
|
// Update alarm visual state
|
|
updateAlarmVisualState();
|
|
}
|
|
|
|
// Update alarm visual state on the button
|
|
function updateAlarmVisualState() {
|
|
const statusPill = document.getElementById('globalStatusPill');
|
|
if (!statusPill) return;
|
|
|
|
statusPill.classList.remove('alarm-active', 'alarm-acknowledged');
|
|
|
|
if (alarmActive && !alarmAcknowledged) {
|
|
statusPill.classList.add('alarm-active');
|
|
} else if (alarmAcknowledged) {
|
|
statusPill.classList.add('alarm-acknowledged');
|
|
}
|
|
}
|
|
|
|
// Acknowledge alarm - called when user clicks the status button
|
|
async function acknowledgeAlarm() {
|
|
const statusPill = document.getElementById('globalStatusPill');
|
|
|
|
// Only allow acknowledgment if alarm is active
|
|
if (!alarmActive && !statusPill.classList.contains('warn') && !statusPill.classList.contains('crit')) {
|
|
console.log('No active alarm to acknowledge');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/alarm/acknowledge', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
console.log('Alarm acknowledged:', data);
|
|
|
|
alarmAcknowledged = true;
|
|
alarmActive = false;
|
|
updateAlarmVisualState();
|
|
|
|
logEvent('info', 'Alarm acknowledged - buzzer muted');
|
|
} else {
|
|
console.error('Failed to acknowledge alarm:', response.statusText);
|
|
logEvent('warn', 'Failed to acknowledge alarm');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error acknowledging alarm:', error);
|
|
logEvent('warn', 'Error acknowledging alarm: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Fetch alarm status from server
|
|
async function fetchAlarmStatus() {
|
|
try {
|
|
const response = await fetch('/api/alarm/status');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
alarmActive = data.alarm_active;
|
|
alarmAcknowledged = data.alarm_acknowledged;
|
|
updateAlarmVisualState();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching alarm status:', error);
|
|
}
|
|
}
|
|
|
|
// Update alert banner
|
|
function updateAlertBanner(data) {
|
|
const banner = document.getElementById('alert-banner');
|
|
const alertText = document.getElementById('alertText');
|
|
const alertDistance = document.getElementById('alert-distance-value');
|
|
const alertIndicator = document.getElementById('alertIndicator');
|
|
|
|
if (!banner) return;
|
|
|
|
banner.classList.add('hidden');
|
|
banner.classList.remove('alert-critical', 'alert-warning');
|
|
|
|
if (data.has_alert && !data.is_valid && data.max_distance_km !== null) {
|
|
// Show distance alert
|
|
banner.classList.remove('hidden');
|
|
banner.classList.add('alert-critical');
|
|
if (alertDistance) {
|
|
alertDistance.textContent = `${data.max_distance_km.toFixed(1)} km`;
|
|
}
|
|
if (alertText) {
|
|
alertText.textContent = `GPS Jamming or Spoofing Alert! Location Distance: ${data.max_distance_km.toFixed(1)} km`;
|
|
}
|
|
if (alertIndicator) {
|
|
alertIndicator.style.background = 'var(--accent-red)';
|
|
}
|
|
} else if (data.has_alert) {
|
|
// Has alert but not distance-based (missing sources)
|
|
banner.classList.remove('hidden');
|
|
banner.classList.add('alert-warning');
|
|
if (alertText) {
|
|
alertText.textContent = 'Some GPS sources are missing or stale.';
|
|
}
|
|
if (alertIndicator) {
|
|
alertIndicator.style.background = 'var(--accent-amber)';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to show degraded state
|
|
function showDegradedState(errorMessage) {
|
|
lastFetchSucceeded = false;
|
|
|
|
// Update status pill and border frame to degraded FIRST (before logging which could fail)
|
|
try {
|
|
const statusPill = document.getElementById('globalStatusPill');
|
|
const statusText = statusPill ? statusPill.querySelector('.status-text') : null;
|
|
if (statusPill) {
|
|
if (statusText) {
|
|
statusText.textContent = 'GNSS Integrity: Degraded';
|
|
} else {
|
|
statusPill.textContent = 'GNSS Integrity: Degraded';
|
|
}
|
|
statusPill.classList.remove('crit');
|
|
statusPill.classList.add('warn');
|
|
// Activate alarm state
|
|
if (!alarmAcknowledged) {
|
|
alarmActive = true;
|
|
updateAlarmVisualState();
|
|
}
|
|
}
|
|
const borderFrame = document.getElementById('healthBorderFrame');
|
|
if (borderFrame) {
|
|
borderFrame.classList.remove('crit');
|
|
borderFrame.classList.add('warn');
|
|
}
|
|
} catch (e) {
|
|
console.error('Error updating status pill:', e);
|
|
}
|
|
|
|
// Mark all "Updated" timestamps as stale/error (red)
|
|
try {
|
|
const sources = ['nmea_primary', 'nmea_secondary', 'tm_ais', 'starlink_gps', 'starlink_location'];
|
|
sources.forEach(sourceName => {
|
|
const updateEl = document.getElementById(`update-${sourceName}`);
|
|
if (updateEl) {
|
|
updateEl.classList.add('stale-text');
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error('Error updating source timestamps:', e);
|
|
}
|
|
|
|
// Log the error message last
|
|
logEvent('crit', errorMessage);
|
|
}
|
|
|
|
// Fetch and update data
|
|
async function fetchData() {
|
|
try {
|
|
const response = await fetch('/api/status');
|
|
if (!response.ok) {
|
|
console.error('Failed to fetch status:', response.statusText);
|
|
showDegradedState(`Server error: ${response.status} ${response.statusText}`);
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
currentData = data;
|
|
lastFetchSucceeded = true;
|
|
|
|
// Update all sources
|
|
const sources = ['nmea_primary', 'nmea_secondary', 'tm_ais', 'starlink_gps', 'starlink_location'];
|
|
sources.forEach(sourceName => {
|
|
updateSource(sourceName, data.sources[sourceName]);
|
|
});
|
|
|
|
// If distance-based alert (GPS jamming/spoofing), mark ALL enabled sources as "AT RISK"
|
|
if (data.has_alert && !data.is_valid && data.max_distance_km !== null) {
|
|
sources.forEach(sourceName => {
|
|
const sourceData = data.sources[sourceName];
|
|
// Only mark sources that have coordinates (participated in distance validation)
|
|
if (sourceData && sourceData.enabled && sourceData.status !== 'missing' && sourceData.coordinates) {
|
|
const card = document.getElementById(`card-${sourceName}`);
|
|
const badge = document.getElementById(`badge-${sourceName}`);
|
|
if (card) {
|
|
card.classList.remove('ok', 'stale');
|
|
card.classList.add('crit');
|
|
}
|
|
if (badge) {
|
|
badge.className = 'badge badge-danger';
|
|
badge.textContent = 'AT RISK';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Capture initial load flag before updateMap modifies it
|
|
const wasInitialLoad = isInitialMapLoad;
|
|
|
|
// Update map
|
|
updateMap(data);
|
|
|
|
// Update global status
|
|
updateGlobalStatus(data);
|
|
|
|
// Update alert banner
|
|
updateAlertBanner(data);
|
|
|
|
// Load route data if enabled (fit bounds on initial load)
|
|
if (window.SHOW_ROUTE && showRouteEnabled) {
|
|
loadRouteData(wasInitialLoad);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching data:', error);
|
|
showDegradedState('Failed to fetch status data: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Update relative times
|
|
function updateRelativeTimes() {
|
|
if (!currentData) return;
|
|
|
|
const sources = ['nmea_primary', 'nmea_secondary', 'tm_ais', 'starlink_gps', 'starlink_location'];
|
|
sources.forEach(sourceName => {
|
|
const sourceData = currentData.sources[sourceName];
|
|
const updateEl = document.getElementById(`update-${sourceName}`);
|
|
|
|
if (updateEl && sourceData && sourceData.enabled && sourceData.last_update_unix) {
|
|
updateEl.textContent = formatRelativeTime(sourceData.last_update_unix);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Tab switching for mobile
|
|
function initTabs() {
|
|
const tabBtns = document.querySelectorAll('.tab-btn');
|
|
const tabContents = document.querySelectorAll('.tab-content');
|
|
|
|
tabBtns.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const tabName = btn.dataset.tab;
|
|
|
|
// Update button states
|
|
tabBtns.forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
|
|
// Update content visibility
|
|
tabContents.forEach(content => {
|
|
content.classList.remove('active');
|
|
if (content.id === `tab-${tabName}`) {
|
|
content.classList.add('active');
|
|
}
|
|
});
|
|
|
|
// Invalidate map size when switching to map tab
|
|
if (tabName === 'map' && map) {
|
|
setTimeout(() => {
|
|
map.invalidateSize();
|
|
}, 100);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Initialize on page load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Initialize tabs
|
|
initTabs();
|
|
|
|
// Initialize map
|
|
initMap();
|
|
|
|
// Initialize route checkbox from stored preference
|
|
initRouteCheckbox();
|
|
|
|
// Initial log
|
|
logEvent('info', 'TM GNSS Guard dashboard initialized.');
|
|
|
|
// Initial data fetch
|
|
fetchData();
|
|
|
|
// Initial alarm status fetch
|
|
fetchAlarmStatus();
|
|
|
|
// Update relative times every second
|
|
setInterval(updateRelativeTimes, 1000);
|
|
|
|
// Fetch data every 10 seconds
|
|
setInterval(fetchData, 10000);
|
|
|
|
// Fetch alarm status every 5 seconds (more frequent to stay in sync with buzzer)
|
|
setInterval(fetchAlarmStatus, 5000);
|
|
|
|
// Log data fetch events (only when data actually changed)
|
|
setInterval(() => {
|
|
if (currentData && lastFetchSucceeded) {
|
|
// Only log if validation timestamp changed (new data from backend)
|
|
if (currentData.validation_timestamp !== lastValidationTimestamp) {
|
|
lastValidationTimestamp = currentData.validation_timestamp;
|
|
|
|
if (currentData.has_alert && !currentData.is_valid && currentData.max_distance_km !== null) {
|
|
logEvent('crit', `Server reports alert: distance ${currentData.max_distance_km.toFixed(1)} km`);
|
|
} else if (!currentData.is_valid) {
|
|
logEvent('warn', 'Server reports validation issue.');
|
|
} else {
|
|
logEvent('info', 'Server status OK.');
|
|
}
|
|
}
|
|
}
|
|
}, 10000);
|
|
});
|
|
|
|
// =============================================================================
|
|
// ROUTE VISUALIZATION (24h history)
|
|
// =============================================================================
|
|
|
|
async function loadRouteData(fitBoundsAfter = false) {
|
|
if (!window.SHOW_ROUTE) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/route');
|
|
if (!response.ok) {
|
|
// If route fetch fails on initial load, still fit bounds to source markers
|
|
if (fitBoundsAfter) {
|
|
fitBoundsToMarkers();
|
|
isInitialMapLoad = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const routeData = await response.json();
|
|
renderRoute(routeData);
|
|
|
|
// Fit bounds to include route on initial load
|
|
if (fitBoundsAfter) {
|
|
// Collect current source marker coordinates
|
|
const sourceCoords = [];
|
|
Object.values(markers).forEach(marker => {
|
|
const latlng = marker.getLatLng();
|
|
sourceCoords.push([latlng.lat, latlng.lng]);
|
|
});
|
|
|
|
// Use all route points (full 24h) for bounds calculation
|
|
const routePointCoords = [];
|
|
routeData.forEach(point => {
|
|
if (point.lat && point.lng) {
|
|
routePointCoords.push([point.lat, point.lng]);
|
|
}
|
|
});
|
|
|
|
const allCoords = [...sourceCoords, ...routePointCoords];
|
|
if (allCoords.length > 0) {
|
|
const bounds = L.latLngBounds(allCoords);
|
|
map.fitBounds(bounds, {
|
|
padding: [50, 50],
|
|
maxZoom: 15
|
|
});
|
|
} else {
|
|
fitBoundsToMarkers();
|
|
}
|
|
isInitialMapLoad = false;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error loading route:', error);
|
|
// If route fetch fails on initial load, still fit bounds to source markers
|
|
if (fitBoundsAfter) {
|
|
fitBoundsToMarkers();
|
|
isInitialMapLoad = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function fitBoundsToMarkers() {
|
|
const sourceCoords = [];
|
|
Object.values(markers).forEach(marker => {
|
|
const latlng = marker.getLatLng();
|
|
sourceCoords.push([latlng.lat, latlng.lng]);
|
|
});
|
|
|
|
if (sourceCoords.length > 0) {
|
|
const bounds = L.latLngBounds(sourceCoords);
|
|
map.fitBounds(bounds, {
|
|
padding: [50, 50],
|
|
maxZoom: 15
|
|
});
|
|
} else if (currentData && currentData.map_center) {
|
|
map.setView([currentData.map_center.latitude, currentData.map_center.longitude], 13);
|
|
}
|
|
}
|
|
|
|
function renderRoute(routeData) {
|
|
clearRouteMarkers();
|
|
routeCoords = []; // Reset route coordinates
|
|
|
|
if (!showRouteEnabled || !routeData || routeData.length === 0) return;
|
|
|
|
// Create small circle markers for route points
|
|
routeData.forEach(point => {
|
|
if (!point.lat || !point.lng) return;
|
|
|
|
// Store coordinates for bounds calculation
|
|
routeCoords.push([point.lat, point.lng]);
|
|
|
|
// Determine color based on status
|
|
let color;
|
|
switch (point.status) {
|
|
case 'alert':
|
|
color = '#c62828'; // Red
|
|
break;
|
|
case 'degraded':
|
|
color = '#ffa726'; // Amber
|
|
break;
|
|
default:
|
|
color = '#1fad3a'; // Green
|
|
}
|
|
|
|
const marker = L.circleMarker([point.lat, point.lng], {
|
|
radius: 4,
|
|
fillColor: color,
|
|
color: color,
|
|
weight: 1,
|
|
opacity: 0.8,
|
|
fillOpacity: 0.6
|
|
}).addTo(map);
|
|
|
|
// Add popup with details
|
|
const popupContent = `
|
|
<div class="route-popup">
|
|
<div class="popup-header status-${point.status}">${point.status.toUpperCase()}</div>
|
|
<div class="popup-row"><strong>Time:</strong> ${formatUTCTimestamp(point.timestamp)}</div>
|
|
${point.sources_missing?.length ? `<div class="popup-row"><strong>Missing:</strong> ${point.sources_missing.join(', ')}</div>` : ''}
|
|
${point.sources_stale?.length ? `<div class="popup-row"><strong>Stale:</strong> ${point.sources_stale.join(', ')}</div>` : ''}
|
|
${point.max_distance_m > point.threshold_m ? `<div class="popup-row"><strong>Distance:</strong> ${(point.max_distance_m/1000).toFixed(2)} km</div>` : ''}
|
|
</div>
|
|
`;
|
|
marker.bindPopup(popupContent);
|
|
|
|
routeMarkers.push(marker);
|
|
});
|
|
}
|
|
|
|
function clearRouteMarkers() {
|
|
routeMarkers.forEach(marker => {
|
|
map.removeLayer(marker);
|
|
});
|
|
routeMarkers = [];
|
|
routeCoords = [];
|
|
}
|
|
|
|
function toggleRoute() {
|
|
const checkbox = document.getElementById('showRoute');
|
|
showRouteEnabled = checkbox ? checkbox.checked : false;
|
|
|
|
// Persist the preference
|
|
saveRoutePreference(showRouteEnabled);
|
|
|
|
if (showRouteEnabled) {
|
|
loadRouteData();
|
|
} else {
|
|
clearRouteMarkers();
|
|
}
|
|
}
|
|
|
|
// Initialize route checkbox state from localStorage
|
|
function initRouteCheckbox() {
|
|
const checkbox = document.getElementById('showRoute');
|
|
if (checkbox) {
|
|
checkbox.checked = showRouteEnabled;
|
|
}
|
|
}
|