diff --git a/web/public/assets/js/app/__tests__/node-page.test.js b/web/public/assets/js/app/__tests__/node-page.test.js index 7571ed1..e3f7944 100644 --- a/web/public/assets/js/app/__tests__/node-page.test.js +++ b/web/public/assets/js/app/__tests__/node-page.test.js @@ -405,6 +405,77 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis assert.equal(html.includes('node-detail__chart-point'), true); }); +test('renderTelemetryCharts expands upper bounds when overflow metrics exceed defaults', () => { + const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0); + const nowSeconds = Math.floor(nowMs / 1000); + const node = { + rawSources: { + telemetry: { + snapshots: [ + { + rx_time: nowSeconds - 120, + device_metrics: { + battery_level: 90, + voltage: 7.2, + current: 3.6, + channel_utilization: 45, + air_util_tx: 18, + }, + environment_metrics: { + temperature: 45, + relative_humidity: 48, + barometric_pressure: 1250, + gas_resistance: 1200, + iaq: 650, + }, + }, + ], + }, + }, + }; + 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>/); +}); + +test('renderTelemetryCharts keeps default bounds when metrics stay within limits', () => { + const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0); + const nowSeconds = Math.floor(nowMs / 1000); + const node = { + rawSources: { + telemetry: { + snapshots: [ + { + rx_time: nowSeconds - 180, + device_metrics: { + battery_level: 70, + voltage: 4.5, + current: 1.5, + channel_utilization: 35, + air_util_tx: 15, + }, + environment_metrics: { + temperature: 25, + relative_humidity: 50, + barometric_pressure: 1015, + gas_resistance: 1500, + iaq: 200, + }, + }, + ], + }, + }, + }; + 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>/); +}); + test('renderNodeDetailHtml composes the table, neighbors, and messages', () => { const html = renderNodeDetailHtml( { diff --git a/web/public/assets/js/app/node-page.js b/web/public/assets/js/app/node-page.js index 8d8fd88..e31288a 100644 --- a/web/public/assets/js/app/node-page.js +++ b/web/public/assets/js/app/node-page.js @@ -68,6 +68,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([ max: 6, ticks: 3, color: '#9ebcda', + allowUpperOverflow: true, }, { id: 'current', @@ -77,6 +78,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([ max: 3, ticks: 3, color: '#3182bd', + allowUpperOverflow: true, }, ], series: [ @@ -156,6 +158,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([ max: 40, ticks: 4, color: '#fc8d59', + allowUpperOverflow: true, }, { id: 'humidity', @@ -220,6 +223,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([ max: 500, ticks: 5, color: '#636363', + allowUpperOverflow: true, }, ], series: [ @@ -1004,6 +1008,31 @@ function buildSeriesPoints(entries, fields, domainStart, domainEnd) { return points; } +/** + * Resolve the effective axis maximum when upper overflow is allowed. + * + * @param {Object} axis Axis descriptor. + * @param {Array<{axisId: string, points: Array<{timestamp: number, value: number}>}>} seriesEntries Series entries. + * @returns {number} Effective axis max. + */ +function resolveAxisMax(axis, seriesEntries) { + if (!axis || axis.allowUpperOverflow !== true) { + return axis?.max; + } + let observedMax = null; + for (const entry of seriesEntries) { + if (!entry || entry.axisId !== axis.id || !Array.isArray(entry.points)) continue; + for (const point of entry.points) { + if (!point || !Number.isFinite(point.value)) continue; + observedMax = observedMax == null ? point.value : Math.max(observedMax, point.value); + } + } + if (observedMax != null && Number.isFinite(axis.max) && observedMax > axis.max) { + return observedMax; + } + return axis.max; +} + /** * Render a telemetry series as circles plus an optional translucent guide line. * @@ -1133,33 +1162,48 @@ function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) { const domainEnd = nowMs; const domainStart = nowMs - windowMs; const dims = createChartDimensions(spec); - const axisMap = new Map(spec.axes.map(axis => [axis.id, axis])); const seriesEntries = spec.series .map(series => { - const axis = axisMap.get(series.axis); - if (!axis) return null; const points = buildSeriesPoints(entries, series.fields, domainStart, domainEnd); if (points.length === 0) return null; - return { config: series, axis, points }; + return { config: series, axisId: series.axis, points }; }) .filter(entry => entry != null); if (seriesEntries.length === 0) { return ''; } - const axesMarkup = spec.axes.map(axis => renderYAxis(axis, dims)).join(''); + const adjustedAxes = spec.axes.map(axis => { + const resolvedMax = resolveAxisMax(axis, seriesEntries); + if (resolvedMax != null && resolvedMax !== axis.max) { + return { ...axis, max: resolvedMax }; + } + return axis; + }); + const axisMap = new Map(adjustedAxes.map(axis => [axis.id, axis])); + const plottedSeries = seriesEntries + .map(series => { + const axis = axisMap.get(series.axisId); + if (!axis) return null; + return { config: series.config, axis, points: series.points }; + }) + .filter(entry => entry != null); + if (plottedSeries.length === 0) { + return ''; + } + 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 }); - const seriesMarkup = seriesEntries + const seriesMarkup = plottedSeries .map(series => renderTelemetrySeries(series.config, series.points, series.axis, dims, domainStart, domainEnd, { lineReducer: chartOptions.lineReducer, }), ) .join(''); - const legendItems = seriesEntries + const legendItems = plottedSeries .map(series => { const legendLabel = stringOrNull(series.config.legend) ?? series.config.label; return `