mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-10 10:52:55 +02:00
charts: render aggregated telemetry charts for all nodes (#453)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user