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}` } },
- },
- },
- });
-}