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 '
Charts
'; + }; + 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 = () => '
unused
'; + 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( - ``, - ); - return { cx, cy }; + return ``; }); + 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 ` `; }) @@ -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) {

${escapeHtml(spec.title)}

- Last 7 days + ${escapeHtml(timeRangeLabel)}
${axesMarkup} @@ -1116,7 +1151,7 @@ function renderTelemetryChart(spec, entries, nowMs) { * @param {{ nowMs?: number }} [options] Rendering options. * @returns {string} Chart grid markup or an empty string. */ -function renderTelemetryCharts(node, { nowMs = Date.now() } = {}) { +export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {} } = {}) { const telemetrySource = node?.rawSources?.telemetry; const snapshotHistory = Array.isArray(node?.rawSources?.telemetrySnapshots) && node.rawSources.telemetrySnapshots.length > 0 ? node.rawSources.telemetrySnapshots @@ -1140,7 +1175,7 @@ function renderTelemetryCharts(node, { nowMs = Date.now() } = {}) { return ''; } const charts = TELEMETRY_CHART_SPECS - .map(spec => renderTelemetryChart(spec, entries, nowMs)) + .map(spec => renderTelemetryChart(spec, entries, nowMs, chartOptions)) .filter(chart => stringOrNull(chart)); if (charts.length === 0) { return ''; diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index e95b231..f6d0a08 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -754,7 +754,7 @@ body.view-map .map-panel--full #map { line-height: 1.4; min-width: 200px; max-width: 240px; - z-index: 12000; + z-index: 20000; } .short-info-overlay[hidden] { @@ -862,6 +862,41 @@ body.dark .node-detail-overlay__close:hover { background: rgba(255, 255, 255, 0.18); } +.charts-page { + padding: 24px var(--pad) 48px; + max-width: 1400px; + margin: 0 auto; + width: 100%; +} + +.charts-page__intro { + padding: 0 4px 12px; +} + +.charts-page__intro h2 { + margin: 0 0 6px; + font-size: 1.6rem; +} + +.charts-page__intro p { + margin: 0; + color: var(--muted); +} + +.charts-page__content { + min-height: 320px; +} + +.charts-page__status { + margin: 18px 0; + font-size: 1rem; + color: var(--muted); +} + +.charts-page__status--error { + color: #c62828; +} + .node-detail { width: 100%; margin: 0; diff --git a/web/views/charts.erb b/web/views/charts.erb new file mode 100644 index 0000000..f4a28be --- /dev/null +++ b/web/views/charts.erb @@ -0,0 +1,28 @@ + +
+
+

Network telemetry trends

+

Aggregated telemetry snapshots from every node in the past week.

+ +
+

Loading aggregated telemetry charts…

+
+ + diff --git a/web/views/layouts/app.erb b/web/views/layouts/app.erb index 74adaf6..064d128 100644 --- a/web/views/layouts/app.erb +++ b/web/views/layouts/app.erb @@ -80,7 +80,7 @@ show_auto_fit_toggle = %i[dashboard map].include?(view_mode) show_info_button = !full_screen_view show_footer = !full_screen_view - show_filter_input = !%i[node_detail].include?(view_mode) + show_filter_input = !%i[node_detail charts].include?(view_mode) controls_classes = ["controls"] controls_classes << "controls--full-screen" if full_screen_view refresh_row_classes = ["refresh-row"]