From 6a2148ad14455aa70562ea29a6b7abe76ba137ec Mon Sep 17 00:00:00 2001 From: Will Bradley Date: Sun, 12 Oct 2025 05:14:35 -0700 Subject: [PATCH] Sort order and purging fix --- README.md | 7 ++ public/css/style.css | 10 ++ public/index.html | 4 +- public/js/app.js | 203 ++++++++++++++++++---------------------- src/config/config.js | 12 +++ src/database/queries.js | 70 +++++++++++++- src/routes/api.js | 37 +++++++- 7 files changed, 219 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index afe0ea7..58c2fd3 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,13 @@ Log level can be configured with `LOG_LEVEL` in `.env` (debug, info, warn, error 2. Check SESSION_SECRET is set in `.env` 3. Clear browser cookies and try again +## T-Deck + +- It's touch screen, which is often easier than using the trackball. +- To pair over Bluetooth, power on the T-Deck and LONG PRESS (about 2 seconds) the Meshtastic logo. +- To set the timezone properly, it should be `PST8PDT,M3.2.0,M11.1.0` for PST +- To get map tiles: https://www.jeffgeerling.com/blog/2025/adding-gps-and-grid-maps-my-meshtastic-t-deck + ## Development ### Project Structure diff --git a/public/css/style.css b/public/css/style.css index 83a20aa..9560cce 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -351,6 +351,16 @@ body { color: var(--text-secondary); } +.message-channel { + display: inline-block; + padding: 0.125rem 0.5rem; + color: white; + border-radius: 0.25rem; + font-weight: 500; + font-size: 0.7rem; + text-transform: uppercase; +} + /* Nodes Grid */ .nodes-grid { display: grid; diff --git a/public/index.html b/public/index.html index 55a04fa..b432d54 100644 --- a/public/index.html +++ b/public/index.html @@ -119,9 +119,7 @@ diff --git a/public/js/app.js b/public/js/app.js index 11c456e..bdd9baa 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -18,7 +18,8 @@ const state = { reconnectAttempts: 0, maxReconnectAttempts: 5, reconnectDelay: 5000, // 5 seconds - isReconnecting: false + isReconnecting: false, + channelConfig: {} // Channel name configuration }; // Handle authentication errors (401) @@ -189,6 +190,10 @@ const api = { async getMqttStatus() { return this.request('/api/mqtt/status'); + }, + + async getChannelConfig() { + return this.request('/api/config/channels'); } }; @@ -321,6 +326,35 @@ const ui = { const bucketIndex = Math.min(7, Math.floor(minutesSince / bucketSize)); return colors[bucketIndex]; + }, + + getChannelName(channelNumber) { + return state.channelConfig[channelNumber] || `Channel ${channelNumber}`; + }, + + // Hash a string to a color + hashStringToColor(str) { + if (!str) return '#2563eb'; // Default primary color + + // Simple hash function + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + hash = hash & hash; // Convert to 32bit integer + } + + // Convert to HSL for better color distribution + // Use hue from hash, fixed saturation and lightness for readability + const hue = Math.abs(hash % 360); + const saturation = 65; // 65% saturation for vibrant colors + const lightness = 45; // 45% lightness for good contrast with white text + + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; + }, + + getChannelColor(channelNumber) { + const channelName = this.getChannelName(channelNumber); + return this.hashStringToColor(channelName); } }; @@ -378,6 +412,25 @@ async function initDashboard() { // Set user info document.getElementById('user-info').textContent = state.user.username; + // Load channel configuration + try { + state.channelConfig = await api.getChannelConfig(); + populateChannelSelector(); + } catch (error) { + console.error('Error loading channel config:', error); + // Use defaults if config fails to load + state.channelConfig = { + 0: 'LongFast', + 1: 'Aether', + 2: 'Channel 2', + 3: 'Channel 3', + 4: 'Channel 4', + 5: 'Channel 5', + 6: 'Channel 6', + 7: 'Channel 7' + }; + } + // Update MQTT status updateMqttStatus(); @@ -388,6 +441,23 @@ async function initDashboard() { startRefreshIntervals(); } +// Populate channel selector dropdown +function populateChannelSelector() { + const channelSelect = document.getElementById('message-channel'); + if (!channelSelect) return; + + // Clear existing options + channelSelect.innerHTML = ''; + + // Add options from config + Object.entries(state.channelConfig).forEach(([channelNum, channelName]) => { + const option = document.createElement('option'); + option.value = channelNum; + option.textContent = channelName; + channelSelect.appendChild(option); + }); +} + // Update MQTT Status async function updateMqttStatus() { try { @@ -504,6 +574,7 @@ function displayMessages(messages, containerId) { if (!existingIds.has(msgId)) { // Create new message element + const channelColor = msg.channel !== null && msg.channel !== undefined ? ui.getChannelColor(msg.channel) : '#2563eb'; const messageHtml = `
@@ -513,13 +584,11 @@ function displayMessages(messages, containerId) { ${ui.formatRelativeTime(msg.created_at)}
${escapeHtml(msg.text || '')}
- ${msg.rx_snr || msg.rx_rssi ? ` -
- ${msg.rx_snr ? `SNR: ${msg.rx_snr.toFixed(1)} dB` : ''} - ${msg.rx_rssi ? ` | RSSI: ${msg.rx_rssi} dBm` : ''} - ${msg.channel !== null ? ` | Channel: ${msg.channel}` : ''} -
- ` : ''} +
+ ${msg.channel !== null && msg.channel !== undefined ? `${ui.getChannelName(msg.channel)}` : ''} + ${msg.rx_snr ? ` | SNR: ${msg.rx_snr.toFixed(1)} dB` : ''} + ${msg.rx_rssi ? ` | RSSI: ${msg.rx_rssi} dBm` : ''} +
`; @@ -590,108 +659,15 @@ function displayNodes(nodes) { return; } - // Build a map of node IDs to nodes - const nodeMap = new Map(); - nodes.forEach(node => { - nodeMap.set(node.node_id, node); - }); + // Clear and rebuild to ensure proper sorting + container.innerHTML = ''; - // 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 + // Add nodes in the order they come from API (already sorted) nodes.forEach(node => { const lastHeard = node.last_heard ? new Date(node.last_heard) : null; const isOnline = lastHeard && (Date.now() - lastHeard.getTime()) < 900000; // 15 minutes - if (existingIds.has(node.node_id)) { - // Update existing node card content - const card = container.querySelector(`[data-node-id="${node.node_id}"]`); - if (card) { - // Update node name - const nameElem = card.querySelector('.node-name'); - if (nameElem) nameElem.textContent = node.long_name || node.short_name || 'Unknown'; - - // Update badge - const badge = card.querySelector('.node-badge'); - if (badge) { - badge.className = `node-badge ${isOnline ? 'online' : 'offline'}`; - badge.textContent = isOnline ? 'Online' : 'Offline'; - } - - // Update last heard time - const timeElem = card.querySelector('.relative-time'); - if (timeElem && node.last_heard) { - timeElem.setAttribute('data-timestamp', node.last_heard); - timeElem.textContent = ui.formatRelativeTime(node.last_heard); - } - - // Update battery if present - const nodeInfo = card.querySelector('.node-info'); - if (nodeInfo && node.battery_level) { - // Check if battery row already exists - let batteryRow = null; - nodeInfo.querySelectorAll('.node-info-row').forEach(row => { - const label = row.querySelector('span:first-child'); - if (label && label.textContent.includes('Battery:')) { - batteryRow = row; - } - }); - - if (!batteryRow) { - nodeInfo.insertAdjacentHTML('beforeend', - `
Battery:${node.battery_level}%
` - ); - } else { - // Update existing battery value - const valueSpan = batteryRow.querySelector('span:last-child'); - if (valueSpan) valueSpan.textContent = `${node.battery_level}%`; - } - } - - // Update or add globe button if location is available - const headerDiv = card.querySelector('.node-card-header > div:last-child'); - if (headerDiv && node.latitude && node.longitude) { - let globeButton = headerDiv.querySelector('a[href*="openstreetmap"]'); - if (!globeButton) { - const globeHtml = `🌍`; - const badge = headerDiv.querySelector('.node-badge'); - if (badge) { - badge.insertAdjacentHTML('beforebegin', globeHtml); - } - } else { - // Update href if coordinates changed - globeButton.href = `https://www.openstreetmap.org/?mlat=${node.latitude}&mlon=${node.longitude}&zoom=11`; - } - } else if (headerDiv && !node.latitude) { - // Remove globe button if location is no longer available - const globeButton = headerDiv.querySelector('a[href*="openstreetmap"]'); - if (globeButton) { - globeButton.remove(); - } - } - } - } else { - // Create new node card - const nodeHtml = ` + const nodeHtml = `
@@ -719,15 +695,14 @@ function displayNodes(nodes) {
`; - container.insertAdjacentHTML('beforeend', nodeHtml); + 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); - }); - } + // Add click handler for the new card + const newCard = container.lastElementChild; + if (newCard) { + newCard.addEventListener('click', () => { + window.showNodeDetail(node.node_id); + }); } }); } @@ -1063,7 +1038,7 @@ document.getElementById('purge-form')?.addEventListener('submit', async (e) => { try { const result = await api.purgeData(hours); - alert(`Successfully purged:\n${result.deleted.messages} messages\n${result.deleted.positions} positions\n${result.deleted.telemetry} telemetry records`); + alert(`Successfully purged:\n${result.deleted.messages} messages\n${result.deleted.positions} positions\n${result.deleted.telemetry} telemetry records\n${result.deleted.nodes} nodes`); // Reload stats loadOverview(); diff --git a/src/config/config.js b/src/config/config.js index 613d283..d6cf1d5 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -50,5 +50,17 @@ module.exports = { // Logging configuration logging: { level: process.env.LOG_LEVEL || 'info' + }, + + // Channel configuration + channels: { + 0: process.env.CHANNEL_0_NAME || 'LongFast', + 1: process.env.CHANNEL_1_NAME || 'Aether', + 2: process.env.CHANNEL_2_NAME || 'Channel 2', + 3: process.env.CHANNEL_3_NAME || 'Channel 3', + 4: process.env.CHANNEL_4_NAME || 'Channel 4', + 5: process.env.CHANNEL_5_NAME || 'Channel 5', + 6: process.env.CHANNEL_6_NAME || 'Channel 6', + 7: process.env.CHANNEL_7_NAME || 'Channel 7' } }; diff --git a/src/database/queries.js b/src/database/queries.js index a2a7463..0f83768 100644 --- a/src/database/queries.js +++ b/src/database/queries.js @@ -34,7 +34,7 @@ const nodeQueries = { hardware_model = COALESCE(excluded.hardware_model, hardware_model), role = COALESCE(excluded.role, role), firmware_version = COALESCE(excluded.firmware_version, firmware_version), - last_heard = COALESCE(excluded.last_heard, CURRENT_TIMESTAMP), + last_heard = CURRENT_TIMESTAMP, battery_level = COALESCE(excluded.battery_level, battery_level), voltage = COALESCE(excluded.voltage, voltage), channel_utilization = COALESCE(excluded.channel_utilization, channel_utilization), @@ -62,11 +62,77 @@ const nodeQueries = { (SELECT altitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as altitude, (SELECT timestamp FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as position_timestamp FROM nodes n - ORDER BY n.last_heard DESC + ORDER BY + COALESCE(datetime(n.last_heard), datetime('1970-01-01')) DESC `), updateNodeLastHeard: db.prepare(` UPDATE nodes SET last_heard = CURRENT_TIMESTAMP WHERE node_id = ? + `), + + updateNullLastHeard: db.prepare(` + UPDATE nodes + SET last_heard = ( + SELECT MAX(latest_time) + FROM ( + SELECT MAX(created_at) as latest_time FROM messages WHERE from_node = nodes.node_id + UNION ALL + SELECT MAX(timestamp) as latest_time FROM positions WHERE node_id = nodes.node_id + UNION ALL + SELECT MAX(timestamp) as latest_time FROM telemetry WHERE node_id = nodes.node_id + ) + ) + WHERE last_heard IS NULL + AND EXISTS ( + SELECT 1 FROM messages WHERE from_node = nodes.node_id + UNION + SELECT 1 FROM positions WHERE node_id = nodes.node_id + UNION + SELECT 1 FROM telemetry WHERE node_id = nodes.node_id + ) + `), + + getOldNodesWithData: db.prepare(` + SELECT + n.node_id, + n.last_heard, + (SELECT COUNT(*) FROM messages WHERE from_node = n.node_id) as message_count, + (SELECT COUNT(*) FROM positions WHERE node_id = n.node_id) as position_count, + (SELECT COUNT(*) FROM telemetry WHERE node_id = n.node_id) as telemetry_count + FROM nodes n + WHERE datetime(last_heard) < datetime('now', '-' || ? || ' hours') OR last_heard IS NULL + `), + + deleteOldNodes: db.prepare(` + DELETE FROM nodes + WHERE ( + datetime(last_heard) < datetime('now', '-' || ? || ' hours') + OR ( + last_heard IS NULL + AND node_id NOT IN ( + SELECT DISTINCT from_node FROM messages WHERE from_node IS NOT NULL + UNION + SELECT DISTINCT node_id FROM positions WHERE node_id IS NOT NULL + UNION + SELECT DISTINCT node_id FROM telemetry WHERE node_id IS NOT NULL + ) + ) + ) + AND node_id NOT IN ( + SELECT DISTINCT from_node FROM messages + WHERE from_node IS NOT NULL + AND datetime(created_at) >= datetime('now', '-' || ? || ' hours') + ) + AND node_id NOT IN ( + SELECT DISTINCT node_id FROM positions + WHERE node_id IS NOT NULL + AND datetime(timestamp) >= datetime('now', '-' || ? || ' hours') + ) + AND node_id NOT IN ( + SELECT DISTINCT node_id FROM telemetry + WHERE node_id IS NOT NULL + AND datetime(timestamp) >= datetime('now', '-' || ? || ' hours') + ) `) }; diff --git a/src/routes/api.js b/src/routes/api.js index 66cb56d..34d9eb7 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -227,11 +227,31 @@ router.post('/purge', requireAuth, (req, res) => { const { hours } = req.body; const hoursToKeep = hours || 720; // Default to 30 days (720 hours) - const messagesDeleted = messageQueries.deleteOldMessages.run(hoursToKeep); - const positionsDeleted = positionQueries.deleteOldPositions.run(hoursToKeep); - const telemetryDeleted = telemetryQueries.deleteOldTelemetry.run(hoursToKeep); + logger.info(`Starting purge of data older than ${hoursToKeep} hours`); - logger.info(`Data purged: ${messagesDeleted.changes} messages, ${positionsDeleted.changes} positions, ${telemetryDeleted.changes} telemetry records`); + // First, update any nodes with NULL last_heard based on their most recent data + const nullLastHeardUpdated = nodeQueries.updateNullLastHeard.run(); + logger.info(`Updated ${nullLastHeardUpdated.changes} nodes with NULL last_heard`); + + const messagesDeleted = messageQueries.deleteOldMessages.run(hoursToKeep); + logger.info(`Deleted ${messagesDeleted.changes} old messages`); + + const positionsDeleted = positionQueries.deleteOldPositions.run(hoursToKeep); + logger.info(`Deleted ${positionsDeleted.changes} old positions`); + + const telemetryDeleted = telemetryQueries.deleteOldTelemetry.run(hoursToKeep); + logger.info(`Deleted ${telemetryDeleted.changes} old telemetry records`); + + // Check which old nodes still have data before deleting + const oldNodesWithData = nodeQueries.getOldNodesWithData.all(hoursToKeep); + oldNodesWithData.forEach(node => { + logger.info(`Old node ${node.node_id} (last_heard: ${node.last_heard}): ${node.message_count} messages, ${node.position_count} positions, ${node.telemetry_count} telemetry`); + }); + + const nodesDeleted = nodeQueries.deleteOldNodes.run(hoursToKeep, hoursToKeep, hoursToKeep, hoursToKeep); + logger.info(`Deleted ${nodesDeleted.changes} old nodes`); + + logger.info(`Data purged: ${messagesDeleted.changes} messages, ${positionsDeleted.changes} positions, ${telemetryDeleted.changes} telemetry records, ${nodesDeleted.changes} nodes`); // Log activity const timePeriod = hoursToKeep < 24 @@ -250,7 +270,8 @@ router.post('/purge', requireAuth, (req, res) => { deleted: { messages: messagesDeleted.changes, positions: positionsDeleted.changes, - telemetry: telemetryDeleted.changes + telemetry: telemetryDeleted.changes, + nodes: nodesDeleted.changes } }); } catch (error) { @@ -266,4 +287,10 @@ router.get('/mqtt/status', requireAuth, (req, res) => { }); }); +// Get channel configuration +router.get('/config/channels', requireAuth, (req, res) => { + const config = require('../config/config'); + res.json(config.channels); +}); + module.exports = router;