Files

822 lines
32 KiB
JavaScript

// 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 = `<strong>${title}</strong>${message ? `<br><small>${message}</small>` : ''}`;
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 = '<option value="">-- Select contact --</option>';
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 += `
<div class="device-info-item">
<div class="device-info-label">${label}</div>
<div class="device-info-value">${value}</div>
</div>`;
});
}
renderRecentMessages(messages, 'recent-messages');
} catch (err) {
console.error('Error loading overview:', err);
}
}
// Parse channel message text: MeshCore sends "SenderName: message body"
// Returns { sender, body } — if no colon pattern, sender is null and body is the full text
function parseChannelText(text) {
if (!text) return { sender: null, body: '' };
// Look for "Name: message" — name can include spaces, colon must be followed by space
const match = text.match(/^([^:]{1,32}):\s(.+)$/s);
if (match) return { sender: match[1].trim(), body: match[2] };
return { sender: null, body: text };
}
function renderRecentMessages(messages, containerId) {
const container = document.getElementById(containerId);
if (!container) return;
if (!messages.length) {
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">💬</div><p>No messages yet</p></div>';
return;
}
container.innerHTML = messages.map(msg => {
const isChannel = msg.msg_type === 1;
const isOutbound = msg.from_pubkey === 'self';
let displayFrom, displayText;
if (isOutbound) {
displayFrom = 'You';
displayText = msg.text || '';
} else if (isChannel) {
// Channel messages include sender name in text: "SenderName: body"
const parsed = parseChannelText(msg.text);
displayFrom = parsed.sender || msg.from_name || `Ch ${msg.channel_idx}`;
displayText = parsed.body;
} else {
// Direct message — from_name comes from contacts JOIN
displayFrom = msg.from_name || `${msg.from_pubkey?.substring(0, 12) || '?'}…`;
displayText = msg.text || '';
}
const channelLabel = isChannel ? (msg.channel_idx === 0 ? 'Public' : `Ch ${msg.channel_idx}`) : null;
const channelBadge = isChannel
? `<span class="message-channel" style="background:${ui.hashStringToColor('ch' + msg.channel_idx)}">${channelLabel}</span>`
: `<span class="message-badge badge-direct">Direct</span>`;
const deliveryBadge = isOutbound && msg.delivered ? '<span class="message-badge badge-delivered">Delivered</span>' : '';
const pathInfo = msg.path_length != null ? `${ui.formatPathLength(msg.path_length)}` : '';
return `
<div class="message-item ${isOutbound ? 'outbound' : ''}">
<div class="message-header">
<span class="message-from">${escapeHtml(displayFrom)}</span>
<span class="message-time">${ui.formatRelativeTime(msg.created_at)}</span>
</div>
<div class="message-text">${escapeHtml(displayText)}</div>
<div class="message-meta">
${channelBadge}
${deliveryBadge}
${pathInfo ? `<span>${pathInfo}</span>` : ''}
</div>
</div>`;
}).join('');
}
// --- Map ---
function initMap() {
if (state.map) return;
state.map = L.map('map').setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(state.map);
state.map.on('dragstart zoomstart', () => { state.mapUserMoved = true; });
document.getElementById('refresh-map-btn')?.addEventListener('click', () => {
state.mapUserMoved = false;
loadMap();
});
}
async function loadMap() {
initMap();
try {
const [positions, trails] = await Promise.all([
api.getPositions(),
api.getPositionTrails(10).catch(() => [])
]);
state.lastDataReceived = Date.now();
// Clear old markers
Object.values(state.markers).forEach(m => state.map.removeLayer(m));
state.markers = {};
Object.values(state.trails).forEach(t => state.map.removeLayer(t));
state.trails = {};
const bounds = [];
// Draw trails
const trailsByPubkey = {};
trails.forEach(p => {
if (!trailsByPubkey[p.pubkey]) trailsByPubkey[p.pubkey] = [];
trailsByPubkey[p.pubkey].push([p.latitude, p.longitude]);
});
Object.entries(trailsByPubkey).forEach(([pubkey, pts]) => {
if (pts.length > 1) {
state.trails[pubkey] = L.polyline(pts, { color: '#94a3b8', weight: 2, opacity: 0.6 }).addTo(state.map);
}
});
// Draw markers
positions.forEach(pos => {
if (!pos.latitude || !pos.longitude) return;
const latlng = [pos.latitude, pos.longitude];
bounds.push(latlng);
const color = ui.getMarkerColor(pos.last_heard, pos.pubkey);
const name = pos.name || pos.pubkey?.substring(0, 8) || 'Unknown';
const icon = L.divIcon({
className: '',
html: `<div style="
width:24px;height:24px;border-radius:50%;
background:${color};border:2px solid white;
box-shadow:0 2px 4px rgba(0,0,0,0.3);
display:flex;align-items:center;justify-content:center;
font-size:10px;font-weight:bold;color:white;
cursor:pointer;
">${name.charAt(0).toUpperCase()}</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const marker = L.marker(latlng, { icon })
.addTo(state.map)
.bindPopup(`
<strong>${escapeHtml(name)}</strong><br>
Type: ${ui.getContactTypeName(pos.contact_type || 0)}<br>
Last heard: ${ui.formatRelativeTime(pos.last_heard)}<br>
${pos.latitude.toFixed(6)}, ${pos.longitude.toFixed(6)}
`);
state.markers[pos.pubkey] = marker;
});
if (bounds.length > 0 && !state.mapUserMoved) {
if (bounds.length === 1) state.map.setView(bounds[0], 12);
else state.map.fitBounds(bounds, { padding: [30, 30] });
}
} catch (err) {
console.error('Error loading map:', err);
}
}
// --- Messages ---
async function loadMessages() {
try {
let type = null, channelIdx = null;
if (state.msgFilter === 'direct') type = 'direct';
if (state.msgFilter === 'channel') { type = 'channel'; channelIdx = state.msgChannelIdx; }
const messages = await api.getMessages(200, type, channelIdx);
state.lastDataReceived = Date.now();
renderRecentMessages(messages, 'message-history');
} catch (err) {
console.error('Error loading messages:', err);
}
}
document.getElementById('refresh-messages-btn')?.addEventListener('click', loadMessages);
// Wire up filter buttons
document.getElementById('msg-filter-tabs')?.addEventListener('click', (e) => {
const btn = e.target.closest('.msg-filter-btn');
if (!btn) return;
document.querySelectorAll('.msg-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const filter = btn.dataset.filter;
state.msgFilter = filter;
state.msgChannelIdx = filter === 'channel' ? parseInt(btn.dataset.ch || 0) : 0;
loadMessages();
});
document.getElementById('send-message-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const text = document.getElementById('message-text').value.trim();
if (!text) return;
const isDirect = document.getElementById('type-direct').checked;
const btn = e.target.querySelector('button[type="submit"]');
btn.disabled = true;
btn.textContent = 'Sending...';
try {
let payload;
if (isDirect) {
const toPubkey = document.getElementById('message-to-contact').value;
if (!toPubkey) { showNotification('Error', 'Select a contact', 'error'); return; }
payload = { type: 'direct', to_pubkey: toPubkey, text };
} else {
const channelIdx = parseInt(document.getElementById('message-channel-idx').value);
payload = { type: 'channel', channel_idx: channelIdx, text };
}
await api.sendMessage(payload);
document.getElementById('message-text').value = '';
showNotification('Sent', 'Message sent successfully', 'success', 2000);
loadMessages();
} catch (err) {
showNotification('Error', err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Send';
}
});
// --- Contacts ---
async function loadContacts() {
try {
const contacts = await api.getContacts();
state.lastDataReceived = Date.now();
state.contacts = contacts;
renderContacts(contacts);
} catch (err) {
console.error('Error loading contacts:', err);
}
}
document.getElementById('refresh-contacts-btn')?.addEventListener('click', loadContacts);
function renderContacts(contacts) {
const grid = document.getElementById('contacts-grid');
if (!grid) return;
if (!contacts.length) {
grid.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📡</div><p>No contacts found</p><p>Connect a MeshCore device to discover contacts.</p></div>';
return;
}
grid.innerHTML = contacts.map(c => {
const name = c.name || c.pubkey?.substring(0, 8) || 'Unknown';
const prefix = c.pubkey ? c.pubkey.substring(0, 12) : '';
const typeClass = ui.getContactTypeBadgeClass(c.contact_type);
const typeName = ui.getContactTypeName(c.contact_type);
const isOnline = c.last_heard && (Date.now() - new Date(c.last_heard.includes('T') ? c.last_heard : c.last_heard.replace(' ', 'T') + 'Z')) < 3600000;
const isFav = (c.flags & 1) === 1;
return `
<div class="node-card ${isFav ? 'favourite' : ''}" data-pubkey="${escapeHtml(c.pubkey)}" onclick="showContactModal('${escapeHtml(c.pubkey)}')">
<div class="node-card-header">
<div>
<div class="node-name">${isFav ? '★ ' : ''}${escapeHtml(name)}</div>
<div class="node-id">${prefix}…</div>
</div>
<div style="display:flex;flex-direction:column;gap:0.25rem;align-items:flex-end">
<span class="node-badge ${isOnline ? 'online' : 'offline'}">${isOnline ? 'Active' : 'Inactive'}</span>
${c.contact_type > 0 ? `<span class="node-badge ${typeClass}">${typeName}</span>` : ''}
</div>
</div>
<div class="node-info">
<div class="node-info-row"><span>Last heard</span><span>${ui.formatRelativeTime(c.last_heard)}</span></div>
<div class="node-info-row"><span>Path</span><span>${ui.formatPathLength(c.path_length)}</span></div>
${c.battery_mv ? `<div class="node-info-row"><span>Battery</span><span>${c.battery_mv} mV</span></div>` : ''}
${c.latitude != null ? `<div class="node-info-row"><span>GPS</span><span>${c.latitude.toFixed(4)}, ${c.longitude.toFixed(4)}</span></div>` : ''}
</div>
</div>`;
}).join('');
}
async function showContactModal(pubkey) {
try {
const contact = await api.getContact(pubkey);
const modal = document.getElementById('contact-modal');
const detail = document.getElementById('contact-detail');
const rows = [
{ label: 'Name', value: contact.name || '—' },
{ label: 'Public Key', value: contact.pubkey },
{ label: 'Type', value: ui.getContactTypeName(contact.contact_type) },
{ label: 'Path Length', value: ui.formatPathLength(contact.path_length) },
{ label: 'Flags', value: (contact.flags & 1) ? 'Favourite' : 'Normal' },
{ label: 'Last Heard', value: ui.formatRelativeTime(contact.last_heard) },
{ label: 'Last Advert', value: contact.last_advert ? ui.formatRelativeTime(new Date(contact.last_advert * 1000).toISOString()) : '—' },
{ label: 'Battery', value: contact.battery_mv ? `${contact.battery_mv} mV` : '—' },
{ label: 'Uptime', value: contact.uptime_seconds ? formatUptime(contact.uptime_seconds) : '—' },
];
if (contact.latitude != null) {
rows.push({ label: 'Latitude', value: contact.latitude.toFixed(6) });
rows.push({ label: 'Longitude', value: contact.longitude.toFixed(6) });
}
detail.innerHTML = rows.map(r =>
`<div class="detail-row"><span class="detail-label">${r.label}</span><span class="detail-value">${escapeHtml(String(r.value))}</span></div>`
).join('');
modal.classList.remove('hidden');
} catch (err) {
console.error('Error loading contact details:', err);
}
}
document.querySelector('#contact-modal .modal-close')?.addEventListener('click', () => {
document.getElementById('contact-modal').classList.add('hidden');
});
document.getElementById('contact-modal')?.addEventListener('click', (e) => {
if (e.target === e.currentTarget) e.currentTarget.classList.add('hidden');
});
function formatUptime(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
}
// --- Settings ---
async function loadSettings() {
try {
const status = await api.getSerialStatus();
const portInfo = document.getElementById('port-info');
if (portInfo) {
portInfo.innerHTML = `
<div class="node-info-row"><span>Port</span><span style="font-family:monospace">${status.port}</span></div>
<div class="node-info-row"><span>Status</span><span>${status.connected ? 'Connected' : 'Disconnected'}</span></div>
${status.deviceName ? `<div class="node-info-row"><span>Device</span><span>${escapeHtml(status.deviceName)}</span></div>` : ''}
`;
}
const ports = await api.getSerialPorts().catch(() => []);
const portsList = document.getElementById('available-ports');
if (portsList) {
if (ports.length === 0) {
portsList.innerHTML = '<p style="color:var(--text-secondary);font-size:0.875rem;margin-top:0.5rem">No serial ports detected.</p>';
} else {
portsList.innerHTML = '<p style="color:var(--text-secondary);font-size:0.875rem;margin-bottom:0.5rem">Available serial ports:</p>' +
ports.map(p => `
<div class="port-item">
<strong>${escapeHtml(p.path)}</strong>
${p.manufacturer ? `<span style="color:var(--text-secondary)">${escapeHtml(p.manufacturer)}</span>` : ''}
</div>`).join('');
}
}
} catch (err) {
console.error('Error loading settings:', err);
}
}
document.getElementById('purge-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const hours = parseInt(document.getElementById('purge-hours').value);
if (!confirm(`Delete all data older than ${hours < 24 ? hours + ' hour(s)' : hours / 24 + ' day(s)'}?`)) return;
try {
const result = await api.purgeData(hours);
showNotification('Purge Complete',
`Deleted: ${result.deleted.messages} messages, ${result.deleted.positions} positions, ${result.deleted.contacts} contacts`,
'success', 5000);
} catch (err) {
showNotification('Purge Failed', err.message, 'error');
}
});
// --- Utility ---
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// --- Init on page load ---
(async () => {
try {
const auth = await api.checkAuth();
if (auth.authenticated) {
state.authenticated = true;
state.user = auth.user;
initDashboard();
} else {
ui.showScreen('login-screen');
}
} catch {
ui.showScreen('login-screen');
}
})();