diff --git a/web/lib/potato_mesh/application/routes/root.rb b/web/lib/potato_mesh/application/routes/root.rb
index 555c5ad..7286acc 100644
--- a/web/lib/potato_mesh/application/routes/root.rb
+++ b/web/lib/potato_mesh/application/routes/root.rb
@@ -181,6 +181,10 @@ module PotatoMesh
render_root_view(:chat, view_mode: :chat)
end
+ app.get %r{/charts/?} do
+ render_root_view(:charts, view_mode: :charts)
+ end
+
app.get "/nodes/:id" do
node_ref = params.fetch("id", nil)
reference_payload = build_node_detail_reference(node_ref)
diff --git a/web/public/assets/js/app/__tests__/charts-page.test.js b/web/public/assets/js/app/__tests__/charts-page.test.js
new file mode 100644
index 0000000..3b9ecbc
--- /dev/null
+++ b/web/public/assets/js/app/__tests__/charts-page.test.js
@@ -0,0 +1,149 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ fetchAggregatedTelemetry,
+ initializeChartsPage,
+ buildMovingAverageSeries,
+} from '../charts-page.js';
+
+function createResponse(status, body) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ async json() {
+ return body;
+ },
+ };
+}
+
+test('fetchAggregatedTelemetry requests the latest 1000 telemetry entries', async () => {
+ const requests = [];
+ const fetchImpl = async url => {
+ requests.push(url);
+ return createResponse(200, [{ rx_time: 1_700_000_000, node_id: '!demo' }]);
+ };
+ const snapshots = await fetchAggregatedTelemetry({ fetchImpl });
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0], '/api/telemetry?limit=1000');
+ assert.equal(Array.isArray(snapshots), true);
+ assert.equal(snapshots[0].node_id, '!demo');
+});
+
+test('fetchAggregatedTelemetry validates fetch availability and response codes', async () => {
+ await assert.rejects(() => fetchAggregatedTelemetry({ fetchImpl: null }), /fetch implementation/i);
+ const fetchImpl = async () => createResponse(503, []);
+ await assert.rejects(() => fetchAggregatedTelemetry({ fetchImpl }), /Failed to fetch telemetry/);
+});
+
+test('initializeChartsPage renders the telemetry charts when snapshots are available', async () => {
+ const container = { innerHTML: '' };
+ const documentStub = {
+ getElementById(id) {
+ return id === 'chartsPage' ? container : null;
+ },
+ };
+ const fetchImpl = async () => createResponse(200, [{ rx_time: 1_700_000_000, temperature: 22.5 }]);
+ let receivedOptions = null;
+ const renderCharts = (node, options) => {
+ receivedOptions = options;
+ return '';
+ };
+ const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
+ assert.equal(result, true);
+ assert.equal(container.innerHTML.includes('node-detail__charts'), true);
+ assert.ok(receivedOptions);
+ assert.equal(receivedOptions.chartOptions.windowMs, 86_400_000);
+ assert.equal(typeof receivedOptions.chartOptions.lineReducer, 'function');
+ const average = receivedOptions.chartOptions.lineReducer(
+ [
+ { timestamp: 0, value: 0 },
+ { timestamp: 1_800_000, value: 10 },
+ { timestamp: 3_600_000, value: 20 },
+ ],
+ );
+ assert.equal(Array.isArray(average), true);
+});
+
+test('initializeChartsPage shows an error message when fetching fails', async () => {
+ const container = { innerHTML: '' };
+ const documentStub = {
+ getElementById() {
+ return container;
+ },
+ };
+ const fetchImpl = async () => {
+ throw new Error('network');
+ };
+ const renderCharts = () => '';
+ const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
+ assert.equal(result, false);
+ assert.equal(container.innerHTML.includes('Failed to load telemetry charts.'), true);
+});
+
+test('initializeChartsPage handles missing containers and empty telemetry snapshots', async () => {
+ const documentMissing = { getElementById() { return null; } };
+ const noneResult = await initializeChartsPage({ document: documentMissing });
+ assert.equal(noneResult, false);
+
+ const container = { innerHTML: '' };
+ const documentStub = {
+ getElementById() {
+ return container;
+ },
+ };
+ const fetchImpl = async () => createResponse(200, []);
+ const renderCharts = () => '';
+ const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
+ assert.equal(result, true);
+ assert.equal(container.innerHTML.includes('Telemetry snapshots are unavailable.'), true);
+});
+
+test('initializeChartsPage shows a status when rendering produces no markup', async () => {
+ const container = { innerHTML: '' };
+ const documentStub = {
+ getElementById() {
+ return container;
+ },
+ };
+ const fetchImpl = async () => createResponse(200, [{ rx_time: 1_700_000_000 }]);
+ const renderCharts = () => '';
+ const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
+ assert.equal(result, true);
+ assert.equal(container.innerHTML.includes('Telemetry snapshots are unavailable.'), true);
+});
+
+test('initializeChartsPage validates the document contract', async () => {
+ await assert.rejects(() => initializeChartsPage({ document: {} }), /getElementById/);
+});
+
+test('buildMovingAverageSeries computes a rolling mean across the window', () => {
+ const points = [
+ { timestamp: 0, value: 0 },
+ { timestamp: 30 * 60 * 1000, value: 30 },
+ { timestamp: 60 * 60 * 1000, value: 60 },
+ { timestamp: 90 * 60 * 1000, value: 90 },
+ ];
+ const averages = buildMovingAverageSeries(points, 60 * 60 * 1000);
+ assert.equal(averages.length, points.length);
+ assert.equal(Math.round(averages[0].value), 0);
+ assert.equal(Math.round(averages[1].value), 15);
+ assert.equal(Math.round(averages[2].value), 30);
+ assert.equal(Math.round(averages[3].value), 60);
+});
diff --git a/web/public/assets/js/app/charts-page.js b/web/public/assets/js/app/charts-page.js
new file mode 100644
index 0000000..76f6d67
--- /dev/null
+++ b/web/public/assets/js/app/charts-page.js
@@ -0,0 +1,148 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderTelemetryCharts } from './node-page.js';
+
+const TELEMETRY_AGGREGATE_LIMIT = 1000;
+const HOUR_MS = 60 * 60 * 1000;
+const DAY_MS = 24 * HOUR_MS;
+
+function escapeHtml(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function renderStatus(message, { error = false } = {}) {
+ const errorClass = error ? ' charts-page__status--error' : '';
+ return `
${escapeHtml(message)}
`;
+}
+
+function padTwo(value) {
+ const num = Number(value);
+ if (Number.isNaN(num)) return '00';
+ return num < 10 ? `0${Math.trunc(num)}` : String(Math.trunc(num));
+}
+
+function buildHourlyTickList(nowMs, windowMs = DAY_MS) {
+ const ticks = [];
+ const safeWindow = Number.isFinite(windowMs) && windowMs > 0 ? windowMs : DAY_MS;
+ const domainStart = nowMs - safeWindow;
+ const cursor = new Date(nowMs);
+ cursor.setMinutes(0, 0, 0);
+ for (let ts = cursor.getTime(); ts >= domainStart; ts -= HOUR_MS) {
+ ticks.push(ts);
+ }
+ return ticks.reverse();
+}
+
+function formatHourLabel(timestampMs) {
+ const date = new Date(timestampMs);
+ if (Number.isNaN(date.getTime())) return '';
+ return padTwo(date.getHours());
+}
+
+export function buildMovingAverageSeries(points, windowMs = HOUR_MS) {
+ if (!Array.isArray(points) || points.length === 0) {
+ return [];
+ }
+ const safeWindow = Number.isFinite(windowMs) && windowMs > 0 ? windowMs : HOUR_MS;
+ const window = [];
+ let sum = 0;
+ const averages = [];
+ for (const point of points) {
+ if (!point || typeof point.timestamp !== 'number' || typeof point.value !== 'number') {
+ continue;
+ }
+ window.push(point);
+ sum += point.value;
+ while (window.length && point.timestamp - window[0].timestamp > safeWindow) {
+ const removed = window.shift();
+ sum -= removed.value;
+ }
+ if (window.length > 0) {
+ averages.push({
+ timestamp: point.timestamp,
+ value: sum / window.length,
+ });
+ }
+ }
+ return averages;
+}
+
+export async function fetchAggregatedTelemetry({ fetchImpl = globalThis.fetch, limit = TELEMETRY_AGGREGATE_LIMIT } = {}) {
+ const fetchFn = typeof fetchImpl === 'function' ? fetchImpl : null;
+ if (!fetchFn) {
+ throw new TypeError('A fetch implementation is required to load telemetry');
+ }
+ const effectiveLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : TELEMETRY_AGGREGATE_LIMIT;
+ const response = await fetchFn(`/api/telemetry?limit=${effectiveLimit}`, { cache: 'no-store' });
+ if (!response.ok) {
+ throw new Error(`Failed to fetch telemetry (HTTP ${response.status})`);
+ }
+ const payload = await response.json();
+ return Array.isArray(payload) ? payload : [];
+}
+
+export async function initializeChartsPage(options = {}) {
+ const documentRef = options.document ?? globalThis.document;
+ if (!documentRef || typeof documentRef.getElementById !== 'function') {
+ throw new TypeError('A document with getElementById support is required');
+ }
+ const rootId = options.rootId ?? 'chartsPage';
+ const container = documentRef.getElementById(rootId);
+ if (!container) {
+ return false;
+ }
+
+ const renderCharts = typeof options.renderCharts === 'function' ? options.renderCharts : renderTelemetryCharts;
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
+ const limit = options.limit ?? TELEMETRY_AGGREGATE_LIMIT;
+
+ container.innerHTML = renderStatus('Loading aggregated telemetry charts…');
+
+ try {
+ const snapshots = await fetchAggregatedTelemetry({ fetchImpl, limit });
+ if (!Array.isArray(snapshots) || snapshots.length === 0) {
+ container.innerHTML = renderStatus('Telemetry snapshots are unavailable.');
+ return true;
+ }
+ const node = { rawSources: { telemetry: { snapshots } } };
+ const chartsHtml = renderCharts(node, {
+ nowMs: Date.now(),
+ chartOptions: {
+ windowMs: DAY_MS,
+ timeRangeLabel: 'Last 24 hours',
+ xAxisTickBuilder: buildHourlyTickList,
+ xAxisTickFormatter: formatHourLabel,
+ lineReducer: points => buildMovingAverageSeries(points, HOUR_MS),
+ },
+ });
+ if (!chartsHtml) {
+ container.innerHTML = renderStatus('Telemetry snapshots are unavailable.');
+ return true;
+ }
+ container.innerHTML = chartsHtml;
+ return true;
+ } catch (error) {
+ console.error('Failed to render aggregated telemetry charts', error);
+ container.innerHTML = renderStatus('Failed to load telemetry charts.', { error: true });
+ return false;
+ }
+}
diff --git a/web/public/assets/js/app/node-page.js b/web/public/assets/js/app/node-page.js
index bde94d2..233b48e 100644
--- a/web/public/assets/js/app/node-page.js
+++ b/web/public/assets/js/app/node-page.js
@@ -35,6 +35,7 @@ const RENDER_WAIT_INTERVAL_MS = 20;
const RENDER_WAIT_TIMEOUT_MS = 500;
const NEIGHBOR_ROLE_FETCH_CONCURRENCY = 4;
const DAY_MS = 86_400_000;
+const HOUR_MS = 3_600_000;
const TELEMETRY_WINDOW_MS = DAY_MS * 7;
const DEFAULT_CHART_DIMENSIONS = Object.freeze({ width: 660, height: 360 });
const DEFAULT_CHART_MARGIN = Object.freeze({ top: 28, right: 80, bottom: 64, left: 80 });
@@ -644,9 +645,10 @@ function formatCompactDate(timestampMs) {
* @param {number} nowMs Reference timestamp in milliseconds.
* @returns {Array} Midnight timestamps within the window.
*/
-function buildMidnightTicks(nowMs) {
+function buildMidnightTicks(nowMs, windowMs = TELEMETRY_WINDOW_MS) {
const ticks = [];
- const domainStart = nowMs - TELEMETRY_WINDOW_MS;
+ const safeWindow = Number.isFinite(windowMs) && windowMs > 0 ? windowMs : TELEMETRY_WINDOW_MS;
+ const domainStart = nowMs - safeWindow;
const cursor = new Date(nowMs);
cursor.setHours(0, 0, 0, 0);
for (let ts = cursor.getTime(); ts >= domainStart; ts -= DAY_MS) {
@@ -655,6 +657,25 @@ function buildMidnightTicks(nowMs) {
return ticks.reverse();
}
+/**
+ * Build hourly tick timestamps across the provided window.
+ *
+ * @param {number} nowMs Reference timestamp in milliseconds.
+ * @param {number} [windowMs=DAY_MS] Window size in milliseconds.
+ * @returns {Array} Hourly tick timestamps.
+ */
+function buildHourlyTicks(nowMs, windowMs = DAY_MS) {
+ const ticks = [];
+ const safeWindow = Number.isFinite(windowMs) && windowMs > 0 ? windowMs : DAY_MS;
+ const domainStart = nowMs - safeWindow;
+ const cursor = new Date(nowMs);
+ cursor.setMinutes(0, 0, 0);
+ for (let ts = cursor.getTime(); ts >= domainStart; ts -= HOUR_MS) {
+ ticks.push(ts);
+ }
+ return ticks.reverse();
+}
+
/**
* Build evenly spaced ticks for linear axes.
*
@@ -948,21 +969,24 @@ function buildSeriesPoints(entries, fields, domainStart, domainEnd) {
* @param {number} domainEnd Window end timestamp.
* @returns {string} SVG markup for the series.
*/
-function renderTelemetrySeries(seriesConfig, points, axis, dims, domainStart, domainEnd) {
+function renderTelemetrySeries(seriesConfig, points, axis, dims, domainStart, domainEnd, { lineReducer } = {}) {
if (!Array.isArray(points) || points.length === 0) {
return '';
}
- const circles = [];
- const coordinates = points.map(point => {
+ const convertPoint = point => {
const cx = scaleTimestamp(point.timestamp, domainStart, domainEnd, dims);
const cy = scaleValueToAxis(point.value, axis, dims);
+ return { cx, cy, value: point.value };
+ };
+ const circleEntries = points.map(point => {
+ const coords = convertPoint(point);
const tooltip = formatSeriesPointValue(seriesConfig, point.value);
const titleMarkup = tooltip ? `${escapeHtml(tooltip)}` : '';
- circles.push(
- `${titleMarkup}`,
- );
- return { cx, cy };
+ return `${titleMarkup}`;
});
+ const lineSource = typeof lineReducer === 'function' ? lineReducer(points) : points;
+ const linePoints = Array.isArray(lineSource) && lineSource.length > 0 ? lineSource : points;
+ const coordinates = linePoints.map(convertPoint);
let line = '';
if (coordinates.length > 1) {
const path = coordinates
@@ -970,7 +994,7 @@ function renderTelemetrySeries(seriesConfig, points, axis, dims, domainStart, do
.join(' ');
line = ``;
}
- return `${line}${circles.join('')}`;
+ return `${line}${circleEntries.join('')}`;
}
/**
@@ -1024,7 +1048,7 @@ function renderYAxis(axis, dims) {
* @param {Array} tickTimestamps Midnight tick timestamps.
* @returns {string} SVG markup for the X axis.
*/
-function renderXAxis(dims, domainStart, domainEnd, tickTimestamps) {
+function renderXAxis(dims, domainStart, domainEnd, tickTimestamps, { labelFormatter = formatCompactDate } = {}) {
const y = dims.chartBottom;
const ticks = tickTimestamps
.map(ts => {
@@ -1032,10 +1056,11 @@ function renderXAxis(dims, domainStart, domainEnd, tickTimestamps) {
const labelY = y + 18;
const xStr = x.toFixed(2);
const yStr = labelY.toFixed(2);
+ const label = labelFormatter(ts);
return `
- ${escapeHtml(formatCompactDate(ts))}
+ ${escapeHtml(label)}
`;
})
@@ -1056,9 +1081,11 @@ function renderXAxis(dims, domainStart, domainEnd, tickTimestamps) {
* @param {number} nowMs Reference timestamp.
* @returns {string} Rendered chart markup or an empty string.
*/
-function renderTelemetryChart(spec, entries, nowMs) {
+function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
+ const windowMs = Number.isFinite(chartOptions.windowMs) && chartOptions.windowMs > 0 ? chartOptions.windowMs : TELEMETRY_WINDOW_MS;
+ const timeRangeLabel = stringOrNull(chartOptions.timeRangeLabel) ?? 'Last 7 days';
const domainEnd = nowMs;
- const domainStart = nowMs - TELEMETRY_WINDOW_MS;
+ const domainStart = nowMs - windowMs;
const dims = createChartDimensions(spec);
const axisMap = new Map(spec.axes.map(axis => [axis.id, axis]));
const seriesEntries = spec.series
@@ -1074,9 +1101,17 @@ function renderTelemetryChart(spec, entries, nowMs) {
return '';
}
const axesMarkup = spec.axes.map(axis => renderYAxis(axis, dims)).join('');
- const xAxisMarkup = renderXAxis(dims, domainStart, domainEnd, buildMidnightTicks(nowMs));
+ const tickBuilder = typeof chartOptions.xAxisTickBuilder === 'function' ? chartOptions.xAxisTickBuilder : buildMidnightTicks;
+ const tickFormatter = typeof chartOptions.xAxisTickFormatter === 'function' ? chartOptions.xAxisTickFormatter : formatCompactDate;
+ const ticks = tickBuilder(nowMs, windowMs);
+ const xAxisMarkup = renderXAxis(dims, domainStart, domainEnd, ticks, { labelFormatter: tickFormatter });
+
const seriesMarkup = seriesEntries
- .map(series => renderTelemetrySeries(series.config, series.points, series.axis, dims, domainStart, domainEnd))
+ .map(series =>
+ renderTelemetrySeries(series.config, series.points, series.axis, dims, domainStart, domainEnd, {
+ lineReducer: chartOptions.lineReducer,
+ }),
+ )
.join('');
const legendItems = seriesEntries
.map(series => {
@@ -1096,7 +1131,7 @@ function renderTelemetryChart(spec, entries, nowMs) {