Refactor golden image handling in backup upload process</message>
<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.
This commit is contained in:
976
backup-from-device/gnss-guard/tm-gnss-guard/server/static/app.js
Normal file
976
backup-from-device/gnss-guard/tm-gnss-guard/server/static/app.js
Normal file
@@ -0,0 +1,976 @@
|
||||
/**
|
||||
* 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 =
|
||||
'<div class="asset-loading">Failed to load assets</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAssetList() {
|
||||
const container = document.getElementById('assetList');
|
||||
|
||||
if (assets.length === 0) {
|
||||
container.innerHTML = '<div class="asset-loading">No assets registered</div>';
|
||||
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 `
|
||||
<div class="asset-item ${isActive ? 'active' : ''} ${!asset.is_online ? 'offline' : ''}"
|
||||
onclick="selectAsset('${asset.name}')">
|
||||
<div class="asset-name">${asset.name}</div>
|
||||
<div class="asset-status">
|
||||
<span class="status-dot ${statusClass}"></span>
|
||||
<span>${asset.is_online ? 'Online' : 'Offline'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function populateMobileDropdown() {
|
||||
const select = document.getElementById('mobileAssetSelect');
|
||||
select.innerHTML = '<option value="">Select Asset...</option>' +
|
||||
assets.map(asset => `<option value="${asset.name}">${asset.name}</option>`).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 `
|
||||
<div class="card ${cardClass}">
|
||||
<div class="card-header">
|
||||
<div class="card-title">${source.display_name}</div>
|
||||
<div class="badge ${badgeClass}">${badgeText}</div>
|
||||
</div>
|
||||
<div class="card-line"><strong>Lat/Lon</strong>: ${coordsText}</div>
|
||||
<div class="card-line"><strong>Updated</strong>: <span class="${updateClass}">${updateText}</span></div>
|
||||
</div>
|
||||
`;
|
||||
}).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 `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">${sourceNames[sourceName]}</div>
|
||||
<div class="badge badge-offline">${badgeText}</div>
|
||||
</div>
|
||||
<div class="card-line"><strong>Lat/Lon</strong>: ${coordsText}</div>
|
||||
<div class="card-line"><strong>Updated</strong>: <span>${updateText}</span></div>
|
||||
</div>
|
||||
`;
|
||||
}).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 = `<b>${config.name}</b><br>Lat: ${origLat.toFixed(6)}<br>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 = `
|
||||
<div class="route-popup">
|
||||
<div class="popup-header">${formatTimestamp(point.timestamp)}</div>
|
||||
<div class="popup-row"><strong>Status:</strong> <span class="status-${point.status}">${statusText}</span></div>
|
||||
<div class="popup-row"><strong>Lat/Lon:</strong> ${point.latitude.toFixed(6)}, ${point.longitude.toFixed(6)}</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 = [];
|
||||
}
|
||||
|
||||
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 = `<span class="level">${levelMap[level]}</span> [${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';
|
||||
}
|
||||
}
|
||||
|
||||
1023
backup-from-device/gnss-guard/tm-gnss-guard/server/static/style.css
Normal file
1023
backup-from-device/gnss-guard/tm-gnss-guard/server/static/style.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user