822 lines
32 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// --- 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');
|
|
}
|
|
})();
|