1348 lines
48 KiB
JavaScript
1348 lines
48 KiB
JavaScript
// 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(text, channel = 0) {
|
|
return this.request('/api/messages/send', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ text, channel })
|
|
});
|
|
},
|
|
|
|
async purgeData(days) {
|
|
return this.request('/api/purge', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ days })
|
|
});
|
|
},
|
|
|
|
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 = '<div class="empty-state"><div class="empty-state-icon">💬</div><p>No messages yet</p></div>';
|
|
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 = `
|
|
<div class="message-item" data-message-id="${msgId}">
|
|
<div class="message-header">
|
|
<span class="message-from">
|
|
${escapeHtml(msg.from_long_name || msg.from_short_name || msg.from_node)}
|
|
</span>
|
|
<span class="message-time" data-timestamp="${msg.created_at}">${ui.formatRelativeTime(msg.created_at)}</span>
|
|
</div>
|
|
<div class="message-text">${escapeHtml(msg.text || '')}</div>
|
|
${msg.rx_snr || msg.rx_rssi ? `
|
|
<div class="message-meta">
|
|
${msg.rx_snr ? `SNR: ${msg.rx_snr.toFixed(1)} dB` : ''}
|
|
${msg.rx_rssi ? ` | RSSI: ${msg.rx_rssi} dBm` : ''}
|
|
${msg.channel !== null ? ` | Channel: ${msg.channel}` : ''}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
|
|
// 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 channel = parseInt(document.getElementById('message-channel').value);
|
|
|
|
try {
|
|
await api.sendMessage(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 = '<div class="empty-state"><div class="empty-state-icon">📡</div><p>No nodes found</p></div>';
|
|
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',
|
|
`<div class="node-info-row"><span>Battery:</span><span>${node.battery_level}%</span></div>`
|
|
);
|
|
} 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 = `<a href="https://www.openstreetmap.org/?mlat=${node.latitude}&mlon=${node.longitude}&zoom=11"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onclick="event.stopPropagation()"
|
|
style="background: #4CAF50; color: white; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 14px; line-height: 1;">🌍</a>`;
|
|
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 = `
|
|
<div class="node-card" data-node-id="${escapeHtml(node.node_id)}">
|
|
<div class="node-card-header">
|
|
<div>
|
|
<div class="node-name">${escapeHtml(node.long_name || node.short_name || 'Unknown')}</div>
|
|
<div class="node-id">${escapeHtml(node.node_id)}</div>
|
|
</div>
|
|
<div style="display: flex; gap: 8px; align-items: center;">
|
|
${node.latitude && node.longitude ? `
|
|
<a href="https://www.openstreetmap.org/?mlat=${node.latitude}&mlon=${node.longitude}&zoom=11"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onclick="event.stopPropagation()"
|
|
style="background: #4CAF50; color: white; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 14px; line-height: 1;">🌍</a>
|
|
` : ''}
|
|
<span class="node-badge ${isOnline ? 'online' : 'offline'}">
|
|
${isOnline ? 'Online' : 'Offline'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="node-info">
|
|
${node.hardware_model ? `<div class="node-info-row"><span>Model:</span><span>${escapeHtml(String(node.hardware_model))}</span></div>` : ''}
|
|
${node.battery_level ? `<div class="node-info-row"><span>Battery:</span><span>${node.battery_level}%</span></div>` : ''}
|
|
${node.last_heard ? `<div class="node-info-row"><span>Last Heard:</span><span class="relative-time" data-timestamp="${node.last_heard}">${ui.formatRelativeTime(node.last_heard)}</span></div>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="detail-row">
|
|
<span class="detail-label">Node ID</span>
|
|
<span class="detail-value">${escapeHtml(node.node_id)}</span>
|
|
</div>
|
|
${node.short_name ? `
|
|
<div class="detail-row">
|
|
<span class="detail-label">Short Name</span>
|
|
<span class="detail-value">${escapeHtml(node.short_name)}</span>
|
|
</div>
|
|
` : ''}
|
|
${node.long_name ? `
|
|
<div class="detail-row">
|
|
<span class="detail-label">Long Name</span>
|
|
<span class="detail-value">${escapeHtml(node.long_name)}</span>
|
|
</div>
|
|
` : ''}
|
|
${node.hardware_model ? `
|
|
<div class="detail-row">
|
|
<span class="detail-label">Hardware Model</span>
|
|
<span class="detail-value">${escapeHtml(String(node.hardware_model))}</span>
|
|
</div>
|
|
` : ''}
|
|
${node.role ? `
|
|
<div class="detail-row">
|
|
<span class="detail-label">Role</span>
|
|
<span class="detail-value">${escapeHtml(String(node.role))}</span>
|
|
</div>
|
|
` : ''}
|
|
${node.firmware_version ? `
|
|
<div class="detail-row">
|
|
<span class="detail-label">Firmware</span>
|
|
<span class="detail-value">${escapeHtml(node.firmware_version)}</span>
|
|
</div>
|
|
` : ''}
|
|
${node.latitude && node.longitude ? `
|
|
<div class="detail-row">
|
|
<span class="detail-label">Location</span>
|
|
<span class="detail-value">
|
|
${node.latitude.toFixed(6)}, ${node.longitude.toFixed(6)}
|
|
<a href="https://www.openstreetmap.org/?mlat=${node.latitude}&mlon=${node.longitude}&zoom=11"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style="background: #4CAF50; color: white; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 14px; line-height: 1; margin-left: 8px; display: inline-block;">🌍</a>
|
|
</span>
|
|
</div>
|
|
` : ''}
|
|
${node.battery_level ? `
|
|
<div class="detail-row">
|
|
<span class="detail-label">Battery Level</span>
|
|
<span class="detail-value">${node.battery_level}%</span>
|
|
</div>
|
|
` : ''}
|
|
${node.voltage ? `
|
|
<div class="detail-row">
|
|
<span class="detail-label">Voltage</span>
|
|
<span class="detail-value">${node.voltage.toFixed(2)}V</span>
|
|
</div>
|
|
` : ''}
|
|
${node.channel_utilization ? `
|
|
<div class="detail-row">
|
|
<span class="detail-label">Channel Utilization</span>
|
|
<span class="detail-value">${node.channel_utilization.toFixed(1)}%</span>
|
|
</div>
|
|
` : ''}
|
|
${node.last_heard ? `
|
|
<div class="detail-row">
|
|
<span class="detail-label">Last Heard</span>
|
|
<span class="detail-value relative-time" data-timestamp="${node.last_heard}">${ui.formatRelativeTime(node.last_heard)}</span>
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
|
|
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: `<div style="opacity: ${opacity}; background-color: ${color}; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3); ${pulseAnimation}"></div>`,
|
|
iconSize: [16, 16],
|
|
iconAnchor: [8, 8]
|
|
});
|
|
|
|
const popupContent = `
|
|
<div style="position: relative; min-width: 180px; padding-bottom: 8px;">
|
|
<b>${escapeHtml(pos.long_name || pos.short_name || pos.node_id)}</b><br>
|
|
<small>${escapeHtml(pos.node_id)}</small><br>
|
|
Lat: ${pos.latitude.toFixed(6)}<br>
|
|
Lon: ${pos.longitude.toFixed(6)}<br>
|
|
${pos.altitude ? `Alt: ${pos.altitude}m<br>` : ''}
|
|
<span class="relative-time" data-timestamp="${pos.timestamp}">${ui.formatRelativeTime(pos.timestamp)}</span>
|
|
<div style="margin-top: 8px; display: flex; gap: 8px; align-items: center;">
|
|
<a href="#"
|
|
onclick="event.preventDefault(); window.showNodeDetail('${pos.node_id}'); return false;"
|
|
style="background: #2196F3; color: white; padding: 4px 12px; border-radius: 4px; text-decoration: none; font-size: 14px; line-height: 1; flex: 1; text-align: center;">View Details</a>
|
|
<a href="https://www.openstreetmap.org/?mlat=${pos.latitude}&mlon=${pos.longitude}&zoom=11"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style="background: #4CAF50; color: white; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 16px; line-height: 1;">🌍</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// 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 days = parseInt(document.getElementById('purge-days').value);
|
|
|
|
if (!confirm(`Are you sure you want to delete data older than ${days} days? This cannot be undone.`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await api.purgeData(days);
|
|
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: `<div style="opacity: ${opacity}; background-color: ${color}; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>`,
|
|
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 class="relative-time" data-timestamp="[^"]+">.*?<\/span>/,
|
|
`<span class="relative-time" data-timestamp="${timestamp}">${relativeTime}</span>`
|
|
);
|
|
|
|
// 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 = `
|
|
<div id="connection-warning" style="
|
|
position: fixed;
|
|
top: 60px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: #ff9800;
|
|
color: white;
|
|
padding: 12px 24px;
|
|
border-radius: 4px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
z-index: 10000;
|
|
font-weight: 500;
|
|
">
|
|
⚠️ Connection issue detected - Data may be stale
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="notification" style="
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: ${colors[type]};
|
|
color: white;
|
|
padding: 16px 24px;
|
|
border-radius: 4px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
z-index: 10001;
|
|
min-width: 300px;
|
|
animation: slideIn 0.3s ease-out;
|
|
">
|
|
<div style="font-weight: bold; margin-bottom: 4px;">${escapeHtml(title)}</div>
|
|
<div style="font-size: 0.9em;">${escapeHtml(message)}</div>
|
|
</div>
|
|
<style>
|
|
@keyframes slideIn {
|
|
from {
|
|
transform: translateX(400px);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
</style>
|
|
`;
|
|
|
|
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');
|
|
}
|
|
})();
|