diff --git a/static/js/node-stats.js b/static/js/node-stats.js deleted file mode 100644 index 5184b9e..0000000 --- a/static/js/node-stats.js +++ /dev/null @@ -1,389 +0,0 @@ -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}` } }, - }, - }, - }); -}