// Application State
const state = {
authenticated: false,
user: null,
map: null,
markers: {},
trails: {}, // Track position trail polylines for each node
nodeLastHeard: {}, // Track last heard times for flash detection
nodeLastColor: {}, // Track last assigned color to prevent flipping
refreshIntervals: [],
timeUpdateInterval: null,
lastSuccessfulUpdate: Date.now(),
lastDataReceived: Date.now(), // Track when actual data was received
mqttConnected: true,
connectionWarningShown: false,
mapUserMoved: false, // Track if user has moved the map
knownMessages: new Set(), // Track known message IDs
knownNodes: new Map(), // Track known nodes with their data
reconnectAttempts: 0,
maxReconnectAttempts: 5,
reconnectDelay: 5000, // 5 seconds
isReconnecting: false,
channelConfig: {} // Channel name configuration
};
// Handle authentication errors (401)
function handleAuthenticationError() {
if (!state.authenticated) return; // Already logged out
console.log('Session expired - logging out');
state.authenticated = false;
state.user = null;
stopRefreshIntervals();
// Show notification
alert('Your session has expired. Please log in again.');
// Redirect to login
ui.showScreen('login-screen');
}
// Handle connection lost (network errors)
function handleConnectionLost() {
if (state.isReconnecting) return; // Already trying to reconnect
state.isReconnecting = true;
// Show notification
if (!state.connectionWarningShown) {
showNotification('Connection Lost', 'Attempting to reconnect to server...', 'warning');
}
// Try to reconnect
attemptReconnect();
}
// Attempt to reconnect to the server
async function attemptReconnect() {
if (state.reconnectAttempts >= state.maxReconnectAttempts) {
state.isReconnecting = false;
showNotification('Connection Failed', 'Unable to reconnect. Please refresh the page.', 'error');
return;
}
state.reconnectAttempts++;
console.log(`Reconnection attempt ${state.reconnectAttempts} of ${state.maxReconnectAttempts}`);
// Wait before attempting
await new Promise(resolve => setTimeout(resolve, state.reconnectDelay));
try {
// Try to check auth status to test connection
const response = await fetch('/api/auth/status', {
credentials: 'include'
});
if (response.ok) {
console.log('Reconnected to server');
state.reconnectAttempts = 0;
state.isReconnecting = false;
showNotification('Connection Restored', 'Successfully reconnected to server', 'success');
// Reload current tab data
const activeTab = document.querySelector('.nav-tab.active')?.getAttribute('data-tab');
if (activeTab === 'overview') loadOverview();
if (activeTab === 'messages') loadMessages();
if (activeTab === 'nodes') loadNodes();
if (activeTab === 'map') loadMap();
} else if (response.status === 401) {
// Session expired
handleAuthenticationError();
state.isReconnecting = false;
} else {
// Still can't connect, try again
attemptReconnect();
}
} catch (error) {
// Still can't connect, try again
attemptReconnect();
}
}
// API Helper
const api = {
async request(url, options = {}) {
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
},
credentials: 'include'
});
// Handle 401 Unauthorized - session expired or invalid
if (response.status === 401) {
console.warn('Authentication required - session expired');
handleAuthenticationError();
throw new Error('Authentication required');
}
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(error.error || 'Request failed');
}
return response.json();
} catch (error) {
// Check if it's a network error (connection lost)
if (error.message === 'Failed to fetch' || error.name === 'TypeError') {
console.warn('Connection to server lost - attempting to reconnect');
handleConnectionLost();
}
throw error;
}
},
async login(username, password) {
return this.request('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
},
async logout() {
return this.request('/api/logout', { method: 'POST' });
},
async checkAuth() {
return this.request('/api/auth/status');
},
async getStats() {
return this.request('/api/stats');
},
async getNodes() {
return this.request('/api/nodes');
},
async getNode(nodeId) {
return this.request(`/api/nodes/${nodeId}`);
},
async getPositions() {
return this.request('/api/positions');
},
async getPositionTrails(limit = 10) {
return this.request(`/api/positions/trails/all?limit=${limit}`);
},
async getMessages(limit = 100) {
return this.request(`/api/messages?limit=${limit}`);
},
async sendMessage(from, text, channel) {
return this.request('/api/messages/send', {
method: 'POST',
body: JSON.stringify({ from, text, channel })
});
},
async purgeData(hours) {
return this.request('/api/purge', {
method: 'POST',
body: JSON.stringify({ hours })
});
},
async getMqttStatus() {
return this.request('/api/mqtt/status');
},
async getChannelConfig() {
return this.request('/api/config/channels');
}
};
// UI Helper
const ui = {
showScreen(screenId) {
document.querySelectorAll('.screen').forEach(screen => {
screen.classList.add('hidden');
});
document.getElementById(screenId).classList.remove('hidden');
},
showTab(tabName) {
document.querySelectorAll('.nav-tab').forEach(tab => {
tab.classList.remove('active');
});
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
document.querySelector(`[data-tab="${tabName}"]`)?.classList.add('active');
document.getElementById(`${tabName}-tab`)?.classList.add('active');
// Reinitialize map when tab becomes visible
if (tabName === 'map') {
setTimeout(() => {
if (state.map) {
state.map.invalidateSize();
} else {
initMap();
}
}, 50);
}
},
showError(elementId, message) {
const element = document.getElementById(elementId);
if (element) {
element.textContent = message;
element.style.display = 'block';
}
},
hideError(elementId) {
const element = document.getElementById(elementId);
if (element) {
element.textContent = '';
element.style.display = 'none';
}
},
formatRelativeTime(dateString) {
if (!dateString) return 'Never';
// Handle SQLite datetime format (YYYY-MM-DD HH:MM:SS)
// Convert to ISO format if needed
let dateStr = dateString;
if (dateString.includes(' ') && !dateString.includes('T')) {
dateStr = dateString.replace(' ', 'T') + 'Z';
}
const date = new Date(dateStr);
// Check if date is valid
if (isNaN(date.getTime())) {
console.warn('Invalid date:', dateString);
return 'Invalid date';
}
const now = new Date();
const diff = Math.floor((now - date) / 1000); // seconds
if (diff < 1) return '0s ago';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return date.toLocaleDateString();
},
formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
},
getMarkerOpacity(lastHeardDate) {
if (!lastHeardDate) return 0.3;
const now = new Date();
const lastHeard = new Date(lastHeardDate);
const hoursSince = (now - lastHeard) / (1000 * 60 * 60);
if (hoursSince < 1) return 1.0;
if (hoursSince < 6) return 0.8;
if (hoursSince < 24) return 0.6;
if (hoursSince < 72) return 0.4;
return 0.2;
},
getMarkerColor(lastHeardDate, nodeId) {
if (!lastHeardDate) return '#a50026'; // Darkest red for unknown
// Handle SQLite datetime format
let dateStr = lastHeardDate;
if (lastHeardDate.includes(' ') && !lastHeardDate.includes('T')) {
dateStr = lastHeardDate.replace(' ', 'T') + 'Z';
}
const now = new Date();
const lastHeard = new Date(dateStr);
const minutesSince = (now - lastHeard) / (1000 * 60);
// Custom color scheme: Pure green -> Yellow -> Orange -> Red
const colors = [
'#00ff00', // Pure green - 0-45 min
'#9db800', // Darker yellow-green - 45-90 min
'#cccc00', // Darker yellow - 90-135 min
'#fee08b', // Yellow-orange - 135-180 min
'#fdae61', // Orange - 180-225 min
'#f46d43', // Light red - 225-270 min
'#d73027', // Red - 270-315 min
'#a50026' // Dark red - 315-360 min (6h+)
];
// 8 buckets over 360 minutes = 45 minutes per bucket
const bucketSize = 360 / 8;
const bucketIndex = Math.min(7, Math.floor(minutesSince / bucketSize));
const newColor = colors[bucketIndex];
// Apply hysteresis: only change color if we're at least 2 minutes into the new bucket
// This prevents flipping at boundaries
if (nodeId && state.nodeLastColor[nodeId]) {
const lastColor = state.nodeLastColor[nodeId];
const lastIndex = colors.indexOf(lastColor);
// If we're in a different bucket, check if we're far enough in
if (lastIndex !== -1 && lastIndex !== bucketIndex) {
const minutesIntoBucket = minutesSince % bucketSize;
const minutesFromBoundary = Math.min(minutesIntoBucket, bucketSize - minutesIntoBucket);
// Need to be at least 2 minutes away from boundary to change color
if (minutesFromBoundary < 2) {
return lastColor; // Keep the old color
}
}
}
// Store the color for this node
if (nodeId) {
state.nodeLastColor[nodeId] = newColor;
}
return newColor;
},
getChannelName(channelNumber) {
return state.channelConfig[channelNumber] || `Channel ${channelNumber}`;
},
// Hash a string to a color
hashStringToColor(str) {
if (!str) return '#2563eb'; // Default primary color
// Simple hash function
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash; // Convert to 32bit integer
}
// Convert to HSL for better color distribution
// Use hue from hash, fixed saturation and lightness for readability
const hue = Math.abs(hash % 360);
const saturation = 65; // 65% saturation for vibrant colors
const lightness = 45; // 45% lightness for good contrast with white text
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
},
getChannelColor(channelNumber) {
const channelName = this.getChannelName(channelNumber);
return this.hashStringToColor(channelName);
}
};
// Login Handler
document.getElementById('login-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
ui.hideError('login-error');
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const result = await api.login(username, password);
if (result.success) {
state.authenticated = true;
state.user = result.user;
initDashboard();
}
} catch (error) {
ui.showError('login-error', error.message);
}
});
// Logout Handler
document.getElementById('logout-btn')?.addEventListener('click', async () => {
try {
await api.logout();
state.authenticated = false;
state.user = null;
stopRefreshIntervals();
ui.showScreen('login-screen');
} catch (error) {
console.error('Logout error:', error);
}
});
// Tab Navigation
document.querySelectorAll('.nav-tab').forEach(tab => {
tab.addEventListener('click', () => {
const tabName = tab.getAttribute('data-tab');
ui.showTab(tabName);
// Load tab content
if (tabName === 'overview') loadOverview();
if (tabName === 'map') loadMap();
if (tabName === 'messages') loadMessages();
if (tabName === 'nodes') loadNodes();
});
});
// Initialize Dashboard
async function initDashboard() {
ui.showScreen('dashboard-screen');
// Set user info
document.getElementById('user-info').textContent = state.user.username;
// Load channel configuration
try {
state.channelConfig = await api.getChannelConfig();
populateChannelSelector();
} catch (error) {
console.error('Error loading channel config:', error);
// Use defaults if config fails to load
state.channelConfig = {
0: 'LongFast',
1: 'Aether',
2: 'Channel 2',
3: 'Channel 3',
4: 'Channel 4',
5: 'Channel 5',
6: 'Channel 6',
7: 'Channel 7'
};
}
// Update MQTT status
updateMqttStatus();
// Load initial content
loadOverview();
// Start auto-refresh
startRefreshIntervals();
}
// Populate channel selector dropdown
function populateChannelSelector() {
const channelSelect = document.getElementById('message-channel');
if (!channelSelect) return;
// Clear existing options
channelSelect.innerHTML = '';
// Add options from config
Object.entries(state.channelConfig).forEach(([channelNum, channelName]) => {
const option = document.createElement('option');
option.value = channelNum;
option.textContent = channelName;
channelSelect.appendChild(option);
});
}
// Update MQTT Status
async function updateMqttStatus() {
try {
const status = await api.getMqttStatus();
const statusElement = document.querySelector('#mqtt-status .status-dot');
// Update connection state
state.lastSuccessfulUpdate = Date.now();
state.connectionWarningShown = false;
hideConnectionWarning();
if (status.connected) {
statusElement?.classList.add('connected');
if (!state.mqttConnected) {
// MQTT reconnected
state.mqttConnected = true;
showNotification('MQTT Connected', 'Connection to MQTT broker restored', 'success');
}
} else {
statusElement?.classList.remove('connected');
if (state.mqttConnected) {
// MQTT disconnected
state.mqttConnected = false;
showNotification('MQTT Disconnected', 'Connection to MQTT broker lost', 'warning');
}
}
} catch (error) {
console.error('Error fetching MQTT status:', error);
checkConnectionHealth();
}
}
// Load Overview
async function loadOverview() {
try {
const stats = await api.getStats();
document.getElementById('stat-nodes').textContent = stats.nodes;
document.getElementById('stat-messages').textContent = stats.messages;
document.getElementById('stat-positions').textContent = stats.positions;
document.getElementById('stat-db-size').textContent = ui.formatBytes(stats.databaseSize);
// Load recent messages
const messages = await api.getMessages(10);
displayMessages(messages, 'recent-messages');
// Update last data received timestamp
state.lastDataReceived = Date.now();
// Reset reconnect attempts on successful load
if (state.reconnectAttempts > 0) {
state.reconnectAttempts = 0;
state.isReconnecting = false;
}
} catch (error) {
console.error('Error loading overview:', error);
}
}
// Load Messages (24 hours or 1000 messages, whichever is less)
async function loadMessages() {
try {
const messages = await api.getMessages(1000);
displayMessages(messages, 'message-history');
state.lastDataReceived = Date.now();
// Reset reconnect attempts on successful load
if (state.reconnectAttempts > 0) {
state.reconnectAttempts = 0;
state.isReconnecting = false;
}
} catch (error) {
console.error('Error loading messages:', error);
}
}
// Display Messages
function displayMessages(messages, containerId) {
const container = document.getElementById(containerId);
if (!container) return;
if (messages.length === 0) {
container.innerHTML = '
';
return;
}
// Build a map of message IDs to messages
const messageMap = new Map();
messages.forEach(msg => {
const msgId = msg.id || `${msg.from_node}-${msg.created_at}`;
messageMap.set(msgId, msg);
});
// Get existing message elements
const existingMessages = container.querySelectorAll('.message-item');
const existingIds = new Set();
existingMessages.forEach(elem => {
const msgId = elem.getAttribute('data-message-id');
if (msgId) existingIds.add(msgId);
});
// Remove messages that are no longer in the list (older than limit)
existingMessages.forEach(elem => {
const msgId = elem.getAttribute('data-message-id');
if (msgId && !messageMap.has(msgId)) {
elem.remove();
}
});
// Add new messages or update existing ones
messages.forEach((msg, index) => {
const msgId = msg.id || `${msg.from_node}-${msg.created_at}`;
if (!existingIds.has(msgId)) {
// Create new message element
const channelColor = msg.channel !== null && msg.channel !== undefined ? ui.getChannelColor(msg.channel) : '#2563eb';
const messageHtml = `
${escapeHtml(msg.text || '')}
${msg.channel !== null && msg.channel !== undefined ? `${ui.getChannelName(msg.channel)}` : ''}
${msg.rx_snr ? ` | SNR: ${msg.rx_snr.toFixed(1)} dB` : ''}
${msg.rx_rssi ? ` | RSSI: ${msg.rx_rssi} dBm` : ''}
`;
// Insert at the beginning (newest first)
if (index === 0) {
container.insertAdjacentHTML('afterbegin', messageHtml);
} else {
container.insertAdjacentHTML('beforeend', messageHtml);
}
}
});
// Reorder if necessary (messages should be newest first)
const allMessages = Array.from(container.querySelectorAll('.message-item'));
if (allMessages.length !== messages.length || allMessages.length > messages.length) {
// Prune to keep only the message limit
const limit = messages.length;
allMessages.slice(limit).forEach(elem => elem.remove());
}
}
// Send Message
document.getElementById('send-message-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const text = document.getElementById('message-text').value;
const from = parseInt(document.getElementById('message-from').value);
const channel = parseInt(document.getElementById('message-channel').value);
try {
await api.sendMessage(from, text, channel);
document.getElementById('message-text').value = '';
// Reload messages after a short delay
setTimeout(() => loadMessages(), 1000);
} catch (error) {
alert('Error sending message: ' + error.message);
}
});
// Refresh Messages Button
document.getElementById('refresh-messages-btn')?.addEventListener('click', loadMessages);
// Load Nodes
async function loadNodes() {
try {
const nodes = await api.getNodes();
displayNodes(nodes);
state.lastDataReceived = Date.now();
// Reset reconnect attempts on successful load
if (state.reconnectAttempts > 0) {
state.reconnectAttempts = 0;
state.isReconnecting = false;
}
} catch (error) {
console.error('Error loading nodes:', error);
}
}
// Display Nodes
function displayNodes(nodes) {
const container = document.getElementById('nodes-grid');
if (!container) return;
if (nodes.length === 0) {
container.innerHTML = '';
return;
}
// Clear and rebuild to ensure proper sorting
container.innerHTML = '';
// Add nodes in the order they come from API (already sorted)
nodes.forEach(node => {
const lastHeard = node.last_heard ? new Date(node.last_heard) : null;
const isOnline = lastHeard && (Date.now() - lastHeard.getTime()) < 900000; // 15 minutes
const nodeHtml = `
${node.hardware_model ? `
Model:${escapeHtml(String(node.hardware_model))}
` : ''}
${node.battery_level ? `
Battery:${node.battery_level}%
` : ''}
${node.last_heard ? `
Last Heard:${ui.formatRelativeTime(node.last_heard)}
` : ''}
`;
container.insertAdjacentHTML('beforeend', nodeHtml);
// Add click handler for the new card
const newCard = container.lastElementChild;
if (newCard) {
newCard.addEventListener('click', () => {
window.showNodeDetail(node.node_id);
});
}
});
}
// Show Node Detail (make it globally accessible for Leaflet popups)
window.showNodeDetail = async function(nodeId) {
try {
const node = await api.getNode(nodeId);
const modal = document.getElementById('node-modal');
const detailContainer = document.getElementById('node-detail');
detailContainer.innerHTML = `
Node ID
${escapeHtml(node.node_id)}
${node.short_name ? `
Short Name
${escapeHtml(node.short_name)}
` : ''}
${node.long_name ? `
Long Name
${escapeHtml(node.long_name)}
` : ''}
${node.hardware_model ? `
Hardware Model
${escapeHtml(String(node.hardware_model))}
` : ''}
${node.role ? `
Role
${escapeHtml(String(node.role))}
` : ''}
${node.firmware_version ? `
Firmware
${escapeHtml(node.firmware_version)}
` : ''}
${node.latitude && node.longitude ? `
Location
${node.latitude.toFixed(6)}, ${node.longitude.toFixed(6)}
🌍
` : ''}
${node.battery_level ? `
Battery Level
${node.battery_level}%
` : ''}
${node.voltage ? `
Voltage
${node.voltage.toFixed(2)}V
` : ''}
${node.channel_utilization ? `
Channel Utilization
${node.channel_utilization.toFixed(1)}%
` : ''}
${node.last_heard ? `
Last Heard
${ui.formatRelativeTime(node.last_heard)}
` : ''}
`;
modal?.classList.remove('hidden');
} catch (error) {
console.error('Error loading node detail:', error);
alert('Error loading node details');
}
}
// Close Node Modal
document.querySelector('.modal-close')?.addEventListener('click', () => {
document.getElementById('node-modal')?.classList.add('hidden');
});
document.getElementById('node-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'node-modal') {
document.getElementById('node-modal')?.classList.add('hidden');
}
});
// Refresh Nodes Button
document.getElementById('refresh-nodes-btn')?.addEventListener('click', loadNodes);
// Initialize Map
function initMap() {
if (state.map) return;
const mapElement = document.getElementById('map');
if (!mapElement) return;
state.map = L.map('map').setView([39.8283, -98.5795], 4); // Center of USA
// Use CartoDB Positron for minimalist black and white tiles
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors, © CARTO',
maxZoom: 19,
subdomains: 'abcd'
}).addTo(state.map);
// Track when user moves the map
state.map.on('movestart', () => {
state.mapUserMoved = true;
});
// Force resize after initialization
setTimeout(() => state.map.invalidateSize(), 100);
}
// Load Map
async function loadMap() {
if (!state.map) {
initMap();
}
try {
const [positions, trails] = await Promise.all([
api.getPositions(),
api.getPositionTrails(10)
]);
displayPositions(positions);
displayTrails(trails);
state.lastDataReceived = Date.now();
// Reset reconnect attempts on successful load
if (state.reconnectAttempts > 0) {
state.reconnectAttempts = 0;
state.isReconnecting = false;
}
} catch (error) {
console.error('Error loading map:', error);
}
}
// Display Positions on Map
function displayPositions(positions) {
if (!state.map) {
initMap();
}
if (!state.map) return;
if (positions.length === 0) {
return;
}
// Track which nodes we've seen in this update
const seenNodes = new Set();
// Update or add markers for each position
positions.forEach(pos => {
seenNodes.add(pos.node_id);
const opacity = ui.getMarkerOpacity(pos.last_heard || pos.timestamp);
const color = ui.getMarkerColor(pos.last_heard || pos.timestamp, pos.node_id);
// Normalize timestamps for comparison
const normalizeTimestamp = (ts) => {
if (!ts) return null;
let dateStr = ts;
if (ts.includes(' ') && !ts.includes('T')) {
dateStr = ts.replace(' ', 'T') + 'Z';
}
return new Date(dateStr).getTime();
};
// Check if node is newly heard (last_heard changed in the last 10 seconds)
const currentLastHeard = normalizeTimestamp(pos.last_heard || pos.timestamp);
const previousLastHeard = state.nodeLastHeard[pos.node_id];
const timeSinceUpdate = currentLastHeard - (previousLastHeard || 0);
const isNewlyHeard = previousLastHeard && timeSinceUpdate > 0 && timeSinceUpdate < 10000;
// Update tracked last heard time (store normalized timestamp)
state.nodeLastHeard[pos.node_id] = currentLastHeard;
// Add pulsing animation for newly heard nodes
const pulseAnimation = isNewlyHeard ? 'animation: markerPulse 2s ease-out;' : '';
const icon = L.divIcon({
className: 'custom-marker',
html: ``,
iconSize: [16, 16],
iconAnchor: [8, 8]
});
// Use last_heard for consistency (fallback to timestamp if not available)
const relevantTimestamp = pos.last_heard || pos.timestamp;
const popupContent = `
${escapeHtml(pos.long_name || pos.short_name || pos.node_id)}
${escapeHtml(pos.node_id)}
Lat: ${pos.latitude.toFixed(6)}
Lon: ${pos.longitude.toFixed(6)}
${pos.altitude ? `Alt: ${pos.altitude}m
` : ''}
${ui.formatRelativeTime(relevantTimestamp)}
`;
// Update existing marker or create new one
if (state.markers[pos.node_id]) {
const marker = state.markers[pos.node_id];
const currentLatLng = marker.getLatLng();
// Update position if changed
if (currentLatLng.lat !== pos.latitude || currentLatLng.lng !== pos.longitude) {
marker.setLatLng([pos.latitude, pos.longitude]);
}
// Update icon opacity
marker.setIcon(icon);
// Update popup content without destroying the marker
marker.getPopup().setContent(popupContent);
} else {
// Create new marker
const marker = L.marker([pos.latitude, pos.longitude], { icon })
.addTo(state.map)
.bindPopup(popupContent);
state.markers[pos.node_id] = marker;
}
});
// Remove markers for nodes that are no longer in the position list (older than retention)
Object.keys(state.markers).forEach(nodeId => {
if (!seenNodes.has(nodeId)) {
state.markers[nodeId].remove();
delete state.markers[nodeId];
}
});
// Only fit bounds if user hasn't moved the map and this is the first load
if (!state.mapUserMoved && positions.length > 0 && Object.keys(state.markers).length === positions.length) {
const bounds = positions.map(pos => [pos.latitude, pos.longitude]);
state.map.fitBounds(bounds, { padding: [50, 50] });
}
// Force resize
setTimeout(() => state.map.invalidateSize(), 100);
}
// Display Position Trails on Map
function displayTrails(trails) {
if (!state.map || !trails || trails.length === 0) return;
// Group trails by node_id
const trailsByNode = {};
trails.forEach(pos => {
if (!trailsByNode[pos.node_id]) {
trailsByNode[pos.node_id] = [];
}
trailsByNode[pos.node_id].push(pos);
});
// Track which nodes we've seen
const seenNodes = new Set();
// Create or update polylines for each node
Object.entries(trailsByNode).forEach(([nodeId, positions]) => {
seenNodes.add(nodeId);
// Only draw trail if we have at least 2 positions
if (positions.length < 2) return;
// Sort by timestamp (oldest to newest) for drawing the line
positions.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
// Create coordinate array for polyline
const latLngs = positions.map(pos => [pos.latitude, pos.longitude]);
// Remove existing trail if it exists
if (state.trails[nodeId]) {
state.trails[nodeId].remove();
}
// Create new polyline (trail)
const polyline = L.polyline(latLngs, {
color: '#666',
weight: 2,
opacity: 0.5,
dashArray: '5, 5'
}).addTo(state.map);
state.trails[nodeId] = polyline;
});
// Remove trails for nodes that are no longer in the list
Object.keys(state.trails).forEach(nodeId => {
if (!seenNodes.has(nodeId)) {
state.trails[nodeId].remove();
delete state.trails[nodeId];
}
});
}
// Refresh Map Button
document.getElementById('refresh-map-btn')?.addEventListener('click', loadMap);
// Purge Data
document.getElementById('purge-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const hours = parseInt(document.getElementById('purge-hours').value);
// Format time period for display
let timePeriod;
if (hours < 24) {
timePeriod = `${hours} hour${hours !== 1 ? 's' : ''}`;
} else {
const days = hours / 24;
timePeriod = `${days} day${days !== 1 ? 's' : ''}`;
}
if (!confirm(`Are you sure you want to delete data older than ${timePeriod}? This cannot be undone.`)) {
return;
}
try {
const result = await api.purgeData(hours);
alert(`Successfully purged:\n${result.deleted.messages} messages\n${result.deleted.positions} positions\n${result.deleted.telemetry} telemetry records\n${result.deleted.nodes} nodes`);
// Reload stats
loadOverview();
} catch (error) {
alert('Error purging data: ' + error.message);
}
});
// Update all relative times
function updateRelativeTimes() {
document.querySelectorAll('.relative-time, .message-time').forEach(element => {
const timestamp = element.getAttribute('data-timestamp');
if (timestamp) {
element.textContent = ui.formatRelativeTime(timestamp);
}
});
}
// Update data last received timer
function updateDataTimer() {
const timerElement = document.getElementById('data-timer');
if (!timerElement) return;
const timeSinceData = Math.floor((Date.now() - state.lastDataReceived) / 1000);
let timeText;
if (timeSinceData < 1) {
timeText = '0s ago';
} else if (timeSinceData < 60) {
timeText = `${timeSinceData}s ago`;
} else if (timeSinceData < 3600) {
timeText = `${Math.floor(timeSinceData / 60)}m ago`;
} else if (timeSinceData < 86400) {
timeText = `${Math.floor(timeSinceData / 3600)}h ago`;
} else {
timeText = `${Math.floor(timeSinceData / 86400)}d ago`;
}
timerElement.textContent = timeText;
// Visual warning if data is stale
const dataLastReceivedElem = document.getElementById('data-last-received');
if (dataLastReceivedElem) {
if (timeSinceData > 30) {
dataLastReceivedElem.style.color = '#ff9800'; // Orange
} else if (timeSinceData > 60) {
dataLastReceivedElem.style.color = '#f44336'; // Red
} else {
dataLastReceivedElem.style.color = ''; // Default
}
}
}
// Update node online/offline badges
function updateNodeBadges() {
document.querySelectorAll('.node-card').forEach(card => {
const lastHeardElement = card.querySelector('.relative-time');
if (!lastHeardElement) return;
const lastHeardTimestamp = lastHeardElement.getAttribute('data-timestamp');
if (!lastHeardTimestamp) return;
const lastHeard = new Date(lastHeardTimestamp);
const isOnline = (Date.now() - lastHeard.getTime()) < 900000; // 15 minutes
const badge = card.querySelector('.node-badge');
if (badge) {
badge.className = `node-badge ${isOnline ? 'online' : 'offline'}`;
badge.textContent = isOnline ? 'Online' : 'Offline';
}
});
}
// Update map marker opacity and relative times in popups
function updateMapMarkerOpacity() {
if (!state.map || Object.keys(state.markers).length === 0) return;
Object.entries(state.markers).forEach(([nodeId, marker]) => {
// Get the marker's popup content to extract timestamp
const popupContent = marker.getPopup()?.getContent();
if (!popupContent) return;
// Extract timestamp from popup
const timestampMatch = popupContent.match(/data-timestamp="([^"]+)"/);
if (!timestampMatch) return;
const timestamp = timestampMatch[1];
const newOpacity = ui.getMarkerOpacity(timestamp);
const newColor = ui.getMarkerColor(timestamp, nodeId);
// Get current icon properties
const currentIcon = marker.getIcon();
const currentOpacity = currentIcon?.options?.html?.match(/opacity: ([\d.]+)/)?.[1];
const currentColor = currentIcon?.options?.html?.match(/background-color: ([#\w]+)/)?.[1];
// Only update icon if opacity changed significantly (>0.1) OR color bucket changed
const opacityChanged = !currentOpacity || Math.abs(parseFloat(currentOpacity) - newOpacity) > 0.1;
const colorChanged = currentColor !== newColor;
// Only update if there's a meaningful change (avoid flashing on minor updates)
if (opacityChanged || colorChanged) {
const icon = L.divIcon({
className: 'custom-marker',
html: ``,
iconSize: [16, 16],
iconAnchor: [8, 8]
});
marker.setIcon(icon);
}
// Update relative time in popup without recreating the popup
const relativeTime = ui.formatRelativeTime(timestamp);
const updatedContent = popupContent.replace(
/.*?<\/span>/,
`${relativeTime}`
);
// Only update if content changed
if (updatedContent !== popupContent) {
marker.getPopup().setContent(updatedContent);
}
});
}
// Check connection health
function checkConnectionHealth() {
const timeSinceLastUpdate = Date.now() - state.lastSuccessfulUpdate;
// If we haven't had a successful update in 10 seconds, show warning
if (timeSinceLastUpdate > 10000 && !state.connectionWarningShown) {
state.connectionWarningShown = true;
showConnectionWarning();
}
}
// Show connection warning
function showConnectionWarning() {
const warningHtml = `
⚠️ Connection issue detected - Data may be stale
`;
if (!document.getElementById('connection-warning')) {
document.body.insertAdjacentHTML('beforeend', warningHtml);
}
}
// Hide connection warning
function hideConnectionWarning() {
const warning = document.getElementById('connection-warning');
if (warning) {
warning.remove();
}
}
// Show notification
function showNotification(title, message, type = 'info') {
const colors = {
success: '#4CAF50',
warning: '#ff9800',
error: '#f44336',
info: '#2196F3'
};
const notificationHtml = `
${escapeHtml(title)}
${escapeHtml(message)}
`;
const notification = document.createElement('div');
notification.innerHTML = notificationHtml;
document.body.appendChild(notification.firstElementChild);
// Auto-dismiss after 5 seconds
setTimeout(() => {
const el = document.querySelector('.notification');
if (el) {
el.style.animation = 'slideIn 0.3s ease-out reverse';
setTimeout(() => el.remove(), 300);
}
}, 5000);
}
// Auto-refresh
function startRefreshIntervals() {
// Update relative times every second
state.timeUpdateInterval = setInterval(updateRelativeTimes, 1000);
// Update data timer every second
state.refreshIntervals.push(
setInterval(updateDataTimer, 1000)
);
// Update node online/offline badges every second
state.refreshIntervals.push(
setInterval(updateNodeBadges, 1000)
);
// Check connection health every 5 seconds
state.refreshIntervals.push(
setInterval(checkConnectionHealth, 5000)
);
// Update map marker opacity every 5 seconds
state.refreshIntervals.push(
setInterval(updateMapMarkerOpacity, 5000)
);
// Refresh current tab data every 3 seconds (faster for live feel)
state.refreshIntervals.push(
setInterval(() => {
const activeTab = document.querySelector('.nav-tab.active')?.getAttribute('data-tab');
if (activeTab === 'overview') loadOverview();
if (activeTab === 'messages') loadMessages();
if (activeTab === 'nodes') loadNodes();
if (activeTab === 'map') loadMap();
}, 3000)
);
// Refresh MQTT status every 3 seconds
state.refreshIntervals.push(
setInterval(updateMqttStatus, 3000)
);
}
function stopRefreshIntervals() {
if (state.timeUpdateInterval) {
clearInterval(state.timeUpdateInterval);
state.timeUpdateInterval = null;
}
state.refreshIntervals.forEach(interval => clearInterval(interval));
state.refreshIntervals = [];
}
// Utility function to escape HTML
function escapeHtml(text) {
if (text === null || text === undefined) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
// Check authentication on page load
(async function() {
try {
const authStatus = await api.checkAuth();
if (authStatus.authenticated) {
state.authenticated = true;
state.user = authStatus.user;
initDashboard();
} else {
ui.showScreen('login-screen');
}
} catch (error) {
ui.showScreen('login-screen');
}
})();