// 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
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
};
// 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');
}
};
// 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) {
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));
return colors[bucketIndex];
}
};
// 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;
// Update MQTT status
updateMqttStatus();
// Load initial content
loadOverview();
// Start auto-refresh
startRefreshIntervals();
}
// 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 messageHtml = `
${escapeHtml(msg.text || '')}
${msg.rx_snr || msg.rx_rssi ? `
${msg.rx_snr ? `SNR: ${msg.rx_snr.toFixed(1)} dB` : ''}
${msg.rx_rssi ? ` | RSSI: ${msg.rx_rssi} dBm` : ''}
${msg.channel !== null ? ` | Channel: ${msg.channel}` : ''}
` : ''}
`;
// 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;
}
// Build a map of node IDs to nodes
const nodeMap = new Map();
nodes.forEach(node => {
nodeMap.set(node.node_id, node);
});
// Get existing node elements
const existingNodes = container.querySelectorAll('.node-card');
const existingIds = new Set();
existingNodes.forEach(elem => {
const nodeId = elem.getAttribute('data-node-id');
if (nodeId) existingIds.add(nodeId);
});
// Remove nodes that are no longer in the list
existingNodes.forEach(elem => {
const nodeId = elem.getAttribute('data-node-id');
if (nodeId && !nodeMap.has(nodeId)) {
elem.remove();
}
});
// Add new nodes or update existing ones
nodes.forEach(node => {
const lastHeard = node.last_heard ? new Date(node.last_heard) : null;
const isOnline = lastHeard && (Date.now() - lastHeard.getTime()) < 900000; // 15 minutes
if (existingIds.has(node.node_id)) {
// Update existing node card content
const card = container.querySelector(`[data-node-id="${node.node_id}"]`);
if (card) {
// Update node name
const nameElem = card.querySelector('.node-name');
if (nameElem) nameElem.textContent = node.long_name || node.short_name || 'Unknown';
// Update badge
const badge = card.querySelector('.node-badge');
if (badge) {
badge.className = `node-badge ${isOnline ? 'online' : 'offline'}`;
badge.textContent = isOnline ? 'Online' : 'Offline';
}
// Update last heard time
const timeElem = card.querySelector('.relative-time');
if (timeElem && node.last_heard) {
timeElem.setAttribute('data-timestamp', node.last_heard);
timeElem.textContent = ui.formatRelativeTime(node.last_heard);
}
// Update battery if present
const nodeInfo = card.querySelector('.node-info');
if (nodeInfo && node.battery_level) {
// Check if battery row already exists
let batteryRow = null;
nodeInfo.querySelectorAll('.node-info-row').forEach(row => {
const label = row.querySelector('span:first-child');
if (label && label.textContent.includes('Battery:')) {
batteryRow = row;
}
});
if (!batteryRow) {
nodeInfo.insertAdjacentHTML('beforeend',
`Battery:${node.battery_level}%
`
);
} else {
// Update existing battery value
const valueSpan = batteryRow.querySelector('span:last-child');
if (valueSpan) valueSpan.textContent = `${node.battery_level}%`;
}
}
// Update or add globe button if location is available
const headerDiv = card.querySelector('.node-card-header > div:last-child');
if (headerDiv && node.latitude && node.longitude) {
let globeButton = headerDiv.querySelector('a[href*="openstreetmap"]');
if (!globeButton) {
const globeHtml = `🌍`;
const badge = headerDiv.querySelector('.node-badge');
if (badge) {
badge.insertAdjacentHTML('beforebegin', globeHtml);
}
} else {
// Update href if coordinates changed
globeButton.href = `https://www.openstreetmap.org/?mlat=${node.latitude}&mlon=${node.longitude}&zoom=11`;
}
} else if (headerDiv && !node.latitude) {
// Remove globe button if location is no longer available
const globeButton = headerDiv.querySelector('a[href*="openstreetmap"]');
if (globeButton) {
globeButton.remove();
}
}
}
} else {
// Create new node card
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.querySelector(`[data-node-id="${node.node_id}"]`);
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);
// Check if node is newly heard (last_heard changed recently)
const currentLastHeard = pos.last_heard || pos.timestamp;
const previousLastHeard = state.nodeLastHeard[pos.node_id];
const isNewlyHeard = previousLastHeard && currentLastHeard !== previousLastHeard;
// Update tracked last heard time
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]
});
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(pos.timestamp)}
`;
// 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`);
// 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 opacity = ui.getMarkerOpacity(timestamp);
const color = ui.getMarkerColor(timestamp);
// Update marker icon with new opacity and color (only if it changed significantly)
const currentIcon = marker.getIcon();
const currentOpacity = currentIcon?.options?.html?.match(/opacity: ([\d.]+)/)?.[1];
const currentColor = currentIcon?.options?.html?.match(/background-color: ([^;]+);/)?.[1];
if (!currentOpacity || Math.abs(parseFloat(currentOpacity) - opacity) > 0.05 || currentColor !== color) {
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');
}
})();