Compare commits

...

1 Commits

Author SHA1 Message Date
l5y
858e9fa189 web: render charts with uplot 2026-01-05 14:39:06 +01:00
15 changed files with 1078 additions and 299 deletions

10
web/package-lock.json generated
View File

@@ -7,6 +7,10 @@
"": {
"name": "potato-mesh",
"version": "0.5.9",
"hasInstallScript": true,
"dependencies": {
"uplot": "^1.6.30"
},
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -154,6 +158,12 @@
"node": ">=8"
}
},
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",

View File

@@ -4,8 +4,12 @@
"type": "module",
"private": true,
"scripts": {
"postinstall": "node ./scripts/copy-uplot.js",
"test": "mkdir -p reports coverage && NODE_V8_COVERAGE=coverage node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/javascript-junit.xml && node ./scripts/export-coverage.js"
},
"dependencies": {
"uplot": "^1.6.30"
},
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",

View File

@@ -80,13 +80,19 @@ test('initializeChartsPage renders the telemetry charts when snapshots are avail
},
]);
let receivedOptions = null;
const renderCharts = (node, options) => {
let mountedModels = null;
const createCharts = (node, options) => {
receivedOptions = options;
return '<section class="node-detail__charts">Charts</section>';
return { chartsHtml: '<section class="node-detail__charts">Charts</section>', chartModels: [{ id: 'power' }] };
};
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
const mountCharts = (chartModels, options) => {
mountedModels = { chartModels, options };
return [];
};
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts, mountCharts });
assert.equal(result, true);
assert.equal(container.innerHTML.includes('node-detail__charts'), true);
assert.equal(mountedModels.chartModels.length, 1);
assert.ok(receivedOptions);
assert.equal(receivedOptions.chartOptions.windowMs, 604_800_000);
assert.equal(typeof receivedOptions.chartOptions.lineReducer, 'function');
@@ -118,8 +124,8 @@ test('initializeChartsPage shows an error message when fetching fails', async ()
const fetchImpl = async () => {
throw new Error('network');
};
const renderCharts = () => '<section>unused</section>';
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
const createCharts = () => ({ chartsHtml: '<section>unused</section>', chartModels: [] });
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts });
assert.equal(result, false);
assert.equal(container.innerHTML.includes('Failed to load telemetry charts.'), true);
});
@@ -136,8 +142,8 @@ test('initializeChartsPage handles missing containers and empty telemetry snapsh
},
};
const fetchImpl = async () => createResponse(200, []);
const renderCharts = () => '';
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
const createCharts = () => ({ chartsHtml: '', chartModels: [] });
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts });
assert.equal(result, true);
assert.equal(container.innerHTML.includes('Telemetry snapshots are unavailable.'), true);
});
@@ -155,8 +161,8 @@ test('initializeChartsPage shows a status when rendering produces no markup', as
aggregates: { voltage: { avg: 3.9 } },
},
]);
const renderCharts = () => '';
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
const createCharts = () => ({ chartsHtml: '', chartModels: [] });
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts });
assert.equal(result, true);
assert.equal(container.innerHTML.includes('Telemetry snapshots are unavailable.'), true);
});

View File

@@ -111,6 +111,26 @@ test('createNodeDetailOverlayManager renders fetched markup and restores focus',
assert.equal(focusTarget.focusCalled, true);
});
test('createNodeDetailOverlayManager mounts telemetry charts for overlay content', async () => {
const { document, content } = createOverlayHarness();
const chartModels = [{ id: 'power' }];
let mountCall = null;
const manager = createNodeDetailOverlayManager({
document,
fetchNodeDetail: async () => ({ html: '<section class="node-detail">Charts</section>', chartModels }),
mountCharts: (models, options) => {
mountCall = { models, options };
return [];
},
});
assert.ok(manager);
await manager.open({ nodeId: '!alpha' });
assert.equal(content.innerHTML.includes('Charts'), true);
assert.ok(mountCall);
assert.equal(mountCall.models, chartModels);
assert.equal(mountCall.options.root, content);
});
test('createNodeDetailOverlayManager surfaces errors and supports escape closing', async () => {
const { document, overlay, content } = createOverlayHarness();
const errors = [];

View File

@@ -47,7 +47,9 @@ const {
categoriseNeighbors,
renderNeighborGroups,
renderSingleNodeTable,
createTelemetryCharts,
renderTelemetryCharts,
buildUPlotChartConfig,
renderMessages,
renderTraceroutes,
renderTracePath,
@@ -386,23 +388,10 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis
},
};
const html = renderTelemetryCharts(node, { nowMs });
const fmt = new Date(nowMs);
const expectedDate = String(fmt.getDate()).padStart(2, '0');
assert.equal(html.includes('node-detail__charts'), true);
assert.equal(html.includes('Power metrics'), true);
assert.equal(html.includes('Environmental telemetry'), true);
assert.equal(html.includes('Battery (%)'), true);
assert.equal(html.includes('Voltage (V)'), true);
assert.equal(html.includes('Current (A)'), true);
assert.equal(html.includes('Channel utilization (%)'), true);
assert.equal(html.includes('Air util TX (%)'), true);
assert.equal(html.includes('Utilization (%)'), true);
assert.equal(html.includes('Gas resistance (\u03a9)'), true);
assert.equal(html.includes('Air quality'), true);
assert.equal(html.includes('IAQ index'), true);
assert.equal(html.includes('Temperature (\u00b0C)'), true);
assert.equal(html.includes(expectedDate), true);
assert.equal(html.includes('node-detail__chart-point'), true);
assert.equal(html.includes('node-detail__chart-plot'), true);
});
test('renderTelemetryCharts expands upper bounds when overflow metrics exceed defaults', () => {
@@ -433,12 +422,18 @@ test('renderTelemetryCharts expands upper bounds when overflow metrics exceed de
},
},
};
const html = renderTelemetryCharts(node, { nowMs });
assert.match(html, />7\.2<\/text>/);
assert.match(html, />3\.6<\/text>/);
assert.match(html, />45<\/text>/);
assert.match(html, />650<\/text>/);
assert.match(html, />1100<\/text>/);
const { chartModels } = createTelemetryCharts(node, { nowMs });
const powerChart = chartModels.find(model => model.id === 'power');
const environmentChart = chartModels.find(model => model.id === 'environment');
const airChart = chartModels.find(model => model.id === 'airQuality');
const powerConfig = buildUPlotChartConfig(powerChart);
const envConfig = buildUPlotChartConfig(environmentChart);
const airConfig = buildUPlotChartConfig(airChart);
assert.equal(powerConfig.options.scales.voltage.range()[1], 7.2);
assert.equal(powerConfig.options.scales.current.range()[1], 3.6);
assert.equal(envConfig.options.scales.temperature.range()[1], 45);
assert.equal(airConfig.options.scales.iaq.range()[1], 650);
assert.equal(airConfig.options.scales.pressure.range()[1], 1100);
});
test('renderTelemetryCharts keeps default bounds when metrics stay within limits', () => {
@@ -469,11 +464,17 @@ test('renderTelemetryCharts keeps default bounds when metrics stay within limits
},
},
};
const html = renderTelemetryCharts(node, { nowMs });
assert.match(html, />6\.0<\/text>/);
assert.match(html, />3\.0<\/text>/);
assert.match(html, />40<\/text>/);
assert.match(html, />500<\/text>/);
const { chartModels } = createTelemetryCharts(node, { nowMs });
const powerChart = chartModels.find(model => model.id === 'power');
const environmentChart = chartModels.find(model => model.id === 'environment');
const airChart = chartModels.find(model => model.id === 'airQuality');
const powerConfig = buildUPlotChartConfig(powerChart);
const envConfig = buildUPlotChartConfig(environmentChart);
const airConfig = buildUPlotChartConfig(airChart);
assert.equal(powerConfig.options.scales.voltage.range()[1], 6);
assert.equal(powerConfig.options.scales.current.range()[1], 3);
assert.equal(envConfig.options.scales.temperature.range()[1], 40);
assert.equal(airConfig.options.scales.iaq.range()[1], 500);
});
test('renderNodeDetailHtml composes the table, neighbors, and messages', () => {
@@ -589,17 +590,18 @@ test('fetchNodeDetailHtml renders the node layout for overlays', async () => {
neighbors: [],
rawSources: { node: { node_id: '!alpha', role: 'CLIENT', short_name: 'ALPH' } },
});
const html = await fetchNodeDetailHtml(reference, {
const result = await fetchNodeDetailHtml(reference, {
refreshImpl,
fetchImpl,
renderShortHtml: short => `<span class="short-name">${short}</span>`,
returnState: true,
});
assert.equal(calledUrls.some(url => url.includes('/api/messages/!alpha')), true);
assert.equal(calledUrls.some(url => url.includes('/api/traces/!alpha')), true);
assert.equal(html.includes('Example Alpha'), true);
assert.equal(html.includes('Overlay hello'), true);
assert.equal(html.includes('Traceroutes'), true);
assert.equal(html.includes('node-detail__table'), true);
assert.equal(result.html.includes('Example Alpha'), true);
assert.equal(result.html.includes('Overlay hello'), true);
assert.equal(result.html.includes('Traceroutes'), true);
assert.equal(result.html.includes('node-detail__table'), true);
});
test('fetchNodeDetailHtml hydrates traceroute nodes with API metadata', async () => {
@@ -637,16 +639,17 @@ test('fetchNodeDetailHtml hydrates traceroute nodes with API metadata', async ()
rawSources: { node: { node_id: '!origin', role: 'CLIENT', short_name: 'ORIG' } },
});
const html = await fetchNodeDetailHtml(reference, {
const result = await fetchNodeDetailHtml(reference, {
refreshImpl,
fetchImpl,
renderShortHtml: short => `<span class="short-name">${short}</span>`,
returnState: true,
});
assert.equal(calledUrls.some(url => url.includes('/api/nodes/!relay')), true);
assert.equal(calledUrls.some(url => url.includes('/api/nodes/!target')), true);
assert.equal(html.includes('RLY1'), true);
assert.equal(html.includes('TGT1'), true);
assert.equal(result.html.includes('RLY1'), true);
assert.equal(result.html.includes('TGT1'), true);
});
test('fetchNodeDetailHtml requires a node identifier reference', async () => {

View File

@@ -0,0 +1,360 @@
/*
* 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 { __testUtils } from '../node-page.js';
import { buildMovingAverageSeries } from '../charts-page.js';
const {
createTelemetryCharts,
buildUPlotChartConfig,
mountTelemetryCharts,
mountTelemetryChartsWithRetry,
} = __testUtils;
test('uPlot chart config preserves axes, colors, and tick labels for node telemetry', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
device_metrics: {
battery_level: 80,
voltage: 4.1,
current: 0.75,
},
},
{
rx_time: nowSeconds - 3_600,
device_metrics: {
battery_level: 78,
voltage: 4.05,
current: 0.65,
},
},
],
},
},
};
const { chartModels } = createTelemetryCharts(node, {
nowMs,
chartOptions: {
xAxisTickBuilder: () => [nowMs],
xAxisTickFormatter: () => '08',
},
});
const powerChart = chartModels.find(model => model.id === 'power');
const { options, data } = buildUPlotChartConfig(powerChart);
assert.deepEqual(options.scales.battery.range(), [0, 100]);
assert.deepEqual(options.scales.voltage.range(), [0, 6]);
assert.deepEqual(options.scales.current.range(), [0, 3]);
assert.equal(options.series[1].stroke, '#8856a7');
assert.equal(options.series[2].stroke, '#9ebcda');
assert.equal(options.series[3].stroke, '#3182bd');
assert.deepEqual(options.axes[0].values(null, [nowMs]), ['08']);
assert.equal(options.axes[0].stroke, '#5c6773');
assert.deepEqual(data[0].slice(0, 2), [nowMs - 3_600_000, nowMs - 60_000]);
assert.deepEqual(data[1].slice(0, 2), [78, 80]);
});
test('uPlot chart config maps moving averages and raw points for aggregated telemetry', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const snapshots = [
{
rx_time: nowSeconds - 3_600,
device_metrics: { battery_level: 10 },
},
{
rx_time: nowSeconds - 1_800,
device_metrics: { battery_level: 20 },
},
];
const node = { rawSources: { telemetry: { snapshots } } };
const { chartModels } = createTelemetryCharts(node, {
nowMs,
chartOptions: {
lineReducer: points => buildMovingAverageSeries(points, 3_600_000),
},
});
const powerChart = chartModels.find(model => model.id === 'power');
const { options, data } = buildUPlotChartConfig(powerChart);
assert.equal(options.series.length, 3);
assert.equal(options.series[1].stroke.startsWith('rgba('), true);
assert.equal(options.series[2].stroke, '#8856a7');
assert.deepEqual(data[1].slice(0, 2), [10, 15]);
assert.deepEqual(data[2].slice(0, 2), [10, 20]);
});
test('buildUPlotChartConfig applies axis color overrides', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
device_metrics: { battery_level: 80 },
},
],
},
},
};
const { chartModels } = createTelemetryCharts(node, { nowMs });
const powerChart = chartModels.find(model => model.id === 'power');
const { options } = buildUPlotChartConfig(powerChart, {
axisColor: '#ffffff',
gridColor: '#222222',
});
assert.equal(options.axes[0].stroke, '#ffffff');
assert.equal(options.axes[0].grid.stroke, '#222222');
});
test('environment chart renders humidity axis on the right side', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
environment_metrics: {
temperature: 19.5,
relative_humidity: 55,
},
},
],
},
},
};
const { chartModels } = createTelemetryCharts(node, { nowMs });
const envChart = chartModels.find(model => model.id === 'environment');
const { options } = buildUPlotChartConfig(envChart);
const humidityAxis = options.axes.find(axis => axis.scale === 'humidity');
assert.ok(humidityAxis);
assert.equal(humidityAxis.side, 1);
assert.equal(humidityAxis.show, true);
});
test('channel utilization chart includes a right-side utilization axis', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
device_metrics: {
channel_utilization: 40,
air_util_tx: 22,
},
},
],
},
},
};
const { chartModels } = createTelemetryCharts(node, { nowMs });
const channelChart = chartModels.find(model => model.id === 'channel');
const { options } = buildUPlotChartConfig(channelChart);
const rightAxis = options.axes.find(axis => axis.scale === 'channelSecondary');
assert.ok(rightAxis);
assert.equal(rightAxis.side, 1);
assert.equal(rightAxis.show, true);
});
test('createTelemetryCharts returns empty markup when snapshots are missing', () => {
const { chartsHtml, chartModels } = createTelemetryCharts({ rawSources: { telemetry: { snapshots: [] } } });
assert.equal(chartsHtml, '');
assert.equal(chartModels.length, 0);
});
test('mountTelemetryCharts instantiates uPlot for chart containers', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
device_metrics: { battery_level: 80 },
},
],
},
},
};
const { chartModels } = createTelemetryCharts(node, { nowMs });
const [model] = chartModels;
const plotRoot = { innerHTML: 'placeholder' };
const chartContainer = {
querySelector(selector) {
return selector === '[data-telemetry-plot]' ? plotRoot : null;
},
};
const root = {
querySelector(selector) {
return selector === `[data-telemetry-chart-id="${model.id}"]` ? chartContainer : null;
},
};
class UPlotStub {
constructor(options, data, container) {
this.options = options;
this.data = data;
this.container = container;
}
}
const instances = mountTelemetryCharts(chartModels, { root, uPlotImpl: UPlotStub });
assert.equal(plotRoot.innerHTML, '');
assert.equal(instances.length, 1);
assert.equal(instances[0].container, plotRoot);
});
test('mountTelemetryCharts responds to window resize events', async () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
device_metrics: { battery_level: 80 },
},
],
},
},
};
const { chartModels } = createTelemetryCharts(node, { nowMs });
const [model] = chartModels;
const plotRoot = {
innerHTML: '',
clientWidth: 320,
clientHeight: 180,
getBoundingClientRect() {
return { width: this.clientWidth, height: this.clientHeight };
},
};
const chartContainer = {
querySelector(selector) {
return selector === '[data-telemetry-plot]' ? plotRoot : null;
},
};
const root = {
querySelector(selector) {
return selector === `[data-telemetry-chart-id="${model.id}"]` ? chartContainer : null;
},
};
const previousResizeObserver = globalThis.ResizeObserver;
const previousAddEventListener = globalThis.addEventListener;
let resizeHandler = null;
globalThis.ResizeObserver = undefined;
globalThis.addEventListener = (event, handler) => {
if (event === 'resize') {
resizeHandler = handler;
}
};
const sizeCalls = [];
class UPlotStub {
constructor(options, data, container) {
this.options = options;
this.data = data;
this.container = container;
this.root = container;
}
setSize(size) {
sizeCalls.push(size);
}
}
mountTelemetryCharts(chartModels, { root, uPlotImpl: UPlotStub });
assert.ok(resizeHandler);
plotRoot.clientWidth = 480;
plotRoot.clientHeight = 240;
resizeHandler();
await new Promise(resolve => setTimeout(resolve, 150));
assert.equal(sizeCalls.length >= 1, true);
assert.deepEqual(sizeCalls[sizeCalls.length - 1], { width: 480, height: 240 });
globalThis.ResizeObserver = previousResizeObserver;
globalThis.addEventListener = previousAddEventListener;
});
test('mountTelemetryChartsWithRetry loads uPlot when missing', async () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
device_metrics: { battery_level: 80 },
},
],
},
},
};
const { chartModels } = createTelemetryCharts(node, { nowMs });
const [model] = chartModels;
const plotRoot = { innerHTML: '', clientWidth: 400, clientHeight: 200 };
const chartContainer = {
querySelector(selector) {
return selector === '[data-telemetry-plot]' ? plotRoot : null;
},
};
const root = {
ownerDocument: {
body: {},
querySelector: () => null,
},
querySelector(selector) {
return selector === `[data-telemetry-chart-id="${model.id}"]` ? chartContainer : null;
},
};
const previousUPlot = globalThis.uPlot;
const instances = [];
class UPlotStub {
constructor(options, data, container) {
this.options = options;
this.data = data;
this.container = container;
instances.push(this);
}
}
let loadCalled = false;
const loadUPlot = ({ onLoad }) => {
loadCalled = true;
globalThis.uPlot = UPlotStub;
if (typeof onLoad === 'function') {
onLoad();
}
return true;
};
mountTelemetryChartsWithRetry(chartModels, { root, loadUPlot });
await new Promise(resolve => setTimeout(resolve, 0));
assert.equal(loadCalled, true);
assert.equal(instances.length, 1);
globalThis.uPlot = previousUPlot;
});

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { renderTelemetryCharts } from './node-page.js';
import { createTelemetryCharts, mountTelemetryChartsWithRetry } from './node-page.js';
const TELEMETRY_BUCKET_SECONDS = 60 * 60;
const HOUR_MS = 60 * 60 * 1000;
@@ -193,6 +193,21 @@ export async function fetchAggregatedTelemetry({
.filter(snapshot => snapshot != null);
}
/**
* Fetch and render aggregated telemetry charts.
*
* @param {{
* document?: Document,
* rootId?: string,
* fetchImpl?: Function,
* bucketSeconds?: number,
* windowMs?: number,
* createCharts?: Function,
* mountCharts?: Function,
* uPlotImpl?: Function,
* }} options Optional overrides for testing.
* @returns {Promise<boolean>} ``true`` when charts were rendered successfully.
*/
export async function initializeChartsPage(options = {}) {
const documentRef = options.document ?? globalThis.document;
if (!documentRef || typeof documentRef.getElementById !== 'function') {
@@ -204,7 +219,8 @@ export async function initializeChartsPage(options = {}) {
return false;
}
const renderCharts = typeof options.renderCharts === 'function' ? options.renderCharts : renderTelemetryCharts;
const createCharts = typeof options.createCharts === 'function' ? options.createCharts : createTelemetryCharts;
const mountCharts = typeof options.mountCharts === 'function' ? options.mountCharts : mountTelemetryChartsWithRetry;
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
const bucketSeconds = options.bucketSeconds ?? TELEMETRY_BUCKET_SECONDS;
const windowMs = options.windowMs ?? CHART_WINDOW_MS;
@@ -218,7 +234,7 @@ export async function initializeChartsPage(options = {}) {
return true;
}
const node = { rawSources: { telemetry: { snapshots } } };
const chartsHtml = renderCharts(node, {
const chartState = createCharts(node, {
nowMs: Date.now(),
chartOptions: {
windowMs,
@@ -228,11 +244,12 @@ export async function initializeChartsPage(options = {}) {
lineReducer: points => buildMovingAverageSeries(points, HOUR_MS),
},
});
if (!chartsHtml) {
if (!chartState.chartsHtml) {
container.innerHTML = renderStatus('Telemetry snapshots are unavailable.');
return true;
}
container.innerHTML = chartsHtml;
container.innerHTML = chartState.chartsHtml;
mountCharts(chartState.chartModels, { root: container, uPlotImpl: options.uPlotImpl });
return true;
} catch (error) {
console.error('Failed to render aggregated telemetry charts', error);

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { fetchNodeDetailHtml } from './node-page.js';
import { fetchNodeDetailHtml, mountTelemetryChartsWithRetry } from './node-page.js';
/**
* Escape a string for safe HTML injection.
@@ -68,6 +68,9 @@ function hasValidReference(reference) {
* fetchImpl?: Function,
* refreshImpl?: Function,
* renderShortHtml?: Function,
* mountCharts?: Function,
* uPlotImpl?: Function,
* loadUPlot?: Function,
* privateMode?: boolean,
* logger?: Console
* }} [options] Behaviour overrides.
@@ -101,6 +104,9 @@ export function createNodeDetailOverlayManager(options = {}) {
const fetchImpl = options.fetchImpl;
const refreshImpl = options.refreshImpl;
const renderShortHtml = options.renderShortHtml;
const mountCharts = typeof options.mountCharts === 'function' ? options.mountCharts : mountTelemetryChartsWithRetry;
const uPlotImpl = options.uPlotImpl;
const loadUPlot = options.loadUPlot;
let requestToken = 0;
let lastTrigger = null;
@@ -198,16 +204,21 @@ export function createNodeDetailOverlayManager(options = {}) {
}
const currentToken = ++requestToken;
try {
const html = await fetchDetail(reference, {
const result = await fetchDetail(reference, {
fetchImpl,
refreshImpl,
renderShortHtml,
privateMode,
returnState: true,
});
if (currentToken !== requestToken) {
return;
}
content.innerHTML = html;
const resolvedHtml = typeof result === 'string' ? result : result?.html;
content.innerHTML = resolvedHtml ?? '';
if (result && typeof result === 'object' && Array.isArray(result.chartModels)) {
mountCharts(result.chartModels, { root: content, uPlotImpl, loadUPlot });
}
if (typeof closeButton.focus === 'function') {
closeButton.focus();
}

View File

@@ -124,6 +124,15 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
ticks: 4,
color: '#2ca25f',
},
{
id: 'channelSecondary',
position: 'right',
label: 'Utilization (%)',
min: 0,
max: 100,
ticks: 4,
color: '#2ca25f',
},
],
series: [
{
@@ -137,7 +146,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
},
{
id: 'air',
axis: 'channel',
axis: 'channelSecondary',
color: '#99d8c9',
label: 'Air util tx',
legend: 'Air util TX (%)',
@@ -162,13 +171,13 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
},
{
id: 'humidity',
position: 'left',
position: 'right',
label: 'Humidity (%)',
min: 0,
max: 100,
ticks: 4,
color: '#91bfdb',
visible: false,
visible: true,
},
],
series: [
@@ -857,67 +866,6 @@ function createChartDimensions(spec) {
};
}
/**
* Compute the horizontal drawing position for an axis descriptor.
*
* @param {string} position Axis position keyword.
* @param {Object} dims Chart dimensions.
* @returns {number} X coordinate for the axis baseline.
*/
function resolveAxisX(position, dims) {
switch (position) {
case 'leftSecondary':
return dims.margin.left - 32;
case 'right':
return dims.width - dims.margin.right;
case 'rightSecondary':
return dims.width - dims.margin.right + 32;
case 'left':
default:
return dims.margin.left;
}
}
/**
* Compute the X coordinate for a timestamp constrained to the rolling window.
*
* @param {number} timestamp Timestamp in milliseconds.
* @param {number} domainStart Start of the window in milliseconds.
* @param {number} domainEnd End of the window in milliseconds.
* @param {Object} dims Chart dimensions.
* @returns {number} X coordinate inside the SVG viewport.
*/
function scaleTimestamp(timestamp, domainStart, domainEnd, dims) {
const safeStart = Math.min(domainStart, domainEnd);
const safeEnd = Math.max(domainStart, domainEnd);
const span = Math.max(1, safeEnd - safeStart);
const clamped = clamp(timestamp, safeStart, safeEnd);
const ratio = (clamped - safeStart) / span;
return dims.margin.left + ratio * dims.innerWidth;
}
/**
* Convert a value bound to a specific axis into a Y coordinate.
*
* @param {number} value Series value.
* @param {Object} axis Axis descriptor.
* @param {Object} dims Chart dimensions.
* @returns {number} Y coordinate.
*/
function scaleValueToAxis(value, axis, dims) {
if (!axis) return dims.chartBottom;
if (axis.scale === 'log') {
const minLog = Math.log10(axis.min);
const maxLog = Math.log10(axis.max);
const safe = clamp(value, axis.min, axis.max);
const ratio = (Math.log10(safe) - minLog) / (maxLog - minLog);
return dims.chartBottom - ratio * dims.innerHeight;
}
const safe = clamp(value, axis.min, axis.max);
const ratio = (safe - axis.min) / (axis.max - axis.min || 1);
return dims.chartBottom - ratio * dims.innerHeight;
}
/**
* Collect candidate containers that may hold telemetry values for a snapshot.
*
@@ -1034,129 +982,15 @@ function resolveAxisMax(axis, seriesEntries) {
}
/**
* Render a telemetry series as circles plus an optional translucent guide line.
*
* @param {Object} seriesConfig Series metadata.
* @param {Array<{timestamp: number, value: number}>} points Series points.
* @param {Object} axis Axis descriptor.
* @param {Object} dims Chart dimensions.
* @param {number} domainStart Window start timestamp.
* @param {number} domainEnd Window end timestamp.
* @returns {string} SVG markup for the series.
*/
function renderTelemetrySeries(seriesConfig, points, axis, dims, domainStart, domainEnd, { lineReducer } = {}) {
if (!Array.isArray(points) || points.length === 0) {
return '';
}
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>` : '';
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
.map((coord, idx) => `${idx === 0 ? 'M' : 'L'}${coord.cx.toFixed(2)} ${coord.cy.toFixed(2)}`)
.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}${circleEntries.join('')}`;
}
/**
* Render a vertical axis when visible.
*
* @param {Object} axis Axis descriptor.
* @param {Object} dims Chart dimensions.
* @returns {string} SVG markup for the axis or an empty string.
*/
function renderYAxis(axis, dims) {
if (!axis || axis.visible === false) {
return '';
}
const x = resolveAxisX(axis.position, dims);
const ticks = axis.scale === 'log'
? buildLogTicks(axis.min, axis.max)
: buildLinearTicks(axis.min, axis.max, axis.ticks);
const tickElements = ticks
.map(value => {
const y = scaleValueToAxis(value, axis, dims);
const tickLength = axis.position === 'left' || axis.position === 'leftSecondary' ? -4 : 4;
const textAnchor = axis.position === 'left' || axis.position === 'leftSecondary' ? 'end' : 'start';
const textOffset = axis.position === 'left' || axis.position === 'leftSecondary' ? -6 : 6;
return `
<g class="node-detail__chart-tick" aria-hidden="true">
<line x1="${x}" y1="${y.toFixed(2)}" x2="${(x + tickLength).toFixed(2)}" y2="${y.toFixed(2)}"></line>
<text x="${(x + textOffset).toFixed(2)}" y="${(y + 3).toFixed(2)}" text-anchor="${textAnchor}" dominant-baseline="middle">${escapeHtml(formatAxisTick(value, axis))}</text>
</g>
`;
})
.join('');
const labelPadding = axis.position === 'left' || axis.position === 'leftSecondary' ? -56 : 56;
const labelX = x + labelPadding;
const labelY = (dims.chartTop + dims.chartBottom) / 2;
const labelTransform = `rotate(-90 ${labelX.toFixed(2)} ${labelY.toFixed(2)})`;
return `
<g class="node-detail__chart-axis node-detail__chart-axis--y" aria-hidden="true">
<line x1="${x}" y1="${dims.chartTop}" x2="${x}" y2="${dims.chartBottom}"></line>
${tickElements}
<text class="node-detail__chart-axis-label" x="${labelX.toFixed(2)}" y="${labelY.toFixed(2)}" text-anchor="middle" dominant-baseline="middle" transform="${labelTransform}">${escapeHtml(axis.label)}</text>
</g>
`;
}
/**
* Render the horizontal floating seven-day axis with midnight ticks.
*
* @param {Object} dims Chart dimensions.
* @param {number} domainStart Window start timestamp.
* @param {number} domainEnd Window end timestamp.
* @param {Array<number>} tickTimestamps Midnight tick timestamps.
* @returns {string} SVG markup for the X axis.
*/
function renderXAxis(dims, domainStart, domainEnd, tickTimestamps, { labelFormatter = formatCompactDate } = {}) {
const y = dims.chartBottom;
const ticks = tickTimestamps
.map(ts => {
const x = scaleTimestamp(ts, domainStart, domainEnd, dims);
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(label)}</text>
</g>
`;
})
.join('');
return `
<g class="node-detail__chart-axis node-detail__chart-axis--x" aria-hidden="true">
<line x1="${dims.margin.left}" y1="${y}" x2="${dims.width - dims.margin.right}" y2="${y}"></line>
${ticks}
</g>
`;
}
/**
* Render a single telemetry chart defined by ``spec``.
* Build a telemetry chart model from a specification and series entries.
*
* @param {Object} spec Chart specification.
* @param {Array<{timestamp: number, snapshot: Object}>} entries Telemetry entries.
* @param {number} nowMs Reference timestamp.
* @returns {string} Rendered chart markup or an empty string.
* @param {Object} chartOptions Rendering overrides.
* @returns {Object|null} Chart model or ``null`` when empty.
*/
function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
function buildTelemetryChartModel(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;
@@ -1170,7 +1004,7 @@ function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
})
.filter(entry => entry != null);
if (seriesEntries.length === 0) {
return '';
return null;
}
const adjustedAxes = spec.axes.map(axis => {
const resolvedMax = resolveAxisMax(axis, seriesEntries);
@@ -1188,22 +1022,33 @@ function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
})
.filter(entry => entry != null);
if (plottedSeries.length === 0) {
return '';
return null;
}
const axesMarkup = adjustedAxes.map(axis => renderYAxis(axis, dims)).join('');
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 });
return {
id: spec.id,
title: spec.title,
timeRangeLabel,
domainStart,
domainEnd,
dims,
axes: adjustedAxes,
seriesEntries: plottedSeries,
ticks: tickBuilder(nowMs, windowMs),
tickFormatter,
lineReducer: typeof chartOptions.lineReducer === 'function' ? chartOptions.lineReducer : null,
};
}
const seriesMarkup = plottedSeries
.map(series =>
renderTelemetrySeries(series.config, series.points, series.axis, dims, domainStart, domainEnd, {
lineReducer: chartOptions.lineReducer,
}),
)
.join('');
const legendItems = plottedSeries
/**
* Render a telemetry chart container for a chart model.
*
* @param {Object} model Chart model.
* @returns {string} Chart markup.
*/
function renderTelemetryChartMarkup(model) {
const legendItems = model.seriesEntries
.map(series => {
const legendLabel = stringOrNull(series.config.legend) ?? series.config.label;
return `
@@ -1217,22 +1062,428 @@ function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
const legendMarkup = legendItems
? `<div class="node-detail__chart-legend" aria-hidden="true">${legendItems}</div>`
: '';
const ariaLabel = `${model.title} over last seven days`;
return `
<figure class="node-detail__chart">
<figure class="node-detail__chart" data-telemetry-chart-id="${escapeHtml(model.id)}">
<figcaption class="node-detail__chart-header">
<h4>${escapeHtml(spec.title)}</h4>
<span>${escapeHtml(timeRangeLabel)}</span>
<h4>${escapeHtml(model.title)}</h4>
<span>${escapeHtml(model.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}
${xAxisMarkup}
${seriesMarkup}
</svg>
<div class="node-detail__chart-plot" data-telemetry-plot role="img" aria-label="${escapeHtml(ariaLabel)}"></div>
${legendMarkup}
</figure>
`;
}
/**
* Build a sorted timestamp index shared across series entries.
*
* @param {Array<Object>} seriesEntries Plotted series entries.
* @param {Function|null} lineReducer Optional line reducer.
* @returns {{timestamps: Array<number>, indexByTimestamp: Map<number, number>}} Timestamp index.
*/
function buildChartTimestampIndex(seriesEntries, lineReducer) {
const timestampSet = new Set();
for (const entry of seriesEntries) {
if (!entry || !Array.isArray(entry.points)) continue;
entry.points.forEach(point => {
if (point && Number.isFinite(point.timestamp)) {
timestampSet.add(point.timestamp);
}
});
if (typeof lineReducer === 'function') {
const reduced = lineReducer(entry.points);
if (Array.isArray(reduced)) {
reduced.forEach(point => {
if (point && Number.isFinite(point.timestamp)) {
timestampSet.add(point.timestamp);
}
});
}
}
}
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
const indexByTimestamp = new Map(timestamps.map((ts, idx) => [ts, idx]));
return { timestamps, indexByTimestamp };
}
/**
* Convert a list of points into an aligned values array.
*
* @param {Array<{timestamp: number, value: number}>} points Series points.
* @param {Map<number, number>} indexByTimestamp Timestamp index.
* @param {number} length Length of the output array.
* @returns {Array<number|null>} Values aligned to timestamps.
*/
function mapSeriesValues(points, indexByTimestamp, length) {
const values = Array.from({ length }, () => null);
if (!Array.isArray(points)) {
return values;
}
for (const point of points) {
if (!point || !Number.isFinite(point.timestamp)) continue;
const idx = indexByTimestamp.get(point.timestamp);
if (idx == null) continue;
values[idx] = Number.isFinite(point.value) ? point.value : null;
}
return values;
}
/**
* Build uPlot series and data arrays for a chart model.
*
* @param {Object} model Chart model.
* @returns {{data: Array<Array<number|null>>, series: Array<Object>}} uPlot data and series config.
*/
function buildTelemetryChartData(model) {
const { timestamps, indexByTimestamp } = buildChartTimestampIndex(model.seriesEntries, model.lineReducer);
const data = [timestamps];
const series = [{ label: 'Time' }];
model.seriesEntries.forEach(entry => {
const baseConfig = {
label: entry.config.label,
scale: entry.axis.id,
};
if (model.lineReducer) {
const reducedPoints = model.lineReducer(entry.points);
const linePoints = Array.isArray(reducedPoints) && reducedPoints.length > 0 ? reducedPoints : entry.points;
const lineValues = mapSeriesValues(linePoints, indexByTimestamp, timestamps.length);
series.push({
...baseConfig,
stroke: hexToRgba(entry.config.color, 0.5),
width: 1.5,
points: { show: false },
});
data.push(lineValues);
const pointValues = mapSeriesValues(entry.points, indexByTimestamp, timestamps.length);
series.push({
...baseConfig,
stroke: entry.config.color,
width: 0,
points: { show: true, size: 6, width: 1 },
});
data.push(pointValues);
} else {
const values = mapSeriesValues(entry.points, indexByTimestamp, timestamps.length);
series.push({
...baseConfig,
stroke: entry.config.color,
width: 1.5,
points: { show: true, size: 6, width: 1 },
});
data.push(values);
}
});
return { data, series };
}
/**
* Build uPlot chart configuration and data for a telemetry chart.
*
* @param {Object} model Chart model.
* @returns {{options: Object, data: Array<Array<number|null>>}} uPlot config and data.
*/
function buildUPlotChartConfig(model, { width, height, axisColor, gridColor } = {}) {
const { data, series } = buildTelemetryChartData(model);
const fallbackWidth = Math.round(model.dims.width * 1.8);
const resolvedWidth = Number.isFinite(width) && width > 0 ? width : fallbackWidth;
const resolvedHeight = Number.isFinite(height) && height > 0 ? height : model.dims.height;
const axisStroke = stringOrNull(axisColor) ?? '#5c6773';
const gridStroke = stringOrNull(gridColor) ?? 'rgba(12, 15, 18, 0.08)';
const axes = [
{
scale: 'x',
side: 2,
stroke: axisStroke,
grid: { show: true, stroke: gridStroke },
splits: () => model.ticks,
values: (u, splits) => splits.map(value => model.tickFormatter(value)),
},
];
const scales = {
x: {
time: true,
range: () => [model.domainStart, model.domainEnd],
},
};
model.axes.forEach(axis => {
const ticks = axis.scale === 'log'
? buildLogTicks(axis.min, axis.max)
: buildLinearTicks(axis.min, axis.max, axis.ticks);
const side = axis.position === 'right' || axis.position === 'rightSecondary' ? 1 : 3;
axes.push({
scale: axis.id,
side,
show: axis.visible !== false,
stroke: axisStroke,
grid: { show: false },
label: axis.label,
splits: () => ticks,
values: (u, splits) => splits.map(value => formatAxisTick(value, axis)),
});
scales[axis.id] = {
distr: axis.scale === 'log' ? 3 : 1,
log: axis.scale === 'log' ? 10 : undefined,
range: () => [axis.min, axis.max],
};
});
return {
options: {
width: resolvedWidth,
height: resolvedHeight,
padding: [
model.dims.margin.top,
model.dims.margin.right,
model.dims.margin.bottom,
model.dims.margin.left,
],
legend: { show: false },
series,
axes,
scales,
},
data,
};
}
/**
* Instantiate uPlot charts for the provided chart models.
*
* @param {Array<Object>} chartModels Chart models to render.
* @param {{root?: ParentNode, uPlotImpl?: Function}} [options] Rendering options.
* @returns {Array<Object>} Instantiated uPlot charts.
*/
export function mountTelemetryCharts(chartModels, { root, uPlotImpl } = {}) {
if (!Array.isArray(chartModels) || chartModels.length === 0) {
return [];
}
const host = root ?? globalThis.document;
if (!host || typeof host.querySelector !== 'function') {
return [];
}
const uPlotCtor = typeof uPlotImpl === 'function' ? uPlotImpl : globalThis.uPlot;
if (typeof uPlotCtor !== 'function') {
console.warn('uPlot is unavailable; telemetry charts will not render.');
return [];
}
const instances = [];
const colorRoot = host?.ownerDocument?.body ?? host?.body ?? globalThis.document?.body ?? null;
const axisColor = colorRoot && typeof globalThis.getComputedStyle === 'function'
? globalThis.getComputedStyle(colorRoot).getPropertyValue('--muted').trim()
: null;
const gridColor = colorRoot && typeof globalThis.getComputedStyle === 'function'
? globalThis.getComputedStyle(colorRoot).getPropertyValue('--line').trim()
: null;
chartModels.forEach(model => {
const container = host.querySelector(`[data-telemetry-chart-id="${model.id}"]`);
if (!container) return;
const plotRoot = container.querySelector('[data-telemetry-plot]');
if (!plotRoot) return;
plotRoot.innerHTML = '';
const plotWidth = plotRoot.clientWidth || plotRoot.getBoundingClientRect?.().width;
const plotHeight = plotRoot.clientHeight || plotRoot.getBoundingClientRect?.().height;
const { options, data } = buildUPlotChartConfig(model, {
width: plotWidth ? Math.round(plotWidth) : undefined,
height: plotHeight ? Math.round(plotHeight) : undefined,
axisColor: axisColor || undefined,
gridColor: gridColor || undefined,
});
const instance = new uPlotCtor(options, data, plotRoot);
instance.__potatoMeshRoot = plotRoot;
instances.push(instance);
});
registerTelemetryChartResize(instances);
return instances;
}
const telemetryResizeRegistry = new Set();
const telemetryResizeObservers = new WeakMap();
let telemetryResizeListenerAttached = false;
let telemetryResizeDebounceId = null;
const TELEMETRY_RESIZE_DEBOUNCE_MS = 120;
function resizeUPlotInstance(instance) {
if (!instance || typeof instance.setSize !== 'function') {
return;
}
const root = instance.__potatoMeshRoot ?? instance.root ?? null;
if (!root) return;
const rect = typeof root.getBoundingClientRect === 'function' ? root.getBoundingClientRect() : null;
const width = Number.isFinite(root.clientWidth) ? root.clientWidth : rect?.width;
const height = Number.isFinite(root.clientHeight) ? root.clientHeight : rect?.height;
if (!width || !height) return;
instance.setSize({ width: Math.round(width), height: Math.round(height) });
}
function registerTelemetryChartResize(instances) {
if (!Array.isArray(instances) || instances.length === 0) {
return;
}
const scheduleResize = () => {
if (telemetryResizeDebounceId != null) {
clearTimeout(telemetryResizeDebounceId);
}
telemetryResizeDebounceId = setTimeout(() => {
telemetryResizeDebounceId = null;
telemetryResizeRegistry.forEach(instance => resizeUPlotInstance(instance));
}, TELEMETRY_RESIZE_DEBOUNCE_MS);
};
instances.forEach(instance => {
telemetryResizeRegistry.add(instance);
resizeUPlotInstance(instance);
if (typeof globalThis.ResizeObserver === 'function') {
if (telemetryResizeObservers.has(instance)) return;
const observer = new globalThis.ResizeObserver(scheduleResize);
telemetryResizeObservers.set(instance, observer);
const root = instance.__potatoMeshRoot ?? instance.root ?? null;
if (root && typeof observer.observe === 'function') {
observer.observe(root);
}
}
});
if (!telemetryResizeListenerAttached && typeof globalThis.addEventListener === 'function') {
globalThis.addEventListener('resize', () => {
scheduleResize();
});
telemetryResizeListenerAttached = true;
}
}
function defaultLoadUPlot({ documentRef, onLoad }) {
if (!documentRef || typeof documentRef.querySelector !== 'function') {
return false;
}
const existing = documentRef.querySelector('script[data-uplot-loader="true"]');
if (existing) {
if (existing.dataset.loaded === 'true' && typeof onLoad === 'function') {
onLoad();
} else if (typeof existing.addEventListener === 'function' && typeof onLoad === 'function') {
existing.addEventListener('load', onLoad, { once: true });
}
return true;
}
if (typeof documentRef.createElement !== 'function') {
return false;
}
const script = documentRef.createElement('script');
script.src = '/assets/vendor/uplot/uPlot.iife.min.js';
script.defer = true;
script.dataset.uplotLoader = 'true';
if (typeof script.addEventListener === 'function') {
script.addEventListener('load', () => {
script.dataset.loaded = 'true';
if (typeof onLoad === 'function') {
onLoad();
}
});
}
const head = documentRef.head ?? documentRef.body;
if (head && typeof head.appendChild === 'function') {
head.appendChild(script);
return true;
}
return false;
}
/**
* Mount telemetry charts, retrying briefly if uPlot has not loaded yet.
*
* @param {Array<Object>} chartModels Chart models to render.
* @param {{root?: ParentNode, uPlotImpl?: Function, loadUPlot?: Function}} [options] Rendering options.
* @returns {Array<Object>} Instantiated uPlot charts.
*/
export function mountTelemetryChartsWithRetry(chartModels, { root, uPlotImpl, loadUPlot } = {}) {
const instances = mountTelemetryCharts(chartModels, { root, uPlotImpl });
if (instances.length > 0 || typeof uPlotImpl === 'function') {
return instances;
}
const host = root ?? globalThis.document;
if (!host || typeof host.querySelector !== 'function') {
return instances;
}
let mounted = false;
let attempts = 0;
const maxAttempts = 10;
const retryDelayMs = 50;
const retry = () => {
if (mounted) return;
attempts += 1;
const next = mountTelemetryCharts(chartModels, { root, uPlotImpl });
if (next.length > 0) {
mounted = true;
return;
}
if (attempts >= maxAttempts) {
return;
}
setTimeout(retry, retryDelayMs);
};
const loadFn = typeof loadUPlot === 'function' ? loadUPlot : defaultLoadUPlot;
loadFn({
documentRef: host.ownerDocument ?? globalThis.document,
onLoad: () => {
const next = mountTelemetryCharts(chartModels, { root, uPlotImpl });
if (next.length > 0) {
mounted = true;
}
},
});
setTimeout(retry, 0);
return instances;
}
/**
* Create chart markup and models for telemetry charts.
*
* @param {Object} node Normalised node payload.
* @param {{ nowMs?: number, chartOptions?: Object }} [options] Rendering options.
* @returns {{chartsHtml: string, chartModels: Array<Object>}} Chart markup and models.
*/
export function createTelemetryCharts(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
: null;
const aggregatedSnapshots = Array.isArray(telemetrySource?.snapshots)
? telemetrySource.snapshots
: null;
const rawSnapshots = snapshotHistory ?? aggregatedSnapshots;
if (!Array.isArray(rawSnapshots) || rawSnapshots.length === 0) {
return { chartsHtml: '', chartModels: [] };
}
const entries = rawSnapshots
.map(snapshot => {
const timestamp = resolveSnapshotTimestamp(snapshot);
if (timestamp == null) return null;
return { timestamp, snapshot };
})
.filter(entry => entry != null && entry.timestamp >= nowMs - TELEMETRY_WINDOW_MS && entry.timestamp <= nowMs)
.sort((a, b) => a.timestamp - b.timestamp);
if (entries.length === 0) {
return { chartsHtml: '', chartModels: [] };
}
const chartModels = TELEMETRY_CHART_SPECS
.map(spec => buildTelemetryChartModel(spec, entries, nowMs, chartOptions))
.filter(model => model != null);
if (chartModels.length === 0) {
return { chartsHtml: '', chartModels: [] };
}
const chartsHtml = `
<section class="node-detail__charts">
<div class="node-detail__charts-grid">
${chartModels.map(model => renderTelemetryChartMarkup(model)).join('')}
</div>
</section>
`;
return { chartsHtml, chartModels };
}
/**
* Render the telemetry charts for the supplied node when telemetry snapshots
* exist.
@@ -1242,41 +1493,7 @@ function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
* @returns {string} Chart grid markup or an empty string.
*/
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
: null;
const aggregatedSnapshots = Array.isArray(telemetrySource?.snapshots)
? telemetrySource.snapshots
: null;
const rawSnapshots = snapshotHistory ?? aggregatedSnapshots;
if (!Array.isArray(rawSnapshots) || rawSnapshots.length === 0) {
return '';
}
const entries = rawSnapshots
.map(snapshot => {
const timestamp = resolveSnapshotTimestamp(snapshot);
if (timestamp == null) return null;
return { timestamp, snapshot };
})
.filter(entry => entry != null && entry.timestamp >= nowMs - TELEMETRY_WINDOW_MS && entry.timestamp <= nowMs)
.sort((a, b) => a.timestamp - b.timestamp);
if (entries.length === 0) {
return '';
}
const charts = TELEMETRY_CHART_SPECS
.map(spec => renderTelemetryChart(spec, entries, nowMs, chartOptions))
.filter(chart => stringOrNull(chart));
if (charts.length === 0) {
return '';
}
return `
<section class="node-detail__charts">
<div class="node-detail__charts-grid">
${charts.join('')}
</div>
</section>
`;
return createTelemetryCharts(node, { nowMs, chartOptions }).chartsHtml;
}
/**
@@ -2298,6 +2515,7 @@ function renderTraceroutes(traces, renderShortHtml, { roleIndex = null, node = n
* messages?: Array<Object>,
* traces?: Array<Object>,
* renderShortHtml: Function,
* chartsHtml?: string,
* }} options Rendering options.
* @returns {string} HTML fragment representing the detail view.
*/
@@ -2307,6 +2525,7 @@ function renderNodeDetailHtml(node, {
traces = [],
renderShortHtml,
roleIndex = null,
chartsHtml = null,
chartNowMs = Date.now(),
} = {}) {
const roleAwareBadge = renderRoleAwareBadge(renderShortHtml, {
@@ -2320,7 +2539,7 @@ function renderNodeDetailHtml(node, {
const longName = stringOrNull(node.longName ?? node.long_name);
const identifier = stringOrNull(node.nodeId ?? node.node_id);
const tableHtml = renderSingleNodeTable(node, renderShortHtml);
const chartsHtml = renderTelemetryCharts(node, { nowMs: chartNowMs });
const telemetryChartsHtml = stringOrNull(chartsHtml) ?? renderTelemetryCharts(node, { nowMs: chartNowMs });
const neighborsHtml = renderNeighborGroups(node, neighbors, renderShortHtml, { roleIndex });
const tracesHtml = renderTraceroutes(traces, renderShortHtml, { roleIndex, node });
const messagesHtml = renderMessages(messages, renderShortHtml, node);
@@ -2346,7 +2565,7 @@ function renderNodeDetailHtml(node, {
<header class="node-detail__header">
<h2 class="node-detail__title">${badgeHtml}${nameHtml}${identifierHtml}</h2>
</header>
${chartsHtml ?? ''}
${telemetryChartsHtml ?? ''}
${tableSection}
${contentHtml}
`;
@@ -2460,15 +2679,17 @@ async function fetchTracesForNode(identifier, { fetchImpl } = {}) {
}
/**
* Initialise the node detail page by hydrating the DOM with fetched data.
* Fetch node detail data and render the HTML fragment.
*
* @param {{
* document?: Document,
* fetchImpl?: Function,
* refreshImpl?: Function,
* renderShortHtml?: Function,
* chartNowMs?: number,
* chartOptions?: Object,
* }} options Optional overrides for testing.
* @returns {Promise<boolean>} ``true`` when the node was rendered successfully.
* @returns {Promise<string|{html: string, chartModels: Array<Object>}>} Rendered markup or chart models when requested.
*/
export async function fetchNodeDetailHtml(referenceData, options = {}) {
if (!referenceData || typeof referenceData !== 'object') {
@@ -2498,15 +2719,38 @@ export async function fetchNodeDetailHtml(referenceData, options = {}) {
fetchTracesForNode(messageIdentifier, { fetchImpl: options.fetchImpl }),
]);
const roleIndex = await buildTraceRoleIndex(traces, neighborRoleIndex, { fetchImpl: options.fetchImpl });
return renderNodeDetailHtml(node, {
const chartNowMs = Number.isFinite(options.chartNowMs) ? options.chartNowMs : Date.now();
const chartState = createTelemetryCharts(node, {
nowMs: chartNowMs,
chartOptions: options.chartOptions ?? {},
});
const html = renderNodeDetailHtml(node, {
neighbors: node.neighbors,
messages,
traces,
renderShortHtml,
roleIndex,
chartsHtml: chartState.chartsHtml,
chartNowMs,
});
if (options.returnState === true) {
return { html, chartModels: chartState.chartModels };
}
return html;
}
/**
* Initialise the standalone node detail page and mount telemetry charts.
*
* @param {{
* document?: Document,
* fetchImpl?: Function,
* refreshImpl?: Function,
* renderShortHtml?: Function,
* uPlotImpl?: Function,
* }} options Optional overrides for testing.
* @returns {Promise<boolean>} ``true`` when the node was rendered successfully.
*/
export async function initializeNodeDetailPage(options = {}) {
const documentRef = options.document ?? globalThis.document;
if (!documentRef || typeof documentRef.querySelector !== 'function') {
@@ -2543,13 +2787,15 @@ export async function initializeNodeDetailPage(options = {}) {
const privateMode = (root.dataset?.privateMode ?? '').toLowerCase() === 'true';
try {
const html = await fetchNodeDetailHtml(referenceData, {
const result = await fetchNodeDetailHtml(referenceData, {
fetchImpl: options.fetchImpl,
refreshImpl,
renderShortHtml: options.renderShortHtml,
privateMode,
returnState: true,
});
root.innerHTML = html;
root.innerHTML = result.html;
mountTelemetryChartsWithRetry(result.chartModels, { root, uPlotImpl: options.uPlotImpl });
return true;
} catch (error) {
console.error('Failed to render node detail page', error);
@@ -2586,7 +2832,11 @@ export const __testUtils = {
categoriseNeighbors,
renderNeighborGroups,
renderSingleNodeTable,
createTelemetryCharts,
renderTelemetryCharts,
mountTelemetryCharts,
mountTelemetryChartsWithRetry,
buildUPlotChartConfig,
renderMessages,
renderTraceroutes,
renderTracePath,

View File

@@ -994,7 +994,7 @@ body.dark .node-detail-overlay__close:hover {
.node-detail__charts-grid {
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 640px), 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(100%, 1152px), 1fr));
}
.node-detail__chart {
@@ -1026,10 +1026,45 @@ body.dark .node-detail-overlay__close:hover {
font-size: 1rem;
}
.node-detail__chart svg {
.node-detail__chart-plot {
width: 100%;
height: auto;
height: clamp(240px, 50vw, 360px);
max-height: 420px;
overflow: hidden;
}
.node-detail__chart-plot .uplot {
width: 100%;
height: 100%;
margin: 0;
line-height: 0;
position: relative;
}
.node-detail__chart-plot .uplot .u-wrap,
.node-detail__chart-plot .uplot .u-under,
.node-detail__chart-plot .uplot .u-over {
top: 0;
left: 0;
}
.node-detail__chart-plot .u-axis,
.node-detail__chart-plot .u-axis .u-label,
.node-detail__chart-plot .u-axis .u-value,
.node-detail__chart-plot .u-axis text,
.node-detail__chart-plot .u-axis-label {
color: var(--muted) !important;
fill: var(--muted) !important;
font-size: 0.95rem;
}
.node-detail__chart-plot .u-grid {
stroke: rgba(12, 15, 18, 0.08);
stroke-width: 1;
}
body.dark .node-detail__chart-plot .u-grid {
stroke: rgba(255, 255, 255, 0.15);
}
.node-detail__chart-axis line {

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}

55
web/scripts/copy-uplot.js Normal file
View File

@@ -0,0 +1,55 @@
/*
* 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 { mkdir, copyFile, access } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Resolve an absolute path relative to this script location.
*
* @param {string[]} segments Path segments to append.
* @returns {string} Absolute path resolved from this script.
*/
function resolvePath(...segments) {
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(scriptDir, ...segments);
}
/**
* Ensure the uPlot assets are available within the public asset tree.
*
* @returns {Promise<void>} Resolves once files have been copied.
*/
async function copyUPlotAssets() {
const sourceDir = resolvePath('..', 'node_modules', 'uplot', 'dist');
const targetDir = resolvePath('..', 'public', 'assets', 'vendor', 'uplot');
const assets = ['uPlot.iife.min.js', 'uPlot.min.css'];
await access(sourceDir, fsConstants.R_OK);
await mkdir(targetDir, { recursive: true });
await Promise.all(
assets.map(async asset => {
const source = path.join(sourceDir, asset);
const target = path.join(targetDir, asset);
await copyFile(source, target);
}),
);
}
await copyUPlotAssets();

View File

@@ -13,12 +13,14 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<link rel="stylesheet" href="/assets/vendor/uplot/uPlot.min.css" />
<script src="/assets/vendor/uplot/uPlot.iife.min.js" defer></script>
<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">
<div id="chartsPage" class="charts-page__content" data-telemetry-root="true">
<p class="charts-page__status">Loading aggregated telemetry charts…</p>
</div>
</section>

View File

@@ -17,11 +17,14 @@
short_display = node_page_short_name || "Loading"
long_display = node_page_long_name
identifier_display = node_page_identifier || "" %>
<link rel="stylesheet" href="/assets/vendor/uplot/uPlot.min.css" />
<script src="/assets/vendor/uplot/uPlot.iife.min.js" defer></script>
<section
id="nodeDetail"
class="node-detail"
data-node-reference="<%= Rack::Utils.escape_html(reference_json) %>"
data-private-mode="<%= private_mode ? "true" : "false" %>"
data-telemetry-root="true"
>
<header class="node-detail__header">
<h2 class="node-detail__title">