Sort order and purging fix

This commit is contained in:
Will Bradley
2025-10-12 05:14:35 -07:00
parent be243e3b50
commit 6a2148ad14
7 changed files with 219 additions and 124 deletions
+7
View File
@@ -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` 2. Check SESSION_SECRET is set in `.env`
3. Clear browser cookies and try again 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 ## Development
### Project Structure ### Project Structure
+10
View File
@@ -351,6 +351,16 @@ body {
color: var(--text-secondary); 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 */
.nodes-grid { .nodes-grid {
display: grid; display: grid;
+1 -3
View File
@@ -119,9 +119,7 @@
<option value="474572292">!1c496604</option> <option value="474572292">!1c496604</option>
</select> </select>
<select id="message-channel"> <select id="message-channel">
<option value="0">LongFast (public)</option> <!-- Options populated dynamically from config -->
<option value="1">Aether (private)</option>
<option value="2">Other (2)</option>
</select> </select>
<button type="submit" class="btn btn-primary">Send</button> <button type="submit" class="btn btn-primary">Send</button>
</div> </div>
+79 -104
View File
@@ -18,7 +18,8 @@ const state = {
reconnectAttempts: 0, reconnectAttempts: 0,
maxReconnectAttempts: 5, maxReconnectAttempts: 5,
reconnectDelay: 5000, // 5 seconds reconnectDelay: 5000, // 5 seconds
isReconnecting: false isReconnecting: false,
channelConfig: {} // Channel name configuration
}; };
// Handle authentication errors (401) // Handle authentication errors (401)
@@ -189,6 +190,10 @@ const api = {
async getMqttStatus() { async getMqttStatus() {
return this.request('/api/mqtt/status'); 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)); const bucketIndex = Math.min(7, Math.floor(minutesSince / bucketSize));
return colors[bucketIndex]; 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 // Set user info
document.getElementById('user-info').textContent = state.user.username; 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 // Update MQTT status
updateMqttStatus(); updateMqttStatus();
@@ -388,6 +441,23 @@ async function initDashboard() {
startRefreshIntervals(); 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 // Update MQTT Status
async function updateMqttStatus() { async function updateMqttStatus() {
try { try {
@@ -504,6 +574,7 @@ function displayMessages(messages, containerId) {
if (!existingIds.has(msgId)) { if (!existingIds.has(msgId)) {
// Create new message element // Create new message element
const channelColor = msg.channel !== null && msg.channel !== undefined ? ui.getChannelColor(msg.channel) : '#2563eb';
const messageHtml = ` const messageHtml = `
<div class="message-item" data-message-id="${msgId}"> <div class="message-item" data-message-id="${msgId}">
<div class="message-header"> <div class="message-header">
@@ -513,13 +584,11 @@ function displayMessages(messages, containerId) {
<span class="message-time" data-timestamp="${msg.created_at}">${ui.formatRelativeTime(msg.created_at)}</span> <span class="message-time" data-timestamp="${msg.created_at}">${ui.formatRelativeTime(msg.created_at)}</span>
</div> </div>
<div class="message-text">${escapeHtml(msg.text || '')}</div> <div class="message-text">${escapeHtml(msg.text || '')}</div>
${msg.rx_snr || msg.rx_rssi ? `
<div class="message-meta"> <div class="message-meta">
${msg.rx_snr ? `SNR: ${msg.rx_snr.toFixed(1)} dB` : ''} ${msg.channel !== null && msg.channel !== undefined ? `<span class="message-channel" style="background-color: ${channelColor};">${ui.getChannelName(msg.channel)}</span>` : ''}
${msg.rx_snr ? ` | SNR: ${msg.rx_snr.toFixed(1)} dB` : ''}
${msg.rx_rssi ? ` | RSSI: ${msg.rx_rssi} dBm` : ''} ${msg.rx_rssi ? ` | RSSI: ${msg.rx_rssi} dBm` : ''}
${msg.channel !== null ? ` | Channel: ${msg.channel}` : ''}
</div> </div>
` : ''}
</div> </div>
`; `;
@@ -590,107 +659,14 @@ function displayNodes(nodes) {
return; return;
} }
// Build a map of node IDs to nodes // Clear and rebuild to ensure proper sorting
const nodeMap = new Map(); container.innerHTML = '';
nodes.forEach(node => {
nodeMap.set(node.node_id, node);
});
// Get existing node elements // Add nodes in the order they come from API (already sorted)
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 => { nodes.forEach(node => {
const lastHeard = node.last_heard ? new Date(node.last_heard) : null; const lastHeard = node.last_heard ? new Date(node.last_heard) : null;
const isOnline = lastHeard && (Date.now() - lastHeard.getTime()) < 900000; // 15 minutes 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 = ` const nodeHtml = `
<div class="node-card" data-node-id="${escapeHtml(node.node_id)}"> <div class="node-card" data-node-id="${escapeHtml(node.node_id)}">
<div class="node-card-header"> <div class="node-card-header">
@@ -722,13 +698,12 @@ function displayNodes(nodes) {
container.insertAdjacentHTML('beforeend', nodeHtml); container.insertAdjacentHTML('beforeend', nodeHtml);
// Add click handler for the new card // Add click handler for the new card
const newCard = container.querySelector(`[data-node-id="${node.node_id}"]`); const newCard = container.lastElementChild;
if (newCard) { if (newCard) {
newCard.addEventListener('click', () => { newCard.addEventListener('click', () => {
window.showNodeDetail(node.node_id); window.showNodeDetail(node.node_id);
}); });
} }
}
}); });
} }
@@ -1063,7 +1038,7 @@ document.getElementById('purge-form')?.addEventListener('submit', async (e) => {
try { try {
const result = await api.purgeData(hours); 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 // Reload stats
loadOverview(); loadOverview();
+12
View File
@@ -50,5 +50,17 @@ module.exports = {
// Logging configuration // Logging configuration
logging: { logging: {
level: process.env.LOG_LEVEL || 'info' 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'
} }
}; };
+68 -2
View File
@@ -34,7 +34,7 @@ const nodeQueries = {
hardware_model = COALESCE(excluded.hardware_model, hardware_model), hardware_model = COALESCE(excluded.hardware_model, hardware_model),
role = COALESCE(excluded.role, role), role = COALESCE(excluded.role, role),
firmware_version = COALESCE(excluded.firmware_version, firmware_version), 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), battery_level = COALESCE(excluded.battery_level, battery_level),
voltage = COALESCE(excluded.voltage, voltage), voltage = COALESCE(excluded.voltage, voltage),
channel_utilization = COALESCE(excluded.channel_utilization, channel_utilization), 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 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 (SELECT timestamp FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as position_timestamp
FROM nodes n FROM nodes n
ORDER BY n.last_heard DESC ORDER BY
COALESCE(datetime(n.last_heard), datetime('1970-01-01')) DESC
`), `),
updateNodeLastHeard: db.prepare(` updateNodeLastHeard: db.prepare(`
UPDATE nodes SET last_heard = CURRENT_TIMESTAMP WHERE node_id = ? 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')
)
`) `)
}; };
+32 -5
View File
@@ -227,11 +227,31 @@ router.post('/purge', requireAuth, (req, res) => {
const { hours } = req.body; const { hours } = req.body;
const hoursToKeep = hours || 720; // Default to 30 days (720 hours) const hoursToKeep = hours || 720; // Default to 30 days (720 hours)
const messagesDeleted = messageQueries.deleteOldMessages.run(hoursToKeep); logger.info(`Starting purge of data older than ${hoursToKeep} hours`);
const positionsDeleted = positionQueries.deleteOldPositions.run(hoursToKeep);
const telemetryDeleted = telemetryQueries.deleteOldTelemetry.run(hoursToKeep);
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 // Log activity
const timePeriod = hoursToKeep < 24 const timePeriod = hoursToKeep < 24
@@ -250,7 +270,8 @@ router.post('/purge', requireAuth, (req, res) => {
deleted: { deleted: {
messages: messagesDeleted.changes, messages: messagesDeleted.changes,
positions: positionsDeleted.changes, positions: positionsDeleted.changes,
telemetry: telemetryDeleted.changes telemetry: telemetryDeleted.changes,
nodes: nodesDeleted.changes
} }
}); });
} catch (error) { } 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; module.exports = router;