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)) || [];