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.
913
backup-from-device/gnss-guard/tm-gnss-guard/web/static/app.js
Normal file
@@ -0,0 +1,913 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/montserrat/v29/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Hw5aX8.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/montserrat/v29/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Hw0aX8.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/montserrat/v29/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtZ6Hw5aX8.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/montserrat/v29/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCu170w5aX8.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 618 B |
642
backup-from-device/gnss-guard/tm-gnss-guard/web/static/style.css
Normal file
@@ -0,0 +1,642 @@
|
||||
/* Local Montserrat font */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/montserrat-300.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/montserrat-400.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/montserrat-500.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/montserrat-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-main: #060b10;
|
||||
--bg-panel: #121821;
|
||||
--bg-card: #171f2b;
|
||||
--bg-card-alt: #1c2533;
|
||||
--border-subtle: #222b38;
|
||||
--text-main: #e5e9f5;
|
||||
--text-muted: #9aa3b8;
|
||||
--accent-red: #c62828;
|
||||
--accent-green: #1fad3a;
|
||||
--accent-amber: #ffa726;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* HEALTH STATUS BORDER FRAME */
|
||||
.health-border-frame {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
border: 0 solid transparent;
|
||||
transition: border-color 0.3s ease, border-width 0.3s ease;
|
||||
}
|
||||
.health-border-frame.warn {
|
||||
border: 6px solid var(--accent-amber);
|
||||
box-shadow: inset 0 0 30px rgba(255, 167, 38, 0.3);
|
||||
}
|
||||
.health-border-frame.crit {
|
||||
border: 6px solid var(--accent-red);
|
||||
box-shadow: inset 0 0 30px rgba(198, 40, 40, 0.4);
|
||||
animation: border-pulse-crit 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes border-pulse-crit {
|
||||
0%, 100% {
|
||||
box-shadow: inset 0 0 30px rgba(198, 40, 40, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: inset 0 0 50px rgba(198, 40, 40, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
background: var(--bg-main);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.header {
|
||||
background: #05080d;
|
||||
color: var(--text-main);
|
||||
padding: 14px 28px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.header-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
/* Status Pill Button - Touch-friendly alarm acknowledge button */
|
||||
.status-pill-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 999px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 48px; /* Touch-friendly minimum size */
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.status-pill-btn:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.status-pill-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.status-pill-btn.warn {
|
||||
background: var(--accent-amber);
|
||||
color: #111;
|
||||
}
|
||||
.status-pill-btn.crit {
|
||||
background: var(--accent-red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Speaker icon container - hidden by default, shown when alarm is active */
|
||||
.speaker-icon {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.speaker-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Hide muted icon by default, show sound icon */
|
||||
.speaker-icon .icon-muted {
|
||||
display: none;
|
||||
}
|
||||
.speaker-icon .icon-sound {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Show speaker icon container when alarm state (warn or crit) */
|
||||
.status-pill-btn.warn .speaker-icon,
|
||||
.status-pill-btn.crit .speaker-icon {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Pulsing animation for speaker icon when alarm is active (not acknowledged) */
|
||||
.status-pill-btn.alarm-active .speaker-icon {
|
||||
animation: speaker-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes speaker-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Acknowledged state - show muted icon, hide sound icon */
|
||||
.status-pill-btn.alarm-acknowledged .speaker-icon .icon-sound {
|
||||
display: none;
|
||||
}
|
||||
.status-pill-btn.alarm-acknowledged .speaker-icon .icon-muted {
|
||||
display: block;
|
||||
}
|
||||
.status-pill-btn.alarm-acknowledged .speaker-icon {
|
||||
animation: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Legacy support for non-button status pill */
|
||||
.status-pill {
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-pill.warn {
|
||||
background: var(--accent-amber);
|
||||
color: #111;
|
||||
}
|
||||
.status-pill.crit {
|
||||
background: var(--accent-red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ALERT BANNER */
|
||||
.alert-banner {
|
||||
padding: 10px 28px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.alert-banner.hidden {
|
||||
display: none;
|
||||
}
|
||||
.alert-critical {
|
||||
background: rgba(198, 40, 40, 0.18);
|
||||
color: #ffbdbd;
|
||||
}
|
||||
.alert-warning {
|
||||
background: rgba(255, 167, 38, 0.16);
|
||||
color: #ffe0b2;
|
||||
}
|
||||
.alert-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-red);
|
||||
box-shadow: 0 0 8px rgba(198,40,40,0.9);
|
||||
}
|
||||
|
||||
/* MAIN LAYOUT */
|
||||
.layout {
|
||||
display: flex;
|
||||
height: calc(100vh - 70px);
|
||||
background: radial-gradient(circle at top left, #182333 0, #05080d 55%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* STATUS TAB (desktop: left column with sources + event log) */
|
||||
.tab-status {
|
||||
width: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, #0d121b 0%, #04070d 100%);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* LEFT PANEL (sources area) */
|
||||
.left-panel {
|
||||
flex: 1;
|
||||
padding: 20px 20px 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.panel-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* CARDS */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
padding: 14px 14px 12px;
|
||||
margin-bottom: 16px;
|
||||
border-left: 4px solid var(--border-subtle);
|
||||
box-shadow: 0 16px 30px rgba(0,0,0,0.35);
|
||||
}
|
||||
.card.ok { border-color: var(--accent-green); }
|
||||
.card.warn { border-color: var(--accent-amber); }
|
||||
.card.crit { border-color: var(--accent-red); }
|
||||
.card.stale { border-color: var(--accent-amber); }
|
||||
.card.offline { border-color: #78909c; }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.badge-healthy {
|
||||
background: rgba(31,173,58,0.18);
|
||||
border-color: rgba(31,173,58,0.8);
|
||||
color: #c8ffcf;
|
||||
}
|
||||
.badge-clean {
|
||||
background: rgba(76,175,80,0.12);
|
||||
border-color: rgba(129,199,132,0.9);
|
||||
color: #e0ffea;
|
||||
}
|
||||
.badge-warning {
|
||||
background: rgba(255,167,38,0.14);
|
||||
border-color: rgba(255,183,77,0.9);
|
||||
color: #ffe0b2;
|
||||
}
|
||||
.badge-danger {
|
||||
background: rgba(198,40,40,0.18);
|
||||
border-color: rgba(229,115,115,0.9);
|
||||
color: #ffcdd2;
|
||||
}
|
||||
.badge-offline {
|
||||
background: rgba(120,144,156,0.14);
|
||||
border-color: rgba(144,164,174,0.9);
|
||||
color: #eceff1;
|
||||
}
|
||||
.badge-stale {
|
||||
background: rgba(255,167,38,0.14);
|
||||
border-color: rgba(255,183,77,0.9);
|
||||
color: #ffe0b2;
|
||||
}
|
||||
|
||||
.card-line {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.card-line strong {
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
.card-line.alert {
|
||||
color: #ffbdbd;
|
||||
}
|
||||
.card-line .stale-text,
|
||||
.stale-text {
|
||||
color: #ffd180;
|
||||
}
|
||||
|
||||
/* MAP */
|
||||
.map-panel {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
#map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map-overlay-legend {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
left: 16px;
|
||||
z-index: 1000;
|
||||
background: rgba(5,8,13,0.86);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.map-overlay-legend div {
|
||||
margin: 2px 0;
|
||||
}
|
||||
.legend-dot {
|
||||
display: inline-block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.legend-section {
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
margin-top: 6px;
|
||||
margin-bottom: 2px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.legend-section:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.legend-primary { background: #9c27b0; }
|
||||
.legend-ais { background: #82b1ff; }
|
||||
.legend-starlink-gps { background: #ffeb3b; }
|
||||
.legend-starlink-location { background: #2ecc71; }
|
||||
.legend-secondary { background: #b0bec5; }
|
||||
.legend-valid { background: #1fad3a; }
|
||||
.legend-degraded { background: #ffa726; }
|
||||
.legend-alert { background: #c62828; }
|
||||
|
||||
/* Route toggle */
|
||||
.map-route-toggle {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 16px;
|
||||
z-index: 1000;
|
||||
background: rgba(5,8,13,0.86);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.map-route-toggle label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.map-route-toggle input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Route point popup */
|
||||
.route-popup {
|
||||
font-size: 12px;
|
||||
}
|
||||
.route-popup .popup-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.route-popup .popup-row {
|
||||
margin: 2px 0;
|
||||
}
|
||||
.route-popup .popup-row strong {
|
||||
color: #666;
|
||||
}
|
||||
.route-popup .status-valid {
|
||||
background: rgba(31,173,58,0.2);
|
||||
color: #1fad3a;
|
||||
}
|
||||
.route-popup .status-degraded {
|
||||
background: rgba(255,167,38,0.2);
|
||||
color: #ffa726;
|
||||
}
|
||||
.route-popup .status-alert {
|
||||
background: rgba(198,40,40,0.2);
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
/* EVENT LOG */
|
||||
.event-log {
|
||||
height: auto;
|
||||
flex-shrink: 0;
|
||||
background: #05080d;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 8px 20px 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.event-log-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.event {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.event span.level {
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.event.level-crit span.level { color: #ff8a80; }
|
||||
.event.level-warn span.level { color: #ffd180; }
|
||||
.event.level-info span.level { color: #81d4fa; }
|
||||
|
||||
/* COPYRIGHT */
|
||||
.copyright {
|
||||
padding: 8px 20px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
background: #05080d;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Scrollbars (webkit) */
|
||||
.left-panel::-webkit-scrollbar,
|
||||
.event-log::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.left-panel::-webkit-scrollbar-thumb,
|
||||
.event-log::-webkit-scrollbar-thumb {
|
||||
background: #263244;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.left-panel::-webkit-scrollbar-track,
|
||||
.event-log::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* MOBILE TABS */
|
||||
.mobile-tabs {
|
||||
display: none;
|
||||
background: #05080d;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--text-main);
|
||||
border-bottom-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* Tab content visibility (desktop - map tab uses contents) */
|
||||
.tab-map {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
/* Show mobile tabs */
|
||||
.mobile-tabs {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Layout changes for portrait */
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 120px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Tab content behavior */
|
||||
.tab-status,
|
||||
.tab-map {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Status tab - scrollable container */
|
||||
.tab-status {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tab-status .left-panel {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tab-status .event-log {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Map tab - full height */
|
||||
.tab-map {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab-map .map-panel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Leaflet custom styles */
|
||||
.leaflet-container {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
background: var(--bg-main);
|
||||
}
|
||||
|
||||
/* Larger zoom controls (1.5x) */
|
||||
.leaflet-control-zoom a {
|
||||
width: 44px !important;
|
||||
height: 44px !important;
|
||||
line-height: 44px !important;
|
||||
font-size: 27px !important;
|
||||
}
|
||||
.leaflet-control-zoom {
|
||||
border-radius: 6px !important;
|
||||
}
|
||||