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 20fa3ab..68cc3e5 100644 --- a/web/public/assets/js/app/__tests__/node-page.test.js +++ b/web/public/assets/js/app/__tests__/node-page.test.js @@ -43,6 +43,7 @@ const { categoriseNeighbors, renderNeighborGroups, renderSingleNodeTable, + renderTelemetryCharts, renderMessages, renderNodeDetailHtml, parseReferencePayload, @@ -260,6 +261,64 @@ test('renderSingleNodeTable renders a condensed table for the node', () => { assert.equal(html.includes('2m 30s'), true); }); +test('renderTelemetryCharts renders condensed scatter charts when telemetry exists', () => { + 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, + channel_utilization: 40, + air_util_tx: 22, + }, + environment_metrics: { + temperature: 19.5, + relative_humidity: 55, + barometric_pressure: 995, + gas_resistance: 1500, + }, + }, + { + rx_time: nowSeconds - 3_600, + deviceMetrics: { + batteryLevel: 78, + voltage: 4.05, + channelUtilization: 35, + airUtilTx: 20, + }, + environmentMetrics: { + temperature: 18.4, + relativeHumidity: 52, + barometricPressure: 1000, + gasResistance: 2000, + }, + }, + ], + }, + }, + }; + const html = renderTelemetryCharts(node, { nowMs }); + const fmt = new Date(nowMs); + const expectedDate = `${fmt.getFullYear()}-${String(fmt.getMonth() + 1).padStart(2, '0')}-${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 (0-100%)'), true); + assert.equal(html.includes('Voltage (0-6V)'), 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 (10-100k Ω)'), true); + assert.equal(html.includes('Temperature (-20-40°C)'), true); + assert.equal(html.includes(expectedDate), true); + assert.equal(html.includes('node-detail__chart-point'), true); +}); + test('renderNodeDetailHtml composes the table, neighbors, and messages', () => { const html = renderNodeDetailHtml( { @@ -296,6 +355,38 @@ test('renderNodeDetailHtml composes the table, neighbors, and messages', () => { assert.equal(html.includes('data-role="CLIENT"'), true); }); +test('renderNodeDetailHtml embeds telemetry charts when snapshots are present', () => { + const nowMs = Date.UTC(2025, 0, 8, 7, 0, 0); + const node = { + shortName: 'NODE', + nodeId: '!abcd', + role: 'CLIENT', + rawSources: { + node: { node_id: '!abcd', role: 'CLIENT', short_name: 'NODE' }, + telemetry: { + snapshots: [ + { + rx_time: Math.floor(nowMs / 1000) - 120, + battery_level: 75, + voltage: 4.08, + channel_utilization: 30, + temperature: 20, + relative_humidity: 45, + barometric_pressure: 990, + gas_resistance: 1800, + }, + ], + }, + }, + }; + const html = renderNodeDetailHtml(node, { + renderShortHtml: short => `${short}`, + chartNowMs: nowMs, + }); + assert.equal(html.includes('node-detail__charts'), true); + assert.equal(html.includes('Power metrics'), true); +}); + test('parseReferencePayload returns null for invalid JSON', () => { assert.equal(parseReferencePayload('{'), null); assert.deepEqual(parseReferencePayload('{"nodeId":"!abc"}'), { nodeId: '!abc' }); diff --git a/web/public/assets/js/app/node-page.js b/web/public/assets/js/app/node-page.js index 6b25bc7..3c078af 100644 --- a/web/public/assets/js/app/node-page.js +++ b/web/public/assets/js/app/node-page.js @@ -34,6 +34,178 @@ const MESSAGE_LIMIT = 50; 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 TELEMETRY_WINDOW_MS = DAY_MS * 7; +const DEFAULT_CHART_DIMENSIONS = Object.freeze({ width: 660, height: 300 }); +const DEFAULT_CHART_MARGIN = Object.freeze({ top: 20, right: 64, bottom: 40, left: 64 }); +/** + * Telemetry chart definitions describing axes and series metadata. + * + * @type {Array} + */ +const TELEMETRY_CHART_SPECS = Object.freeze([ + { + id: 'power', + title: 'Power metrics', + axes: [ + { + id: 'battery', + position: 'left', + label: 'Battery (0-100%)', + min: 0, + max: 100, + ticks: 4, + color: '#8856a7', + }, + { + id: 'voltage', + position: 'right', + label: 'Voltage (0-6V)', + min: 0, + max: 6, + ticks: 3, + color: '#9ebcda', + }, + ], + series: [ + { + id: 'battery', + axis: 'battery', + color: '#8856a7', + label: 'Battery level', + legend: 'Battery (0-100%)', + fields: ['battery', 'battery_level', 'batteryLevel'], + valueFormatter: value => `${value.toFixed(1)}%`, + }, + { + id: 'voltage', + axis: 'voltage', + color: '#9ebcda', + label: 'Voltage', + legend: 'Voltage (0-6V)', + fields: ['voltage', 'voltageReading'], + valueFormatter: value => `${value.toFixed(2)} V`, + }, + ], + }, + { + id: 'channel', + title: 'Channel utilization', + axes: [ + { + id: 'channel', + position: 'left', + label: 'Utilization', + min: 0, + max: 100, + ticks: 4, + color: '#2ca25f', + }, + ], + series: [ + { + id: 'channel', + axis: 'channel', + color: '#2ca25f', + label: 'Channel util', + legend: 'Channel utilization (%)', + fields: ['channel', 'channel_utilization', 'channelUtilization'], + valueFormatter: value => `${value.toFixed(1)}%`, + }, + { + id: 'air', + axis: 'channel', + color: '#99d8c9', + label: 'Air util tx', + legend: 'Air util TX (%)', + fields: ['airUtil', 'air_util_tx', 'airUtilTx'], + valueFormatter: value => `${value.toFixed(1)}%`, + }, + ], + }, + { + id: 'environment', + title: 'Environmental telemetry', + axes: [ + { + id: 'temperature', + position: 'left', + label: 'Temperature (-20-40°C)', + min: -20, + max: 40, + ticks: 4, + color: '#fc8d59', + }, + { + id: 'humidity', + position: 'left', + label: 'Humidity (0-100%)', + min: 0, + max: 100, + ticks: 4, + color: '#91bfdb', + visible: false, + }, + { + id: 'pressure', + position: 'right', + label: 'Pressure (800-1100hPa)', + min: 800, + max: 1_100, + ticks: 4, + color: '#c51b8a', + }, + { + id: 'gas', + position: 'rightSecondary', + label: 'Gas resistance (10-100k Ω)', + min: 10, + max: 100_000, + ticks: 5, + color: '#fa9fb5', + scale: 'log', + }, + ], + series: [ + { + id: 'temperature', + axis: 'temperature', + color: '#fc8d59', + label: 'Temperature', + legend: 'Temperature (-20-40\u00b0C)', + fields: ['temperature', 'temp'], + valueFormatter: value => `${value.toFixed(1)}\u00b0C`, + }, + { + id: 'humidity', + axis: 'humidity', + color: '#91bfdb', + label: 'Humidity', + legend: 'Humidity (0-100%)', + fields: ['humidity', 'relative_humidity', 'relativeHumidity'], + valueFormatter: value => `${value.toFixed(1)}%`, + }, + { + id: 'pressure', + axis: 'pressure', + color: '#c51b8a', + label: 'Pressure', + legend: 'Pressure (800-1100hPa)', + fields: ['pressure', 'barometric_pressure', 'barometricPressure'], + valueFormatter: value => `${value.toFixed(1)} hPa`, + }, + { + id: 'gas', + axis: 'gas', + color: '#fa9fb5', + label: 'Gas resistance', + legend: 'Gas resistance (10-100k \u03a9)', + fields: ['gas_resistance', 'gasResistance'], + valueFormatter: value => formatGasResistance(value), + }, + ], + }, +]); /** * Convert a candidate value into a trimmed string. @@ -308,6 +480,624 @@ function formatSnr(value) { return `${numeric.toFixed(1)} dB`; } +/** + * Convert a timestamp that may be expressed in seconds or milliseconds into + * milliseconds. + * + * @param {*} value Candidate timestamp. + * @returns {number|null} Timestamp in milliseconds or ``null``. + */ +function toTimestampMs(value) { + const numeric = numberOrNull(value); + if (numeric == null) return null; + if (numeric > 1_000_000_000_000) { + return numeric; + } + return numeric * 1000; +} + +/** + * Resolve the canonical telemetry timestamp for a snapshot record. + * + * @param {*} snapshot Telemetry snapshot payload. + * @returns {number|null} Timestamp in milliseconds. + */ +function resolveSnapshotTimestamp(snapshot) { + if (!snapshot || typeof snapshot !== 'object') { + return null; + } + const isoCandidate = stringOrNull( + snapshot.rx_iso + ?? snapshot.rxIso + ?? snapshot.telemetry_time_iso + ?? snapshot.telemetryTimeIso + ?? snapshot.timestampIso, + ); + if (isoCandidate) { + const parsed = new Date(isoCandidate); + if (!Number.isNaN(parsed.getTime())) { + return parsed.getTime(); + } + } + const numericCandidates = [ + snapshot.rx_time, + snapshot.rxTime, + snapshot.telemetry_time, + snapshot.telemetryTime, + snapshot.timestamp, + snapshot.ts, + ]; + for (const candidate of numericCandidates) { + const ts = toTimestampMs(candidate); + if (ts != null) { + return ts; + } + } + return null; +} + +/** + * Clamp a numeric value between ``min`` and ``max``. + * + * @param {number} value Value to clamp. + * @param {number} min Minimum bound. + * @param {number} max Maximum bound. + * @returns {number} Clamped numeric value. + */ +function clamp(value, min, max) { + if (!Number.isFinite(value)) return min; + if (value < min) return min; + if (value > max) return max; + return value; +} + +/** + * Convert a hex colour into an rgba string with the specified alpha. + * + * @param {string} hex Hex colour string. + * @param {number} alpha Alpha component between 0 and 1. + * @returns {string} RGBA CSS string. + */ +function hexToRgba(hex, alpha = 1) { + const normalised = stringOrNull(hex)?.replace(/^#/, '') ?? ''; + if (!(normalised.length === 6 || normalised.length === 3)) { + return `rgba(0, 0, 0, ${alpha})`; + } + const expanded = normalised.length === 3 + ? normalised.split('').map(piece => piece + piece).join('') + : normalised; + const toComponent = (start, end) => parseInt(expanded.slice(start, end), 16); + const r = toComponent(0, 2); + const g = toComponent(2, 4); + const b = toComponent(4, 6); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +/** + * Format a timestamp as ``YYYY-MM-DD`` using the local time zone. + * + * @param {number} timestampMs Timestamp expressed in milliseconds. + * @returns {string} Compact date string. + */ +function formatCompactDate(timestampMs) { + const date = new Date(timestampMs); + if (Number.isNaN(date.getTime())) return ''; + const year = date.getFullYear(); + const month = padTwo(date.getMonth() + 1); + const day = padTwo(date.getDate()); + return `${year}-${month}-${day}`; +} + +/** + * Build midnight tick timestamps covering the floating telemetry window. + * + * @param {number} nowMs Reference timestamp in milliseconds. + * @returns {Array} Midnight timestamps within the window. + */ +function buildMidnightTicks(nowMs) { + const ticks = []; + const domainStart = nowMs - TELEMETRY_WINDOW_MS; + const cursor = new Date(nowMs); + cursor.setHours(0, 0, 0, 0); + for (let ts = cursor.getTime(); ts >= domainStart; ts -= DAY_MS) { + ticks.push(ts); + } + return ticks.reverse(); +} + +/** + * Build evenly spaced ticks for linear axes. + * + * @param {number} min Axis minimum. + * @param {number} max Axis maximum. + * @param {number} [count=4] Number of tick segments. + * @returns {Array} Tick values including the extrema. + */ +function buildLinearTicks(min, max, count = 4) { + if (!Number.isFinite(min) || !Number.isFinite(max)) return []; + if (max <= min) return [min]; + const segments = Math.max(1, Math.floor(count)); + const step = (max - min) / segments; + const ticks = []; + for (let idx = 0; idx <= segments; idx += 1) { + ticks.push(min + step * idx); + } + return ticks; +} + +/** + * Build base-10 ticks for logarithmic axes. + * + * @param {number} min Minimum domain value. + * @param {number} max Maximum domain value. + * @returns {Array} Tick values distributed across powers of 10. + */ +function buildLogTicks(min, max) { + if (!Number.isFinite(min) || !Number.isFinite(max) || min <= 0 || max <= min) { + return []; + } + const ticks = []; + const minExp = Math.ceil(Math.log10(min)); + const maxExp = Math.floor(Math.log10(max)); + for (let exp = minExp; exp <= maxExp; exp += 1) { + ticks.push(10 ** exp); + } + if (!ticks.includes(min)) ticks.unshift(min); + if (!ticks.includes(max)) ticks.push(max); + return ticks; +} + +/** + * Format tick labels using compact units for better readability. + * + * @param {number} value Tick value. + * @param {Object} axis Axis descriptor. + * @returns {string} Formatted label. + */ +function formatAxisTick(value, axis) { + if (!Number.isFinite(value)) return ''; + if (axis.scale === 'log') { + if (value >= 1000) { + return `${Math.round(value / 1000)}k`; + } + return `${Math.round(value)}`; + } + if (Math.abs(axis.max - axis.min) <= 10) { + return value.toFixed(1); + } + return Math.round(value).toString(); +} + +/** + * Format a gas resistance reading using sensible prefixes with the Ω symbol. + * + * @param {number} value Resistance value in Ohms. + * @returns {string} Formatted resistance string. + */ +function formatGasResistance(value) { + const numeric = numberOrNull(value); + if (numeric == null) return ''; + const absValue = Math.abs(numeric); + if (absValue >= 1_000_000) { + return `${(numeric / 1_000_000).toFixed(2)} M\u03a9`; + } + if (absValue >= 1_000) { + return `${(numeric / 1_000).toFixed(2)} k\u03a9`; + } + if (absValue >= 100) { + return `${numeric.toFixed(1)} \u03a9`; + } + return `${numeric.toFixed(0)} \u03a9`; +} + +/** + * Format a data point value for tooltip display. + * + * @param {Object} seriesConfig Series configuration. + * @param {number} value Numeric data point value. + * @returns {string} Formatted value string. + */ +function formatSeriesPointValue(seriesConfig, value) { + const numeric = numberOrNull(value); + if (numeric == null) return ''; + if (typeof seriesConfig.valueFormatter === 'function') { + return seriesConfig.valueFormatter(numeric); + } + return numeric.toString(); +} + +/** + * Determine the layout metrics for the provided chart specification. + * + * @param {Object} spec Chart specification. + * @returns {{width: number, height: number, margin: Object, innerWidth: number, innerHeight: number, chartTop: number, chartBottom: number}} + * Chart dimensions. + */ +function createChartDimensions(spec) { + const margin = { ...DEFAULT_CHART_MARGIN }; + if (spec.axes.some(axis => axis.position === 'leftSecondary')) { + margin.left += 36; + } + if (spec.axes.some(axis => axis.position === 'rightSecondary')) { + margin.right += 40; + } + const width = DEFAULT_CHART_DIMENSIONS.width; + const height = DEFAULT_CHART_DIMENSIONS.height; + const innerWidth = Math.max(1, width - margin.left - margin.right); + const innerHeight = Math.max(1, height - margin.top - margin.bottom); + return { + width, + height, + margin, + innerWidth, + innerHeight, + chartTop: margin.top, + chartBottom: height - margin.bottom, + }; +} + +/** + * 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. + * + * @param {Object} snapshot Telemetry snapshot payload. + * @returns {Array} Container objects inspected for telemetry fields. + */ +function collectSnapshotContainers(snapshot) { + const containers = []; + if (!snapshot || typeof snapshot !== 'object') { + return containers; + } + const seen = new Set(); + const enqueue = value => { + if (!value || typeof value !== 'object') return; + if (seen.has(value)) return; + seen.add(value); + containers.push(value); + }; + enqueue(snapshot); + const directKeys = [ + 'device_metrics', + 'deviceMetrics', + 'environment_metrics', + 'environmentMetrics', + 'raw', + ]; + directKeys.forEach(key => { + if (Object.prototype.hasOwnProperty.call(snapshot, key)) { + enqueue(snapshot[key]); + } + }); + if (snapshot.raw && typeof snapshot.raw === 'object') { + ['device_metrics', 'deviceMetrics', 'environment_metrics', 'environmentMetrics'].forEach(key => { + if (Object.prototype.hasOwnProperty.call(snapshot.raw, key)) { + enqueue(snapshot.raw[key]); + } + }); + } + return containers; +} + +/** + * Extract the first numeric telemetry value that matches one of the provided + * field names. + * + * @param {*} snapshot Telemetry payload. + * @param {Array} fields Candidate property names. + * @returns {number|null} Extracted numeric value or ``null``. + */ +function extractSnapshotValue(snapshot, fields) { + if (!snapshot || typeof snapshot !== 'object' || !Array.isArray(fields)) { + return null; + } + const containers = collectSnapshotContainers(snapshot); + for (const container of containers) { + for (const field of fields) { + if (!Object.prototype.hasOwnProperty.call(container, field)) continue; + const numeric = numberOrNull(container[field]); + if (numeric != null) { + return numeric; + } + } + } + return null; +} + +/** + * Build data points for a series constrained to the seven-day window. + * + * @param {Array<{timestamp: number, snapshot: Object}>} entries Telemetry entries. + * @param {Array} fields Candidate metric names. + * @param {number} domainStart Window start in milliseconds. + * @param {number} domainEnd Window end in milliseconds. + * @returns {Array<{timestamp: number, value: number}>} Series points sorted by timestamp. + */ +function buildSeriesPoints(entries, fields, domainStart, domainEnd) { + const points = []; + entries.forEach(entry => { + if (!entry || typeof entry !== 'object') return; + const value = extractSnapshotValue(entry.snapshot, fields); + if (value == null) return; + if (entry.timestamp < domainStart || entry.timestamp > domainEnd) { + return; + } + points.push({ timestamp: entry.timestamp, value }); + }); + points.sort((a, b) => a.timestamp - b.timestamp); + return points; +} + +/** + * 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) { + if (!Array.isArray(points) || points.length === 0) { + return ''; + } + const circles = []; + const coordinates = points.map(point => { + const cx = scaleTimestamp(point.timestamp, domainStart, domainEnd, dims); + const cy = scaleValueToAxis(point.value, axis, dims); + const tooltip = formatSeriesPointValue(seriesConfig, point.value); + const titleMarkup = tooltip ? `${escapeHtml(tooltip)}` : ''; + circles.push( + ``, + ); + return { cx, cy }; + }); + 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 = ``; + } + return `${line}${circles.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 ` + + `; + }) + .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 ` + + `; +} + +/** + * 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} tickTimestamps Midnight tick timestamps. + * @returns {string} SVG markup for the X axis. + */ +function renderXAxis(dims, domainStart, domainEnd, tickTimestamps) { + 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); + return ` + + `; + }) + .join(''); + return ` + + `; +} + +/** + * Render a single telemetry chart defined by ``spec``. + * + * @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. + */ +function renderTelemetryChart(spec, entries, nowMs) { + const domainEnd = nowMs; + const domainStart = nowMs - TELEMETRY_WINDOW_MS; + 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 }; + }) + .filter(entry => entry != null); + if (seriesEntries.length === 0) { + return ''; + } + const axesMarkup = spec.axes.map(axis => renderYAxis(axis, dims)).join(''); + const xAxisMarkup = renderXAxis(dims, domainStart, domainEnd, buildMidnightTicks(nowMs)); + const seriesMarkup = seriesEntries + .map(series => renderTelemetrySeries(series.config, series.points, series.axis, dims, domainStart, domainEnd)) + .join(''); + const legendItems = seriesEntries + .map(series => { + const legendLabel = stringOrNull(series.config.legend) ?? series.config.label; + return ` + + + ${escapeHtml(legendLabel)} + + `; + }) + .join(''); + const legendMarkup = legendItems + ? `` + : ''; + return ` +
+
+

${escapeHtml(spec.title)}

+ Last 7 days +
+ + ${axesMarkup} + ${xAxisMarkup} + ${seriesMarkup} + + ${legendMarkup} +
+ `; +} + +/** + * Render the telemetry charts for the supplied node when telemetry snapshots + * exist. + * + * @param {Object} node Normalised node payload. + * @param {{ nowMs?: number }} [options] Rendering options. + * @returns {string} Chart grid markup or an empty string. + */ +function renderTelemetryCharts(node, { nowMs = Date.now() } = {}) { + const telemetrySource = node?.rawSources?.telemetry; + const rawSnapshots = telemetrySource?.snapshots; + 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)) + .filter(chart => stringOrNull(chart)); + if (charts.length === 0) { + return ''; + } + return ` +
+
+ ${charts.join('')} +
+
+ `; +} + /** * Normalise a node identifier for consistent lookups. * @@ -1130,7 +1920,8 @@ function renderNodeDetailHtml(node, { messages = [], renderShortHtml, neighborRoleIndex = null, -}) { + chartNowMs = Date.now(), +} = {}) { const roleAwareBadge = renderRoleAwareBadge(renderShortHtml, { shortName: node.shortName ?? node.short_name, longName: node.longName ?? node.long_name, @@ -1142,6 +1933,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 neighborsHtml = renderNeighborGroups(node, neighbors, renderShortHtml, { roleIndex: neighborRoleIndex }); const messagesHtml = renderMessages(messages, renderShortHtml, node); @@ -1163,6 +1955,7 @@ function renderNodeDetailHtml(node, {

${badgeHtml}${nameHtml}${identifierHtml}

+ ${chartsHtml ?? ''} ${tableSection} ${contentHtml} `; @@ -1327,6 +2120,7 @@ export const __testUtils = { categoriseNeighbors, renderNeighborGroups, renderSingleNodeTable, + renderTelemetryCharts, renderMessages, renderNodeDetailHtml, parseReferencePayload, diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index 20cb03b..24d7d64 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -812,6 +812,107 @@ body.view-map .map-panel--full #map { width: 100%; } +.node-detail__charts { + padding: 0 20px; + margin: 12px 0 24px; +} + +.node-detail__charts-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.node-detail__chart { + border: 1px solid var(--line); + border-radius: 10px; + background: var(--card); + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.node-detail__chart-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + font-size: 0.9rem; + margin: 0; +} + +.node-detail__chart-header h4 { + margin: 0; + font-size: 1rem; +} + +.node-detail__chart-header span { + color: var(--muted); + font-size: 0.85rem; +} + +.node-detail__chart svg { + width: 100%; + height: auto; + max-height: 320px; +} + +.node-detail__chart-axis line { + stroke: var(--line); + stroke-width: 1; +} + +.node-detail__chart-axis text, +.node-detail__chart-axis-label { + fill: var(--muted); + font-size: 0.75rem; +} + +.node-detail__chart-grid-line { + stroke: rgba(12, 15, 18, 0.08); + stroke-width: 1; +} + +body.dark .node-detail__chart-grid-line { + stroke: rgba(255, 255, 255, 0.15); +} + +.node-detail__chart-point { + stroke: none; +} + +.node-detail__chart-legend { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.node-detail__chart-legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; +} + +.node-detail__chart-legend-swatch { + width: 10px; + height: 10px; + border-radius: 999px; + flex: 0 0 auto; +} + +.node-detail__chart-legend-text { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.node-detail__chart-legend-text small { + color: var(--muted); + font-size: 0.75rem; +} + .node-detail__identifier { font-family: ui-monospace, Menlo, Consolas, monospace; color: var(--muted);