charts: render aggregated telemetry charts for all nodes (#453)

This commit is contained in:
l5y
2025-11-15 17:09:55 +01:00
committed by GitHub
parent 4a03e17886
commit 9304a99745
7 changed files with 420 additions and 21 deletions
@@ -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)
@@ -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 '<section class="node-detail__charts">Charts</section>';
};
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 = () => '<section>unused</section>';
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);
});
+148
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderStatus(message, { error = false } = {}) {
const errorClass = error ? ' charts-page__status--error' : '';
return `<p class="charts-page__status${errorClass}">${escapeHtml(message)}</p>`;
}
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;
}
}
+54 -19
View File
@@ -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<number>} 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<number>} 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 ? `<title>${escapeHtml(tooltip)}</title>` : '';
circles.push(
`<circle class="node-detail__chart-point" cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" r="3.2" fill="${seriesConfig.color}" aria-hidden="true">${titleMarkup}</circle>`,
);
return { cx, cy };
return `<circle class="node-detail__chart-point" cx="${coords.cx.toFixed(2)}" cy="${coords.cy.toFixed(2)}" r="3.2" fill="${seriesConfig.color}" aria-hidden="true">${titleMarkup}</circle>`;
});
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 = `<path class="node-detail__chart-trend" d="${path}" fill="none" stroke="${hexToRgba(seriesConfig.color, 0.5)}" stroke-width="1.5" aria-hidden="true"></path>`;
}
return `${line}${circles.join('')}`;
return `${line}${circleEntries.join('')}`;
}
/**
@@ -1024,7 +1048,7 @@ function renderYAxis(axis, dims) {
* @param {Array<number>} 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 `
<g class="node-detail__chart-tick" aria-hidden="true">
<line class="node-detail__chart-grid-line" x1="${xStr}" y1="${dims.chartTop}" x2="${xStr}" y2="${dims.chartBottom}"></line>
<text x="${xStr}" y="${yStr}" text-anchor="end" dominant-baseline="central" transform="rotate(-90 ${xStr} ${yStr})">${escapeHtml(formatCompactDate(ts))}</text>
<text x="${xStr}" y="${yStr}" text-anchor="end" dominant-baseline="central" transform="rotate(-90 ${xStr} ${yStr})">${escapeHtml(label)}</text>
</g>
`;
})
@@ -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) {
<figure class="node-detail__chart">
<figcaption class="node-detail__chart-header">
<h4>${escapeHtml(spec.title)}</h4>
<span>Last 7 days</span>
<span>${escapeHtml(timeRangeLabel)}</span>
</figcaption>
<svg viewBox="0 0 ${dims.width} ${dims.height}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${escapeHtml(`${spec.title} over last seven days`)}">
${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 '';
+36 -1
View File
@@ -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;
+28
View File
@@ -0,0 +1,28 @@
<!--
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.
-->
<section class="charts-page">
<header class="charts-page__intro">
<h2>Network telemetry trends</h2>
<p>Aggregated telemetry snapshots from every node in the past week.</p>
</header>
<div id="chartsPage" class="charts-page__content">
<p class="charts-page__status">Loading aggregated telemetry charts…</p>
</div>
</section>
<script type="module">
import { initializeChartsPage } from '/assets/js/app/charts-page.js';
initializeChartsPage();
</script>
+1 -1
View File
@@ -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"]