From 382ca20af7d7ac02e9055fa923eb69ad902d668a Mon Sep 17 00:00:00 2001 From: Anton Roslund Date: Sat, 20 Sep 2025 10:38:54 +0200 Subject: [PATCH] Fix date logic --- static/js/node-stats.js | 389 ++++++++++++++++++ static/js/status/nodes-seen.js | 3 +- .../js/status/portnum-distribution-chart.js | 4 +- 3 files changed, 392 insertions(+), 4 deletions(-) create mode 100644 static/js/node-stats.js diff --git a/static/js/node-stats.js b/static/js/node-stats.js new file mode 100644 index 0000000..5184b9e --- /dev/null +++ b/static/js/node-stats.js @@ -0,0 +1,389 @@ +document.addEventListener('DOMContentLoaded', async () => { + try { + await fetchNodes(); + } catch (e) { + console.error('Failed to load nodes', e); + return; + } + + const elements = { + longName: document.getElementById('node-long-name'), + nodeId: document.getElementById('node-id'), + nodeIdHex: document.getElementById('node-id-hex'), + shortName: document.getElementById('node-short-name'), + role: document.getElementById('node-role'), + hardware: document.getElementById('node-hardware'), + firmware: document.getElementById('node-fw'), + maxHops: document.getElementById('node-max-hops'), + backbone: document.getElementById('node-backbone'), + okMqtt: document.getElementById('node-ok-mqtt'), + battery: document.getElementById('node-battery'), + voltage: document.getElementById('node-voltage'), + cu: document.getElementById('node-cu'), + air: document.getElementById('node-air'), + uptime: document.getElementById('node-uptime'), + temp: document.getElementById('node-temp'), + humidity: document.getElementById('node-humidity'), + pressure: document.getElementById('node-pressure'), + iaq: document.getElementById('node-iaq'), + deviceMetricsUpdated: document.getElementById('node-device-metrics-updated'), + environmentMetricsUpdated: document.getElementById('node-environment-metrics-updated'), + position: document.getElementById('node-position'), + altitude: document.getElementById('node-altitude'), + precision: document.getElementById('node-precision'), + updated: document.getElementById('node-updated'), + neighbours: document.getElementById('node-neighbours'), + neighboursUpdated: document.getElementById('neighbours-updated'), + nodeSearch: document.getElementById('nodeSearch'), + suggestions: document.getElementById('suggestions'), + clearBtn: document.getElementById('clearFilterBtn'), + searchContainer: document.getElementById('searchContainer'), + nodeContent: document.getElementById('nodeContent'), + }; + + function setText(el, value) { + if (!el) return; + el.textContent = value ?? '–'; + } + + function formatBool(b) { + if (b === null || b === undefined) return '–'; + return b ? 'Ja' : 'Nej'; + } + + function formatNumber(num, fractionDigits = 2) { + if (num === null || num === undefined || isNaN(num)) return '–'; + return Number(num).toFixed(fractionDigits); + } + + function formatUptime(seconds) { + const s = Number(seconds); + if (!isFinite(s) || s <= 0) return '–'; + const days = Math.floor(s / 86400); + const hours = Math.floor((s % 86400) / 3600); + const minutes = Math.floor((s % 3600) / 60); + return `${days}d ${hours}h ${minutes}m`; + } + + function formatDate(iso) { + if (!iso) return '–'; + try { + return new Date(iso).toLocaleString('sv-SE', { hour12: false }); + } catch { + return '–'; + } + } + + function degreesFromInt(value) { + if (value === null || value === undefined) return null; + return Number(value) / 1e7; + } + + function renderNeighbours(node) { + if (!elements.neighbours) return; + const neighbours = Array.isArray(node?.neighbours) ? node.neighbours : []; + if (neighbours.length === 0) { + elements.neighbours.innerHTML = ''; + setText(elements.neighboursUpdated, formatDate(node?.neighbours_updated_at)); + return; + } + + const items = neighbours + .slice() + .sort((a, b) => (b.snr ?? -999) - (a.snr ?? -999)) + .map(n => { + const neighbourNode = nodes.find(x => String(x.node_id) === String(n.node_id)); + const label = neighbourNode?.long_name || `#${n.node_id}`; + const snr = n.snr === null || n.snr === undefined ? '–' : n.snr; + return `${label} (${snr} dB)`; + }) + .join(' '); + + elements.neighbours.innerHTML = items; + setText(elements.neighboursUpdated, formatDate(node?.neighbours_updated_at)); + } + + function renderNodeDetails(node) { + setText(elements.longName, node?.long_name); + setText(elements.nodeId, node?.node_id); + setText(elements.nodeIdHex, node?.node_id_hex ?? '–'); + setText(elements.shortName, node?.short_name); + setText(elements.role, node?.role_name ?? node?.role); + setText(elements.hardware, node?.hardware_model_name ?? node?.hardware_model); + setText(elements.firmware, node?.firmware_version); + setText(elements.maxHops, node?.max_hops); + setText(elements.backbone, formatBool(node?.is_backbone)); + setText(elements.okMqtt, formatBool(node?.ok_to_mqtt)); + setText(elements.battery, node?.battery_level != null ? `${Math.round(Number(node.battery_level))}%` : '–'); + setText(elements.voltage, node?.voltage != null ? `${formatNumber(node.voltage, 3)} V` : '–'); + setText(elements.cu, node?.channel_utilization != null ? `${formatNumber(node.channel_utilization, 2)}%` : '–'); + setText(elements.air, node?.air_util_tx != null ? `${formatNumber(node.air_util_tx, 2)}%` : '–'); + setText(elements.uptime, formatUptime(node?.uptime_seconds)); + setText(elements.temp, node?.temperature != null ? `${formatNumber(node.temperature, 1)} °C` : '–'); + setText(elements.humidity, node?.relative_humidity != null ? `${formatNumber(node.relative_humidity, 1)} %` : '–'); + setText(elements.pressure, node?.barometric_pressure != null ? `${formatNumber(node.barometric_pressure, 1)} hPa` : '–'); + setText(elements.iaq, node?.iaq != null ? `${formatNumber(node.iaq, 0)}` : '–'); + setText(elements.deviceMetricsUpdated, formatDate(node?.device_metrics_updated_at)); + setText(elements.environmentMetricsUpdated, formatDate(node?.environment_metrics_updated_at)); + + const lat = degreesFromInt(node?.latitude); + const lon = degreesFromInt(node?.longitude); + const alt = node?.altitude; + const positionText = lat != null && lon != null + ? `${lat.toFixed(5)}, ${lon.toFixed(5)}` + : '–'; + setText(elements.position, positionText); + setText(elements.altitude, alt != null ? `${alt} m` : '–'); + + // Map position_precision using helper ranges from status page + function precisionToMeters(precision) { + switch(Number(precision)){ + case 2: return 5976446; case 3: return 2988223; case 4: return 1494111; case 5: return 747055; case 6: return 373527; case 7: return 186763; case 8: return 93381; case 9: return 46690; case 10: return 23345; case 11: return 11672; case 12: return 5836; case 13: return 2918; case 14: return 1459; case 15: return 729; case 16: return 364; case 17: return 182; case 18: return 91; case 19: return 45; case 20: return 22; case 21: return 11; case 22: return 5; case 23: return 2; case 24: return 1; case 32: return 0; default: return null; + } + } + const ppm = precisionToMeters(node?.position_precision); + const precisionText = ppm == null ? '–' : (ppm > 1000 ? `±${Math.ceil(ppm/1000)}km` : `±${ppm}m`); + setText(elements.precision, precisionText); + + setText(elements.updated, formatDate(node?.updated_at)); + + renderNeighbours(node); + } + + function updateQueryParam(nodeIdOrNull) { + const url = new URL(window.location.href); + if (nodeIdOrNull) { + url.searchParams.set('node_id', String(nodeIdOrNull)); + } else { + url.searchParams.delete('node_id'); + } + window.history.replaceState({}, '', url); + } + + async function applyNodeSelection(nodeId) { + const node = nodes.find(n => String(n.node_id) === String(nodeId)); + renderNodeDetails(node || null); + updateQueryParam(node ? node.node_id : null); + if (elements.nodeSearch && node?.long_name) { + elements.nodeSearch.value = node.long_name; + } + // Update the chart + try { portnumDistributionChart(node ? node.node_id : null); } catch (e) { console.warn('Chart not ready', e); } + // Show search always; hide the rest of the content if no node + if (elements.nodeContent) { + elements.nodeContent.style.display = node ? '' : 'none'; + } + // Load metric charts when a node is selected + if (node) { + try { + const [deviceData, envData] = await Promise.all([ + fetchDeviceMetricsData(node.node_id), + fetchEnvironmentMetricsData(node.node_id), + ]); + await renderDeviceMetricsChart(node.node_id, deviceData); + await renderEnvironmentMetricsChart(node.node_id, envData); + } catch (e) { console.warn('Metric charts failed', e); } + } else { + destroyChartById('deviceMetricsChart'); + destroyChartById('environmentMetricsChart'); + } + } + + // Intercept suggestion clicks (capture) to also update node details and URL + if (elements.suggestions) { + elements.suggestions.addEventListener('click', (e) => { + const target = e.target; + if (target && target.classList && target.classList.contains('dropdown-item')) { + e.preventDefault(); + e.stopPropagation(); + const text = target.textContent; + const node = nodes.find(n => n.long_name === text); + if (node) { + if (elements.nodeSearch) elements.nodeSearch.value = node.long_name; + elements.suggestions.style.display = 'none'; + applyNodeSelection(node.node_id); + } + } + }, true); + } + + // Clear button should also clear node details and URL param + if (elements.clearBtn) { + elements.clearBtn.addEventListener('click', () => { + renderNodeDetails(null); + updateQueryParam(null); + if (elements.nodeContent) elements.nodeContent.style.display = 'none'; + if (elements.suggestions) elements.suggestions.style.display = 'none'; + try { portnumDistributionChart(null); } catch (e) { /* noop */ } + destroyChartById('deviceMetricsChart'); + destroyChartById('environmentMetricsChart'); + }); + } + + // Neighbour badge click selects that neighbour + if (elements.neighbours) { + elements.neighbours.addEventListener('click', (e) => { + const target = e.target.closest('[data-node-id]'); + if (!target) return; + const nid = target.getAttribute('data-node-id'); + applyNodeSelection(nid); + }); + } + + // Initial load: check URL + const params = new URLSearchParams(window.location.search); + const nodeIdParam = params.get('node_id'); + if (nodeIdParam) { + applyNodeSelection(nodeIdParam); + } else { + renderNodeDetails(null); + if (elements.nodeContent) elements.nodeContent.style.display = 'none'; + destroyChartById('deviceMetricsChart'); + destroyChartById('environmentMetricsChart'); + } +}); + +function destroyChartById(canvasId) { + try { + const existing = window.Chart?.getChart(canvasId); + if (existing) existing.destroy(); + } catch (_) {} +} + +function firstArrayIn(obj, keys = []) { + if (Array.isArray(obj)) return obj; + if (obj && typeof obj === 'object') { + for (const key of keys) { + const candidate = obj[key]; + if (Array.isArray(candidate)) return candidate; + } + } + return []; +} + +async function fetchDeviceMetricsData(nodeId) { + const url = `https://map.sthlm-mesh.se/api/v1/nodes/${nodeId}/device-metrics`; + let arr = []; + try { + const res = await fetch(url); + const data = await res.json(); + arr = firstArrayIn(data, ['device_metrics']); + } catch (e) { + console.warn('Failed to fetch device metrics', e); + } + return arr.slice().reverse(); +} + +async function renderDeviceMetricsChart(nodeId, arr) { + const canvas = document.getElementById('deviceMetricsChart'); + if (!canvas) return; + destroyChartById('deviceMetricsChart'); + const ctx = canvas.getContext('2d'); + ctx.font = '16px Arial'; + ctx.fillStyle = 'gray'; + ctx.textAlign = 'center'; + ctx.fillText('Loading data...', canvas.width / 2, canvas.height / 2); + + if (!arr || arr.length === 0) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = 'gray'; + ctx.fillText('No data', canvas.width / 2, canvas.height / 2); + return; + } + + const labels = arr.map(d => d.created_at); + const battery = arr.map(d => d.battery_level); + const cu = arr.map(d => d.channel_utilization); + const air = arr.map(d => d.air_util_tx); + + new Chart(canvas, { + type: 'line', + data: { + labels, + datasets: [ + { label: 'Battery Level', borderColor: '#3b82f6', backgroundColor: '#3b82f6', pointStyle: false, fill: false, data: battery }, + { label: 'Channel Util', borderColor: '#22c55e', backgroundColor: '#22c55e', showLine: false, fill: false, data: cu }, + { label: 'Air Util TX', borderColor: '#f97316', backgroundColor: '#f97316', showLine: false, fill: false, data: air }, + ], + }, + options: { + responsive: true, + borderWidth: 2, + elements: { point: { radius: 2 } }, + scales: { + x: { position: 'top', type: 'time', time: { unit: 'day', displayFormats: { day: 'MMM dd' } } }, + y: { min: 0, max: 101, ticks: { callback: (v) => `${v}%` } }, + }, + plugins: { + legend: { display: false }, + tooltip: { mode: 'index', intersect: false, callbacks: { label: (item) => `${item.dataset.label}: ${item.formattedValue}%` } }, + }, + }, + }); +} + +async function fetchEnvironmentMetricsData(nodeId) { + const url = `https://map.sthlm-mesh.se/api/v1/nodes/${nodeId}/environment-metrics`; + let arr = []; + try { + const res = await fetch(url); + const data = await res.json(); + arr = firstArrayIn(data, ['environment_metrics']); + } catch (e) { + console.warn('Failed to fetch environment metrics', e); + } + return arr.slice().reverse(); +} + +async function renderEnvironmentMetricsChart(nodeId, arr) { + const canvas = document.getElementById('environmentMetricsChart'); + if (!canvas) return; + destroyChartById('environmentMetricsChart'); + const ctx = canvas.getContext('2d'); + ctx.font = '16px Arial'; + ctx.fillStyle = 'gray'; + ctx.textAlign = 'center'; + ctx.fillText('Loading data...', canvas.width / 2, canvas.height / 2); + + if (!arr || arr.length === 0) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = 'gray'; + ctx.fillText('No data', canvas.width / 2, canvas.height / 2); + return; + } + + const labels = arr.map(d => d.created_at); + const temperature = arr.map(d => d.temperature); + const humidity = arr.map(d => d.relative_humidity); + const pressure = arr.map(d => d.barometric_pressure); + const iaq = arr.map(d => d.iaq); + + new Chart(canvas, { + type: 'line', + data: { + labels, + datasets: [ + { label: 'Temperature', suffix: '°C', borderColor: '#3b82f6', backgroundColor: '#3b82f6', pointStyle: false, fill: false, data: temperature, yAxisID: 'y' }, + { label: 'Humidity', suffix: '%', borderColor: '#22c55e', backgroundColor: '#22c55e', pointStyle: false, fill: false, data: humidity, yAxisID: 'y' }, + { label: 'Pressure', suffix: 'hPa', borderColor: '#f97316', backgroundColor: '#f97316', pointStyle: false, fill: false, data: pressure, yAxisID: 'y1' }, + { label: 'IAQ', suffix: 'IAQ', borderColor: '#f472b6', backgroundColor: '#f472b6', pointStyle: false, fill: false, data: iaq, yAxisID: 'yIAQ' }, + ], + }, + options: { + responsive: true, + borderWidth: 2, + spanGaps: 1000 * 60 * 60 * 24, + elements: { point: { radius: 2 } }, + scales: { + x: { position: 'top', type: 'time', time: { unit: 'day', displayFormats: { day: 'MMM dd' } } }, + y: { min: -20, max: 100 }, + y1: { min: 800, max: 1100, ticks: { stepSize: 10, callback: (v) => `${v} hPa` }, position: 'right', grid: { drawOnChartArea: false } }, + yIAQ: { type: 'linear', display: false }, + }, + plugins: { + legend: { display: false }, + tooltip: { mode: 'index', intersect: false, callbacks: { label: (item) => `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}` } }, + }, + }, + }); +} diff --git a/static/js/status/nodes-seen.js b/static/js/status/nodes-seen.js index d10cda9..0f92484 100644 --- a/static/js/status/nodes-seen.js +++ b/static/js/status/nodes-seen.js @@ -1,7 +1,6 @@ function countNodesSince(daysAgo) { const now = new Date(); - const threshold = new Date(now); - threshold.setDate(threshold.getDate() - daysAgo); + const threshold = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000); return nodes.filter(node => new Date(node.updated_at) >= threshold).length; } diff --git a/static/js/status/portnum-distribution-chart.js b/static/js/status/portnum-distribution-chart.js index 3be8588..6d11264 100644 --- a/static/js/status/portnum-distribution-chart.js +++ b/static/js/status/portnum-distribution-chart.js @@ -4,8 +4,8 @@ let chartInstance; function filterSuggestions(query) { const lowerQuery = query.toLowerCase(); - const threshold = new Date(); - threshold.setDate(threshold.getDate() - 1); + const now = new Date(); + const threshold = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000); return nodes .filter(node => new Date(node.updated_at) >= threshold) .filter(node => node.long_name?.toLowerCase().includes(lowerQuery)) || [];