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:
nearxos
2026-02-24 00:19:40 +02:00
parent df180120aa
commit 808fbf5c7c
136 changed files with 407837 additions and 2 deletions

View File

@@ -0,0 +1,2 @@
"""Web server package for GNSS Guard dashboard"""

View File

@@ -0,0 +1,423 @@
#!/usr/bin/env python3
"""
Flask web server for GNSS Guard dashboard
Provides real-time monitoring of GPS sources with auto-refresh
"""
import json
import logging
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Any, Optional
from flask import Flask, render_template, jsonify, request
logger = logging.getLogger("gnss_guard.web")
class WebServer:
"""Web server for GNSS Guard dashboard"""
def __init__(self, config, database, buzzer_service=None):
"""
Initialize web server
Args:
config: Config instance
database: Database instance
buzzer_service: Optional BuzzerService instance for alarm control
"""
self.config = config
self.database = database
self.buzzer_service = buzzer_service
# Create Flask app
self.app = Flask(
__name__,
template_folder=str(Path(__file__).parent / "templates"),
static_folder=str(Path(__file__).parent / "static")
)
# Setup routes
self.app.add_url_rule('/', 'index', self.index)
self.app.add_url_rule('/api/status', 'api_status', self.api_status)
self.app.add_url_rule('/api/route', 'api_route', self.api_route)
self.app.add_url_rule('/api/alarm/acknowledge', 'api_alarm_acknowledge',
self.api_alarm_acknowledge, methods=['POST'])
self.app.add_url_rule('/api/alarm/status', 'api_alarm_status', self.api_alarm_status)
def index(self):
"""Render main dashboard page"""
return render_template('dashboard.html', show_route=self.config.web_show_route)
def api_status(self):
"""
API endpoint returning latest validation data
Returns:
JSON response with current status of all GPS sources
"""
try:
# Get latest validation record
conn = sqlite3.connect(str(self.database.database_path), check_same_thread=False, timeout=5.0)
cursor = conn.cursor()
cursor.execute(
"""
SELECT validation_timestamp, validation_timestamp_unix, is_valid,
sources_missing, sources_stale, coordinate_differences,
source_coordinates, validation_details
FROM positions_validation
ORDER BY validation_timestamp_unix DESC
LIMIT 1
"""
)
row = cursor.fetchone()
conn.close()
if not row:
return jsonify({
"error": "No validation data available",
"timestamp": datetime.now(timezone.utc).isoformat()
}), 404
# Parse row data
validation_timestamp = row[0]
validation_timestamp_unix = row[1]
is_valid = bool(row[2])
sources_missing = json.loads(row[3]) if row[3] else []
sources_stale = json.loads(row[4]) if row[4] else []
coordinate_differences = json.loads(row[5]) if row[5] else {}
source_coordinates = json.loads(row[6]) if row[6] else {}
validation_details = json.loads(row[7]) if row[7] else {}
# Get enabled sources from config
enabled_sources = self.config.get_enabled_sources()
# Source name mapping for display
source_display_names = {
"nmea_primary": "Primary GPS",
"nmea_secondary": "Secondary GPS",
"tm_ais": "TM AIS GPS",
"starlink_gps": "Starlink GPS",
"starlink_location": "Starlink Location"
}
# Build sources status
sources = {}
all_source_names = ["nmea_primary", "nmea_secondary", "tm_ais", "starlink_gps", "starlink_location"]
for source_name in all_source_names:
display_name = source_display_names.get(source_name, source_name)
# Check if source is enabled
if source_name not in enabled_sources:
sources[source_name] = {
"display_name": display_name,
"enabled": False,
"status": "not_configured",
"is_stale": False,
"coordinates": None,
"last_update": None,
"last_update_unix": None
}
continue
# Source is enabled
source_data = source_coordinates.get(source_name)
# Check if this source is stale
is_stale = source_name in sources_stale
if not source_data:
# Enabled but missing
sources[source_name] = {
"display_name": display_name,
"enabled": True,
"status": "missing",
"is_stale": is_stale,
"coordinates": None,
"last_update": None,
"last_update_unix": None
}
else:
# Has data - check if stale
status = "stale" if is_stale else "ok"
sources[source_name] = {
"display_name": display_name,
"enabled": True,
"status": status,
"is_stale": is_stale,
"coordinates": {
"latitude": source_data.get("latitude"),
"longitude": source_data.get("longitude")
},
"last_update": source_data.get("timestamp"),
"last_update_unix": source_data.get("timestamp_unix")
}
# Get threshold from validation_details
threshold_meters = validation_details.get("threshold_meters", 100.0)
# Calculate maximum distance for alert banner
max_distance_km = None
max_distance_m = 0.0
if not is_valid and coordinate_differences:
# Find maximum distance in coordinate_differences
for source_pair, diff_data in coordinate_differences.items():
if isinstance(diff_data, dict):
# Try both field names (distance_meters is the correct one)
distance = diff_data.get("distance_meters", diff_data.get("distance_m", 0))
if distance > max_distance_m:
max_distance_m = distance
# Only show distance alert if max distance exceeds threshold
if max_distance_m > threshold_meters:
max_distance_km = max_distance_m / 1000.0
# Determine alert state
# Show alert if validation fails AND distance exceeds threshold (GPS jamming/spoofing)
has_alert = (not is_valid and max_distance_km is not None) or len(sources_missing) > 0
# Find center coordinate for map (priority: nmea_primary > tm_ais > starlink_location)
map_center = None
for priority_source in ["nmea_primary", "tm_ais", "starlink_location"]:
if sources.get(priority_source, {}).get("coordinates"):
coords = sources[priority_source]["coordinates"]
if coords.get("latitude") and coords.get("longitude"):
map_center = coords
break
# If no priority source, use any available
if not map_center:
for source_name, source_data in sources.items():
if source_data.get("coordinates"):
coords = source_data["coordinates"]
if coords.get("latitude") and coords.get("longitude"):
map_center = coords
break
# Build response
response = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"validation_timestamp": validation_timestamp,
"validation_timestamp_unix": validation_timestamp_unix,
"is_valid": is_valid,
"has_alert": has_alert,
"max_distance_km": max_distance_km,
"threshold_meters": threshold_meters,
"sources": sources,
"sources_stale": sources_stale,
"map_center": map_center,
"asset_name": self.config.asset_name
}
return jsonify(response)
except Exception as e:
logger.error(f"Error in api_status: {e}")
return jsonify({
"error": str(e),
"timestamp": datetime.now(timezone.utc).isoformat()
}), 500
def api_route(self):
"""
API endpoint returning 24h route data for map visualization
Returns:
JSON array of route points with coordinates and validation status
"""
if not self.config.web_show_route:
return jsonify({"error": "Route feature is disabled"}), 403
try:
hours = 24
conn = sqlite3.connect(str(self.database.database_path), check_same_thread=False, timeout=5.0)
cursor = conn.cursor()
# Determine reference time for the 24h window
if self.config.demo_unit:
# Demo mode: use the PREVIOUS-TO-LAST historical record's timestamp as reference
# The last record is the "live" one that gets deleted, so we skip it
# Exclude recent "live" records (last 5 minutes) added during demo session
five_minutes_ago = time.time() - 300
cursor.execute(
"""
SELECT validation_timestamp_unix FROM positions_validation
WHERE validation_timestamp_unix < ?
ORDER BY validation_timestamp_unix DESC
LIMIT 1 OFFSET 1
""",
(five_minutes_ago,)
)
result = cursor.fetchone()
reference_time = result[0] if result and result[0] else time.time()
else:
# Normal mode: use current time as reference
reference_time = time.time()
# Get validations from the 24h window
cutoff_unix = reference_time - (hours * 3600)
cursor.execute(
"""
SELECT validation_timestamp, validation_timestamp_unix, is_valid,
sources_missing, sources_stale, source_coordinates, validation_details
FROM positions_validation
WHERE validation_timestamp_unix >= ? AND validation_timestamp_unix <= ?
ORDER BY validation_timestamp_unix DESC
""",
(cutoff_unix, reference_time)
)
rows = cursor.fetchall()
conn.close()
route_points = []
for row in rows:
validation_timestamp = row[0]
validation_timestamp_unix = row[1]
is_valid = bool(row[2])
sources_missing = json.loads(row[3]) if row[3] else []
sources_stale = json.loads(row[4]) if row[4] else []
source_coordinates = json.loads(row[5]) if row[5] else {}
validation_details = json.loads(row[6]) if row[6] else {}
# Find best coordinate (priority: nmea_primary > tm_ais > starlink_location)
coord = None
for priority_source in ["nmea_primary", "tm_ais", "starlink_location", "starlink_gps"]:
if priority_source in source_coordinates:
src_data = source_coordinates[priority_source]
if src_data.get("latitude") and src_data.get("longitude"):
coord = {
"latitude": src_data["latitude"],
"longitude": src_data["longitude"]
}
break
if not coord:
continue
# Determine status
threshold = validation_details.get("threshold_meters", 200)
max_distance = validation_details.get("max_difference_meters", 0)
if not is_valid and max_distance > threshold:
status = "alert"
elif sources_missing or sources_stale:
status = "degraded"
else:
status = "valid"
route_points.append({
"lat": coord["latitude"],
"lng": coord["longitude"],
"timestamp": validation_timestamp,
"timestamp_unix": validation_timestamp_unix,
"status": status,
"is_valid": is_valid,
"sources_missing": sources_missing,
"sources_stale": sources_stale,
"max_distance_m": max_distance,
"threshold_m": threshold
})
return jsonify(route_points)
except Exception as e:
logger.error(f"Error in api_route: {e}")
return jsonify({
"error": str(e),
"timestamp": datetime.now(timezone.utc).isoformat()
}), 500
def api_alarm_acknowledge(self):
"""
API endpoint to acknowledge the buzzer alarm.
POST /api/alarm/acknowledge
Returns:
JSON response with acknowledgment status
"""
try:
if not self.buzzer_service:
return jsonify({
"success": False,
"error": "Buzzer service not available",
"alarm_active": False,
"alarm_acknowledged": False
}), 503
# Acknowledge the alarm
was_active = self.buzzer_service.is_alarm_active()
acknowledged = self.buzzer_service.acknowledge_alarm()
logger.info(f"Alarm acknowledge request: was_active={was_active}, acknowledged={acknowledged}")
return jsonify({
"success": True,
"acknowledged": acknowledged,
"alarm_active": self.buzzer_service.is_alarm_active(),
"alarm_acknowledged": self.buzzer_service.is_alarm_acknowledged(),
"timestamp": datetime.now(timezone.utc).isoformat()
})
except Exception as e:
logger.error(f"Error in api_alarm_acknowledge: {e}")
return jsonify({
"success": False,
"error": str(e),
"timestamp": datetime.now(timezone.utc).isoformat()
}), 500
def api_alarm_status(self):
"""
API endpoint to get current alarm status.
GET /api/alarm/status
Returns:
JSON response with alarm status
"""
try:
if not self.buzzer_service:
return jsonify({
"available": False,
"alarm_active": False,
"alarm_acknowledged": False,
"buzzer_status": "unavailable"
})
return jsonify({
"available": True,
"alarm_active": self.buzzer_service.is_alarm_active(),
"alarm_acknowledged": self.buzzer_service.is_alarm_acknowledged(),
"buzzer_status": self.buzzer_service.get_status(),
"timestamp": datetime.now(timezone.utc).isoformat()
})
except Exception as e:
logger.error(f"Error in api_alarm_status: {e}")
return jsonify({
"available": False,
"error": str(e),
"timestamp": datetime.now(timezone.utc).isoformat()
}), 500
def run(self, host='0.0.0.0', port=8080, debug=False):
"""
Run the Flask web server
Args:
host: Host to bind to
port: Port to bind to
debug: Enable debug mode
"""
logger.info(f"Starting web server on {host}:{port}")
self.app.run(host=host, port=port, debug=debug, threaded=True)

View 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: '&copy; 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;
}
}

View File

@@ -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>Thats an error.</ins>
<p>The requested URL <code>/s/montserrat/v29/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Hw5aX8.woff2</code> was not found on this server. <ins>Thats all we know.</ins>

View File

@@ -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>Thats an error.</ins>
<p>The requested URL <code>/s/montserrat/v29/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Hw0aX8.woff2</code> was not found on this server. <ins>Thats all we know.</ins>

View File

@@ -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>Thats an error.</ins>
<p>The requested URL <code>/s/montserrat/v29/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtZ6Hw5aX8.woff2</code> was not found on this server. <ins>Thats all we know.</ins>

View File

@@ -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>Thats an error.</ins>
<p>The requested URL <code>/s/montserrat/v29/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCu170w5aX8.woff2</code> was not found on this server. <ins>Thats all we know.</ins>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

View File

@@ -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;
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

View 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;
}

View File

@@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TM GNSS Guard</title>
<!-- LEAFLET (local) -->
<link rel="stylesheet" href="{{ url_for('static', filename='leaflet/leaflet.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v={{ range(1000000) | random }}">
</head>
<body data-show-route="{{ show_route|tojson }}">
<!-- HEALTH STATUS BORDER FRAME (full viewport) -->
<div class="health-border-frame" id="healthBorderFrame"></div>
<!-- HEADER -->
<div class="header">
<div class="header-left">
<div class="header-title">TM GNSS Guard</div>
<div class="header-sub">GNSS Spoofing &amp; Jamming Monitoring Console</div>
</div>
<div class="header-right">
<button class="status-pill-btn" id="globalStatusPill" onclick="acknowledgeAlarm()" title="Click to acknowledge alarm and mute buzzer">
<span class="status-text">GNSS Integrity: Stable</span>
<span class="speaker-icon" id="speakerIcon">
<!-- Speaker/Sound icon (SVG) - shown when alarm is active -->
<svg class="icon-sound" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path>
</svg>
<!-- Muted Speaker icon (SVG) - shown when alarm is acknowledged/muted -->
<svg class="icon-muted" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<line x1="22" y1="9" x2="16" y2="15"></line>
<line x1="16" y1="9" x2="22" y2="15"></line>
</svg>
</span>
</button>
</div>
</div>
<!-- ALERT BANNER (dynamic) -->
<div class="alert-banner alert-critical hidden" id="alertBanner">
<div class="alert-indicator" id="alertIndicator"></div>
<div id="alertText">GPS Jamming or Spoofing Alert! Location Distance: <span id="alert-distance-value">-</span></div>
</div>
<!-- MOBILE TAB BAR (only visible in portrait mode) -->
<div class="mobile-tabs">
<button class="tab-btn active" data-tab="status">Status</button>
<button class="tab-btn" data-tab="map">Map</button>
</div>
<!-- MAIN LAYOUT -->
<div class="layout">
<!-- STATUS TAB CONTENT (Sources + Event Log) -->
<div class="tab-content tab-status active" id="tab-status">
<div class="left-panel">
<div class="panel-title">GNSS Sources</div>
<!-- PRIMARY GPS -->
<div class="card ok" id="card-nmea_primary">
<div class="card-header">
<div class="card-title">Primary GPS</div>
<div class="badge badge-healthy" id="badge-nmea_primary">HEALTHY</div>
</div>
<div class="card-line"><strong>Lat/Lon</strong>: <span id="coords-nmea_primary">Loading...</span></div>
<div class="card-line"><strong>Updated</strong>: <span id="update-nmea_primary">-</span></div>
</div>
<!-- SECONDARY GPS -->
<div class="card warn" id="card-nmea_secondary">
<div class="card-header">
<div class="card-title">Secondary GPS</div>
<div class="badge badge-offline" id="badge-nmea_secondary">NOT CONFIGURED</div>
</div>
<div class="card-line"><strong>Lat/Lon</strong>: <span id="coords-nmea_secondary">No data source configured.</span></div>
<div class="card-line"><strong>Updated</strong>: <span id="update-nmea_secondary">-</span></div>
</div>
<!-- TM AIS GPS -->
<div class="card ok" id="card-tm_ais">
<div class="card-header">
<div class="card-title">TM AIS GPS</div>
<div class="badge badge-healthy" id="badge-tm_ais">HEALTHY</div>
</div>
<div class="card-line"><strong>Lat/Lon</strong>: <span id="coords-tm_ais">Loading...</span></div>
<div class="card-line"><strong>Updated</strong>: <span id="update-tm_ais">-</span></div>
</div>
<!-- STARLINK GPS -->
<div class="card ok" id="card-starlink_gps">
<div class="card-header">
<div class="card-title">Starlink GPS</div>
<div class="badge badge-healthy" id="badge-starlink_gps">HEALTHY</div>
</div>
<div class="card-line"><strong>Lat/Lon</strong>: <span id="coords-starlink_gps">Loading...</span></div>
<div class="card-line"><strong>Updated</strong>: <span id="update-starlink_gps">-</span></div>
</div>
<!-- STARLINK LOCATION -->
<div class="card ok" id="card-starlink_location">
<div class="card-header">
<div class="card-title">Starlink Location</div>
<div class="badge badge-healthy" id="badge-starlink_location">HEALTHY</div>
</div>
<div class="card-line"><strong>Lat/Lon</strong>: <span id="coords-starlink_location">Loading...</span></div>
<div class="card-line"><strong>Updated</strong>: <span id="update-starlink_location">-</span></div>
</div>
</div>
<!-- EVENT LOG (inside status tab for mobile) -->
<div class="event-log" id="eventLog">
<div class="event-log-title">Event Stream</div>
</div>
<!-- COPYRIGHT -->
<div class="copyright">Tototheo Global © 2025</div>
</div>
<!-- MAP TAB CONTENT -->
<div class="tab-content tab-map" id="tab-map">
<div class="map-panel">
<div id="map"></div>
<div class="map-overlay-legend">
<div class="legend-section">Sources</div>
<div><span class="legend-dot legend-primary"></span>Primary GPS</div>
<div><span class="legend-dot legend-secondary"></span>Secondary GPS</div>
<div><span class="legend-dot legend-ais"></span>TM AIS GPS</div>
<div><span class="legend-dot legend-starlink-gps"></span>Starlink GPS</div>
<div><span class="legend-dot legend-starlink-location"></span>Starlink Location</div>
{% if show_route %}
<div class="legend-section">24h Route</div>
<div><span class="legend-dot legend-valid"></span>Valid</div>
<div><span class="legend-dot legend-degraded"></span>Degraded</div>
<div><span class="legend-dot legend-alert"></span>Alert</div>
{% endif %}
</div>
{% if show_route %}
<div class="map-route-toggle">
<label>
<input type="checkbox" id="showRoute" checked onchange="toggleRoute()">
Show 24h Route
</label>
</div>
{% endif %}
</div>
</div>
</div>
<script src="{{ url_for('static', filename='leaflet/leaflet.js') }}"></script>
<script>
// Config passed from server
window.SHOW_ROUTE = document.body.dataset.showRoute === 'true';
</script>
<script src="{{ url_for('static', filename='app.js') }}?v={{ range(1000000) | random }}"></script>
</body>
</html>