// Application State
const state = {
authenticated: false,
user: null,
map: null,
markers: {},
trails: {},
contactLastHeard: {},
contactLastColor: {},
refreshIntervals: [],
timeUpdateInterval: null,
lastDataReceived: Date.now(),
serialConnected: false,
connectionWarningShown: false,
mapUserMoved: false,
knownMessages: new Set(),
knownContacts: new Map(),
reconnectAttempts: 0,
maxReconnectAttempts: 5,
reconnectDelay: 5000,
isReconnecting: false,
contacts: [],
msgFilter: 'all', // 'all' | 'direct' | 'channel'
msgChannelIdx: 0 // which channel when filter='channel'
};
// --- Auth error & reconnect helpers ---
function handleAuthenticationError() {
if (!state.authenticated) return;
state.authenticated = false;
state.user = null;
stopRefreshIntervals();
alert('Your session has expired. Please log in again.');
ui.showScreen('login-screen');
}
function handleConnectionLost() {
if (state.isReconnecting) return;
state.isReconnecting = true;
if (!state.connectionWarningShown) {
showNotification('Connection Lost', 'Attempting to reconnect to server...', 'warning');
state.connectionWarningShown = true;
}
attemptReconnect();
}
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++;
await new Promise(r => setTimeout(r, state.reconnectDelay));
try {
const response = await fetch('/api/auth/status', { credentials: 'include' });
if (response.ok) {
state.reconnectAttempts = 0;
state.isReconnecting = false;
state.connectionWarningShown = false;
showNotification('Connection Restored', 'Reconnected to server', 'success');
const activeTab = document.querySelector('.nav-tab.active')?.getAttribute('data-tab');
if (activeTab === 'overview') loadOverview();
if (activeTab === 'messages') loadMessages();
if (activeTab === 'contacts') loadContacts();
if (activeTab === 'map') loadMap();
} else if (response.status === 401) {
handleAuthenticationError();
state.isReconnecting = false;
} else {
attemptReconnect();
}
} catch {
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'
});
if (response.status === 401) { handleAuthenticationError(); throw new Error('Authentication required'); }
if (!response.ok) {
const err = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(err.error || 'Request failed');
}
return response.json();
} catch (error) {
if (error.message === 'Failed to fetch' || error.name === 'TypeError') handleConnectionLost();
throw error;
}
},
login: (username, password) => api.request('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
logout: () => api.request('/api/logout', { method: 'POST' }),
checkAuth: () => api.request('/api/auth/status'),
getStats: () => api.request('/api/stats'),
getContacts: () => api.request('/api/contacts'),
getContact: (pubkey) => api.request(`/api/contacts/${pubkey}`),
getPositions: () => api.request('/api/positions'),
getPositionTrails: (limit = 10) => api.request(`/api/positions/trails/all?limit=${limit}`),
getMessages: (limit = 200, type = null, channelIdx = null) => {
let url = `/api/messages?limit=${limit}`;
if (type) url += `&type=${type}`;
if (channelIdx != null) url += `&channel_idx=${channelIdx}`;
return api.request(url);
},
sendMessage: (payload) => api.request('/api/messages/send', { method: 'POST', body: JSON.stringify(payload) }),
purgeData: (hours) => api.request('/api/purge', { method: 'POST', body: JSON.stringify({ hours }) }),
getSerialStatus: () => api.request('/api/serial/status'),
getSerialPorts: () => api.request('/api/serial/ports'),
getDevice: () => api.request('/api/device'),
getChannels: () => api.request('/api/channels')
};
// --- UI Helper ---
const ui = {
showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden'));
document.getElementById(id).classList.remove('hidden');
},
showTab(tabName) {
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.querySelector(`[data-tab="${tabName}"]`)?.classList.add('active');
document.getElementById(`${tabName}-tab`)?.classList.add('active');
if (tabName === 'map') {
setTimeout(() => {
if (state.map) state.map.invalidateSize();
else initMap();
}, 50);
}
},
showError(id, msg) {
const el = document.getElementById(id);
if (el) { el.textContent = msg; el.style.display = 'block'; }
},
hideError(id) {
const el = document.getElementById(id);
if (el) { el.textContent = ''; el.style.display = 'none'; }
},
formatRelativeTime(dateString) {
if (!dateString) return 'Never';
let str = dateString;
if (typeof str === 'number') return ui.formatRelativeTime(new Date(str * 1000).toISOString());
if (str.includes(' ') && !str.includes('T')) str = str.replace(' ', 'T') + 'Z';
const date = new Date(str);
if (isNaN(date.getTime())) return 'Unknown';
const diff = Math.floor((Date.now() - date) / 1000);
if (diff < 1) return 'just now';
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) return '0 B';
const k = 1024, sizes = ['B', '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];
},
getMarkerColor(lastHeardDate, id) {
if (!lastHeardDate) return '#a50026';
let str = lastHeardDate;
if (str.includes(' ') && !str.includes('T')) str = str.replace(' ', 'T') + 'Z';
const mins = (Date.now() - new Date(str)) / 60000;
const colors = ['#00ff00','#9db800','#cccc00','#fee08b','#fdae61','#f46d43','#d73027','#a50026'];
const bucketSize = 360 / 8;
const idx = Math.min(7, Math.floor(mins / bucketSize));
const newColor = colors[idx];
if (id && state.contactLastColor[id]) {
const lastIdx = colors.indexOf(state.contactLastColor[id]);
if (lastIdx !== -1 && lastIdx !== idx) {
const minsIntoBucket = mins % bucketSize;
if (Math.min(minsIntoBucket, bucketSize - minsIntoBucket) < 2) return state.contactLastColor[id];
}
}
if (id) state.contactLastColor[id] = newColor;
return newColor;
},
getContactTypeName(type) {
return ['None', 'Chat', 'Repeater', 'Room', 'Sensor'][type] || 'Unknown';
},
getContactTypeBadgeClass(type) {
return ['', 'type-chat', 'type-repeater', 'type-room', 'type-sensor'][type] || '';
},
formatPathLength(pl) {
if (pl === 255 || pl === 0xFF) return 'Flood';
if (pl === 0) return 'Direct';
return `${pl} hop${pl !== 1 ? 's' : ''}`;
},
formatSNR(snr) {
if (snr == null) return '—';
return `${snr.toFixed(1)} dB`;
},
hashStringToColor(str) {
if (!str) return '#2563eb';
let hash = 0;
for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash) | 0;
const hue = Math.abs(hash % 360);
return `hsl(${hue}, 65%, 45%)`;
}
};
// --- Notification ---
function showNotification(title, message, type = 'info', duration = 4000) {
const existing = document.querySelector('.notification');
if (existing) existing.remove();
const el = document.createElement('div');
el.className = `notification ${type}`;
el.innerHTML = `${title}${message ? `
${message}` : ''}`;
document.body.appendChild(el);
setTimeout(() => el.remove(), duration);
}
// --- Login / Logout ---
document.getElementById('login-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
ui.hideError('login-error');
try {
const result = await api.login(
document.getElementById('username').value,
document.getElementById('password').value
);
if (result.success) {
state.authenticated = true;
state.user = result.user;
initDashboard();
}
} catch (error) {
ui.showError('login-error', error.message);
}
});
document.getElementById('logout-btn')?.addEventListener('click', async () => {
try {
await api.logout();
} catch {}
state.authenticated = false;
state.user = null;
stopRefreshIntervals();
ui.showScreen('login-screen');
});
// --- Tab Navigation ---
document.querySelectorAll('.nav-tab').forEach(tab => {
tab.addEventListener('click', () => {
const name = tab.getAttribute('data-tab');
ui.showTab(name);
if (name === 'overview') loadOverview();
if (name === 'map') loadMap();
if (name === 'messages') loadMessages();
if (name === 'contacts') loadContacts();
if (name === 'settings') loadSettings();
});
});
// --- Dashboard Init ---
async function initDashboard() {
ui.showScreen('dashboard-screen');
document.getElementById('user-info').textContent = state.user.username;
// Load device info for header badge
try {
const device = await api.getDevice();
if (device.available && device.name) {
const badge = document.getElementById('device-name-badge');
badge.textContent = device.name;
badge.classList.remove('hidden');
}
} catch {}
// Populate contact selector for message sending
populateContactSelector();
// Load initial tab and pre-fetch messages
loadOverview();
loadMessages(); // pre-load so they're ready when tab is opened
startRefreshIntervals();
}
function startRefreshIntervals() {
stopRefreshIntervals();
// Serial status every 5s
const statusInterval = setInterval(async () => {
try {
const status = await api.getSerialStatus();
updateSerialStatus(status.connected);
} catch {}
}, 5000);
state.refreshIntervals.push(statusInterval);
// Data auto-refresh every 10s
const overviewInterval = setInterval(() => {
const activeTab = document.querySelector('.nav-tab.active')?.getAttribute('data-tab');
if (activeTab === 'overview') loadOverview();
if (activeTab === 'messages') loadMessages();
if (activeTab === 'map' && !state.mapUserMoved) loadMap();
if (activeTab === 'contacts') loadContacts();
}, 10000);
state.refreshIntervals.push(overviewInterval);
// Update relative timestamps every second
state.timeUpdateInterval = setInterval(updateDataTimer, 1000);
state.refreshIntervals.push(state.timeUpdateInterval);
}
function stopRefreshIntervals() {
state.refreshIntervals.forEach(id => clearInterval(id));
state.refreshIntervals = [];
}
function updateSerialStatus(connected) {
state.serialConnected = connected;
const el = document.getElementById('serial-status');
const dot = el?.querySelector('.status-dot');
if (dot) dot.classList.toggle('connected', connected);
if (el) el.title = connected ? 'USB Serial: Connected' : 'USB Serial: Disconnected';
}
function updateDataTimer() {
const diff = Math.floor((Date.now() - state.lastDataReceived) / 1000);
const el = document.getElementById('data-timer');
if (!el) return;
if (diff < 60) el.textContent = `${diff}s ago`;
else if (diff < 3600) el.textContent = `${Math.floor(diff / 60)}m ago`;
else el.textContent = `${Math.floor(diff / 3600)}h ago`;
}
async function populateContactSelector() {
try {
const contacts = await api.getContacts();
state.contacts = contacts;
const sel = document.getElementById('message-to-contact');
if (!sel) return;
sel.innerHTML = '';
contacts.forEach(c => {
const opt = document.createElement('option');
opt.value = c.pubkey;
const prefix = c.pubkey ? c.pubkey.substring(0, 8) + '...' : '';
opt.textContent = c.name ? `${c.name} (${prefix})` : prefix;
sel.appendChild(opt);
});
} catch (err) {
console.error('Error loading contacts for selector:', err);
}
}
// Toggle direct/channel selector visibility
document.querySelectorAll('input[name="msg-type"]').forEach(radio => {
radio.addEventListener('change', () => {
const isDirect = document.getElementById('type-direct').checked;
document.getElementById('message-to-contact').classList.toggle('hidden', !isDirect);
document.getElementById('message-channel-idx').classList.toggle('hidden', isDirect);
});
});
// --- Overview ---
async function loadOverview() {
try {
const [stats, device, messages] = await Promise.all([
api.getStats(),
api.getDevice().catch(() => ({ available: false })),
api.getMessages(10).catch(() => [])
]);
state.lastDataReceived = Date.now();
document.getElementById('stat-contacts').textContent = stats.contacts || 0;
document.getElementById('stat-messages').textContent = stats.messages || 0;
document.getElementById('stat-positions').textContent = stats.positions || 0;
document.getElementById('stat-db-size').textContent = ui.formatBytes(stats.databaseSize || 0);
updateSerialStatus(stats.serialConnected);
// Device info panel
if (device.available) {
const panel = document.getElementById('device-info-panel');
panel.classList.remove('hidden');
const grid = document.getElementById('device-info-content');
grid.innerHTML = '';
const items = [
{ label: 'Name', value: device.name || '—' },
{ label: 'Pubkey Prefix', value: device.pubkeyPrefix || '—' },
{ label: 'TX Power', value: device.txPower != null ? `${device.txPower} dBm` : '—' },
{ label: 'Frequency', value: device.frequency ? `${(device.frequency / 1e6).toFixed(3)} MHz` : '—' },
];
if (device.latitude != null) {
items.push({ label: 'Latitude', value: device.latitude.toFixed(6) });
items.push({ label: 'Longitude', value: device.longitude.toFixed(6) });
}
items.forEach(({ label, value }) => {
grid.innerHTML += `
No messages yet
No contacts found
Connect a MeshCore device to discover contacts.
No serial ports detected.
'; } else { portsList.innerHTML = 'Available serial ports:
' + ports.map(p => `