From 12f1801ed2583ac77c0c2d9baa5319c24589de01 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Fri, 14 Nov 2025 20:17:58 +0100 Subject: [PATCH] nodes: improve charts on detail pages (#450) * nodes: add charts to detail pages * nodes: improve charts on detail pages * fix ignored packet debug loggin * run rufo * address review comments --- .gitignore | 3 + data/mesh_ingestor/handlers.py | 4 +- tests/test_mesh.py | 2 +- web/lib/potato_mesh/application/helpers.rb | 31 +++++++ .../potato_mesh/application/routes/root.rb | 12 +-- .../assets/js/app/__tests__/node-page.test.js | 16 ++-- .../__tests__/short-info-telemetry.test.js | 2 +- web/public/assets/js/app/main.js | 85 ++++++++++++++++--- .../assets/js/app/map-marker-node-info.js | 2 +- web/public/assets/js/app/node-details.js | 7 +- web/public/assets/js/app/node-page.js | 75 ++++++++++++---- .../assets/js/app/short-info-telemetry.js | 2 +- web/public/assets/styles/base.css | 39 ++++++--- 13 files changed, 213 insertions(+), 67 deletions(-) diff --git a/.gitignore b/.gitignore index 8773115..d87c914 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,6 @@ web/.config # JavaScript dependencies node_modules/ web/node_modules/ + +# Debug symbols +ignored.txt diff --git a/data/mesh_ingestor/handlers.py b/data/mesh_ingestor/handlers.py index d27f95e..10fab90 100644 --- a/data/mesh_ingestor/handlers.py +++ b/data/mesh_ingestor/handlers.py @@ -29,7 +29,7 @@ from pathlib import Path from . import channels, config, queue -_IGNORED_PACKET_LOG_PATH = Path(__file__).resolve().parents[2] / "ingored.txt" +_IGNORED_PACKET_LOG_PATH = Path(__file__).resolve().parents[2] / "ignored.txt" """Filesystem path that stores ignored packets when debugging.""" _IGNORED_PACKET_LOCK = threading.Lock() @@ -52,7 +52,7 @@ def _ignored_packet_default(value: object) -> object: def _record_ignored_packet(packet: Mapping | object, *, reason: str) -> None: - """Persist packet details to :data:`ingored.txt` during debugging.""" + """Persist packet details to :data:`ignored.txt` during debugging.""" if not config.DEBUG: return diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 392191a..8714276 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -2331,7 +2331,7 @@ def test_store_packet_dict_records_ignored_packets(mesh_module, monkeypatch, tmp mesh = mesh_module monkeypatch.setattr(mesh, "DEBUG", True) - ignored_path = tmp_path / "ingored.txt" + ignored_path = tmp_path / "ignored.txt" monkeypatch.setattr(mesh.handlers, "_IGNORED_PACKET_LOG_PATH", ignored_path) monkeypatch.setattr(mesh.handlers, "_IGNORED_PACKET_LOCK", threading.Lock()) diff --git a/web/lib/potato_mesh/application/helpers.rb b/web/lib/potato_mesh/application/helpers.rb index 3ea93ab..af51b91 100644 --- a/web/lib/potato_mesh/application/helpers.rb +++ b/web/lib/potato_mesh/application/helpers.rb @@ -158,6 +158,37 @@ module PotatoMesh PotatoMesh::Meta.formatted_distance_km(distance) end + # Build the canonical node detail path for the supplied identifier. + # + # @param identifier [String, nil] node identifier in ``!xxxx`` notation. + # @return [String, nil] detail path including the canonical ``!`` prefix. + def node_detail_path(identifier) + ident = string_or_nil(identifier) + return nil unless ident && !ident.empty? + trimmed = ident.strip + return nil if trimmed.empty? + body = trimmed.start_with?("!") ? trimmed[1..-1] : trimmed + return nil unless body && !body.empty? + escaped = Rack::Utils.escape_path(body) + "/nodes/!#{escaped}" + end + + # Render a linked long name pointing to the node detail page. + # + # @param long_name [String] display name for the node. + # @param identifier [String, nil] canonical node identifier. + # @param css_class [String, nil] optional CSS class applied to the anchor. + # @return [String] escaped HTML snippet. + def node_long_name_link(long_name, identifier, css_class: "node-long-link") + text = string_or_nil(long_name) + return "" unless text + href = node_detail_path(identifier) + escaped_text = Rack::Utils.escape_html(text) + return escaped_text unless href + class_attr = css_class ? %( class="#{css_class}") : "" + %(#{escaped_text}) + end + # Generate the meta description used in SEO tags. # # @return [String] combined descriptive sentence. diff --git a/web/lib/potato_mesh/application/routes/root.rb b/web/lib/potato_mesh/application/routes/root.rb index 81a5189..555c5ad 100644 --- a/web/lib/potato_mesh/application/routes/root.rb +++ b/web/lib/potato_mesh/application/routes/root.rb @@ -173,18 +173,14 @@ module PotatoMesh render_root_view(:index, view_mode: :dashboard) end - app.get "/map" do + app.get %r{/map/?} do render_root_view(:map, view_mode: :map) end - app.get "/chat" do + app.get %r{/chat/?} do render_root_view(:chat, view_mode: :chat) end - app.get "/nodes" do - render_root_view(:nodes, view_mode: :nodes) - end - app.get "/nodes/:id" do node_ref = params.fetch("id", nil) reference_payload = build_node_detail_reference(node_ref) @@ -209,6 +205,10 @@ module PotatoMesh ) end + app.get %r{/nodes/?} do + render_root_view(:nodes, view_mode: :nodes) + end + app.get "/metrics" do content_type ::Prometheus::Client::Formats::Text::CONTENT_TYPE ::Prometheus::Client::Formats::Text.marshal(::Prometheus::Client.registry) 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 68cc3e5..0d40f6f 100644 --- a/web/public/assets/js/app/__tests__/node-page.test.js +++ b/web/public/assets/js/app/__tests__/node-page.test.js @@ -66,7 +66,7 @@ test('format helpers normalise values as expected', () => { assert.equal(padTwo(3), '03'); assert.equal(normalizeNodeId('!NODE'), '!node'); const messageTimestamp = formatMessageTimestamp(1_700_000_000); - assert.equal(messageTimestamp.startsWith('2023-'), true); + assert.match(messageTimestamp, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); }); test('role lookup helpers normalise identifiers and register candidates', () => { @@ -144,7 +144,7 @@ test('additional format helpers provide table friendly output', () => { ); assert.equal(messagesHtml.includes('hello'), true); assert.equal(messagesHtml.includes('😊'), true); - assert.equal(messagesHtml.includes('[2023-'), true); + assert.match(messagesHtml, /\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]\[868\]/); assert.equal(messagesHtml.includes('[868]'), true); assert.equal(messagesHtml.includes('[MF]'), true); assert.equal(messagesHtml.includes('[Primary]'), true); @@ -235,7 +235,7 @@ test('renderSingleNodeTable renders a condensed table for the node', () => { battery: 66, voltage: 4.12, uptime: 3_700, - channel: 1.23, + channel_utilization: 1.23, airUtil: 0.45, temperature: 22.5, humidity: 55.5, @@ -253,7 +253,7 @@ test('renderSingleNodeTable renders a condensed table for the node', () => { 10_000, ); assert.equal(html.includes('Example Node<\/a>/); assert.equal(html.includes('66.0%'), true); assert.equal(html.includes('1.230%'), true); assert.equal(html.includes('52.52000'), true); @@ -304,7 +304,7 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis }; 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')}`; + 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); @@ -312,7 +312,7 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis 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('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); @@ -348,10 +348,10 @@ test('renderNodeDetailHtml composes the table, neighbors, and messages', () => { assert.equal(html.includes('Heard by'), true); assert.equal(html.includes('We hear'), true); assert.equal(html.includes('Messages'), true); - assert.equal(html.includes('Example Node'), true); + assert.match(html, /Example Node<\/a>/); assert.equal(html.includes('PEER'), true); assert.equal(html.includes('ALLY'), true); - assert.equal(html.includes('[2023'), true); + assert.match(html, /\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]\[/); assert.equal(html.includes('data-role="CLIENT"'), true); }); diff --git a/web/public/assets/js/app/__tests__/short-info-telemetry.test.js b/web/public/assets/js/app/__tests__/short-info-telemetry.test.js index ed48813..87a2a4d 100644 --- a/web/public/assets/js/app/__tests__/short-info-telemetry.test.js +++ b/web/public/assets/js/app/__tests__/short-info-telemetry.test.js @@ -89,7 +89,7 @@ test('collectTelemetryMetrics prefers latest nested telemetry values over stale air_util_tx: 0.0091, }, telemetry: { - channel: 0.563, + channel_utilization: 0.563, }, raw: { device_metrics: { diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index bd79462..d9b23e8 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -1872,9 +1872,9 @@ let messagesById = new Map(); */ function buildMapPopupHtml(node, nowSec) { const lines = []; - const longName = node && node.long_name ? escapeHtml(String(node.long_name)) : ''; - if (longName) { - lines.push(`${longName}`); + const longNameLink = renderNodeLongNameLink(node?.long_name, node?.node_id); + if (longNameLink) { + lines.push(`${longNameLink}`); } const shortHtml = renderShortHtml(node?.short_name, node?.role, node?.long_name, node); @@ -2083,7 +2083,16 @@ let messagesById = new Map(); if (!target) return; const normalized = normalizeOverlaySource(info || {}); const heading = normalized.longName || normalized.shortName || normalized.nodeId || ''; - const headingHtml = heading ? `${escapeHtml(heading)}
` : ''; + let headingHtml = ''; + if (normalized.longName) { + const link = renderNodeLongNameLink(normalized.longName, normalized.nodeId); + if (link) { + headingHtml = `${link}
`; + } + } + if (!headingHtml && heading) { + headingHtml = `${escapeHtml(heading)}
`; + } overlayStack.render(target, `${headingHtml}Loading…`); } @@ -2101,9 +2110,14 @@ let messagesById = new Map(); overlayInfo.role = 'CLIENT'; } const lines = []; - const longNameValue = shortInfoValueOrDash(overlayInfo.longName ?? ''); - if (longNameValue !== '—') { - lines.push(`${escapeHtml(longNameValue)}`); + const longNameLink = renderNodeLongNameLink(overlayInfo.longName, overlayInfo.nodeId); + if (longNameLink) { + lines.push(`${longNameLink}`); + } else { + const longNameValue = shortInfoValueOrDash(overlayInfo.longName ?? ''); + if (longNameValue !== '—') { + lines.push(`${escapeHtml(longNameValue)}`); + } } const shortParts = []; const shortHtml = renderShortHtml(overlayInfo.shortName, overlayInfo.role, overlayInfo.longName); @@ -2175,9 +2189,16 @@ let messagesById = new Map(); const sourceIdText = shortInfoValueOrDash(segment.sourceId || ''); const neighborFullName = shortInfoValueOrDash(segment.targetDisplayName || segment.targetId || ''); const lines = []; - lines.push(`${escapeHtml(nodeName)}`); + const sourceLongLink = renderNodeLongNameLink(segment.sourceDisplayName, segment.sourceId); + if (sourceLongLink) { + lines.push(`${sourceLongLink}`); + } else { + lines.push(`${escapeHtml(nodeName)}`); + } lines.push(`${sourceShortHtml} ${escapeHtml(sourceIdText)}`); - const neighborLine = `${targetShortHtml} [${escapeHtml(neighborFullName)}]`; + const neighborLongLink = renderNodeLongNameLink(segment.targetDisplayName, segment.targetId); + const neighborLabel = neighborLongLink || escapeHtml(neighborFullName); + const neighborLine = `${targetShortHtml} [${neighborLabel}]`; lines.push(neighborLine); lines.push(`SNR: ${escapeHtml(snrText)}`); overlayStack.render(target, lines.join('
')); @@ -2220,6 +2241,8 @@ let messagesById = new Map(); const fallbackId = nodeIdRaw || 'Unknown node'; const longNameRaw = pickFirstProperty([node], ['long_name', 'longName']); const longNameDisplay = longNameRaw ? String(longNameRaw) : fallbackId; + const longNameLink = renderNodeLongNameLink(longNameRaw, nodeIdRaw); + const announcementName = longNameLink || escapeHtml(longNameDisplay); const shortNameRaw = pickFirstProperty([node], ['short_name', 'shortName']); const shortNameDisplay = shortNameRaw ? String(shortNameRaw) : (nodeIdRaw ? nodeIdRaw.slice(-4) : null); const roleDisplay = pickFirstProperty([node], ['role']); @@ -2233,7 +2256,7 @@ let messagesById = new Map(); role: roleDisplay, metadataSource: node, nodeData: node, - messageHtml: `${renderEmojiHtml('☀️')} ${renderAnnouncementCopy(`New node: ${longNameDisplay}`)}` + messageHtml: `${renderEmojiHtml('☀️')} ${renderAnnouncementCopy('New node:', ` ${announcementName}`)}` }); } @@ -2987,6 +3010,41 @@ let messagesById = new Map(); return str.length ? str : ''; } + /** + * Compute the node detail path for a given identifier. + * + * @param {string|null} identifier Node identifier. + * @returns {string|null} Detail path. + */ + function buildNodeDetailHref(identifier) { + if (identifier == null) return null; + const trimmed = String(identifier).trim(); + if (!trimmed) return null; + const body = trimmed.startsWith('!') ? trimmed.slice(1) : trimmed; + if (!body) return null; + const encoded = encodeURIComponent(body); + return `/nodes/!${encoded}`; + } + + /** + * Render a linked long name pointing to the node detail view. + * + * @param {string|null} longName Display name. + * @param {string|null} identifier Node identifier. + * @param {string} [className='node-long-link'] Optional class attribute. + * @returns {string} Escaped HTML snippet. + */ + function renderNodeLongNameLink(longName, identifier, className = 'node-long-link') { + const text = normalizeNodeNameValue(longName); + if (!text) return ''; + const href = buildNodeDetailHref(identifier); + if (!href) { + return escapeHtml(text); + } + const classAttr = className ? ` class="${escapeHtml(className)}"` : ''; + return `${escapeHtml(text)}
`; + } + /** * Determine the preferred display name for overlay content. * @@ -3401,11 +3459,12 @@ let messagesById = new Map(); const lastPositionCell = lastPositionTime != null ? timeAgo(lastPositionTime, nowSec) : ''; const latitudeDisplay = fmtCoords(n.latitude); const longitudeDisplay = fmtCoords(n.longitude); - const nodeDisplayName = getNodeDisplayNameForOverlay(n); - tr.innerHTML = ` + const nodeDisplayName = getNodeDisplayNameForOverlay(n); + const longNameHtml = renderNodeLongNameLink(n.long_name, n.node_id); + tr.innerHTML = ` ${n.node_id || ""} ${renderShortHtml(n.short_name, n.role, n.long_name, n)} - ${n.long_name || ""} + ${longNameHtml} ${timeAgo(n.last_heard, nowSec)} ${n.role || "CLIENT"} ${fmtHw(n.hw_model)} diff --git a/web/public/assets/js/app/map-marker-node-info.js b/web/public/assets/js/app/map-marker-node-info.js index 94c62a2..f045734 100644 --- a/web/public/assets/js/app/map-marker-node-info.js +++ b/web/public/assets/js/app/map-marker-node-info.js @@ -118,7 +118,7 @@ export function overlayToPopupNode(source) { battery_level: toFiniteNumber(source.battery ?? source.battery_level), voltage: toFiniteNumber(source.voltage), uptime_seconds: toFiniteNumber(source.uptime ?? source.uptime_seconds), - channel_utilization: toFiniteNumber(source.channel ?? source.channel_utilization), + channel_utilization: toFiniteNumber(source.channel_utilization ?? source.channelUtilization), air_util_tx: toFiniteNumber(source.airUtil ?? source.air_util_tx), temperature: toFiniteNumber(source.temperature), relative_humidity: toFiniteNumber(source.humidity ?? source.relative_humidity), diff --git a/web/public/assets/js/app/node-details.js b/web/public/assets/js/app/node-details.js index f2f0ee3..ca61690 100644 --- a/web/public/assets/js/app/node-details.js +++ b/web/public/assets/js/app/node-details.js @@ -183,7 +183,7 @@ function mergeNodeFields(target, record) { assignNumber(target, 'battery', extractNumber(record, ['battery', 'battery_level', 'batteryLevel'])); assignNumber(target, 'voltage', extractNumber(record, ['voltage'])); assignNumber(target, 'uptime', extractNumber(record, ['uptime', 'uptime_seconds', 'uptimeSeconds'])); - assignNumber(target, 'channel', extractNumber(record, ['channel', 'channel_utilization', 'channelUtilization'])); + assignNumber(target, 'channel', extractNumber(record, ['channel_utilization', 'channelUtilization'])); assignNumber(target, 'airUtil', extractNumber(record, ['airUtil', 'air_util_tx', 'airUtilTx'])); assignNumber(target, 'temperature', extractNumber(record, ['temperature'])); assignNumber(target, 'humidity', extractNumber(record, ['humidity', 'relative_humidity', 'relativeHumidity'])); @@ -214,7 +214,7 @@ function mergeTelemetry(target, telemetry) { assignNumber(target, 'battery', extractNumber(telemetry, ['battery_level', 'batteryLevel']), { preferExisting: true }); assignNumber(target, 'voltage', extractNumber(telemetry, ['voltage']), { preferExisting: true }); assignNumber(target, 'uptime', extractNumber(telemetry, ['uptime_seconds', 'uptimeSeconds']), { preferExisting: true }); - assignNumber(target, 'channel', extractNumber(telemetry, ['channel', 'channel_utilization', 'channelUtilization']), { preferExisting: true }); + assignNumber(target, 'channel', extractNumber(telemetry, ['channel_utilization', 'channelUtilization']), { preferExisting: true }); assignNumber(target, 'airUtil', extractNumber(telemetry, ['air_util_tx', 'airUtilTx', 'airUtil']), { preferExisting: true }); assignNumber(target, 'temperature', extractNumber(telemetry, ['temperature']), { preferExisting: true }); assignNumber(target, 'humidity', extractNumber(telemetry, ['relative_humidity', 'relativeHumidity', 'humidity']), { preferExisting: true }); @@ -398,7 +398,7 @@ export async function refreshNodeInformation(reference, options = {}) { const nodeRecordEntry = aggregatedNodeRecords[0] ?? null; const telemetryCandidates = Array.isArray(telemetryRecords) - ? telemetryRecords + ? telemetryRecords.filter(isObject) : (isObject(telemetryRecords) ? [telemetryRecords] : []); const aggregatedTelemetry = aggregateTelemetrySnapshots(telemetryCandidates); const telemetryEntry = aggregatedTelemetry[0] ?? null; @@ -448,6 +448,7 @@ export async function refreshNodeInformation(reference, options = {}) { node.rawSources = { node: nodeRecordEntry, telemetry: telemetryEntry, + telemetrySnapshots: telemetryCandidates, position: positionEntry, neighbors: neighborEntries, }; diff --git a/web/public/assets/js/app/node-page.js b/web/public/assets/js/app/node-page.js index 3c078af..0f91feb 100644 --- a/web/public/assets/js/app/node-page.js +++ b/web/public/assets/js/app/node-page.js @@ -36,8 +36,8 @@ 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 }); +const DEFAULT_CHART_DIMENSIONS = Object.freeze({ width: 660, height: 360 }); +const DEFAULT_CHART_MARGIN = Object.freeze({ top: 28, right: 80, bottom: 64, left: 80 }); /** * Telemetry chart definitions describing axes and series metadata. * @@ -95,7 +95,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([ { id: 'channel', position: 'left', - label: 'Utilization', + label: 'Utilization (%)', min: 0, max: 100, ticks: 4, @@ -109,7 +109,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([ color: '#2ca25f', label: 'Channel util', legend: 'Channel utilization (%)', - fields: ['channel', 'channel_utilization', 'channelUtilization'], + fields: ['channel_utilization', 'channelUtilization'], valueFormatter: value => `${value.toFixed(1)}%`, }, { @@ -250,6 +250,42 @@ function escapeHtml(input) { .replace(/'/g, '''); } +/** + * Build a canonical node detail path for hyperlinking long names. + * + * @param {string|null} identifier Node identifier. + * @returns {string|null} Node detail path. + */ +function buildNodeDetailHref(identifier) { + const value = stringOrNull(identifier); + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const body = trimmed.startsWith('!') ? trimmed.slice(1) : trimmed; + if (!body) return null; + const encoded = encodeURIComponent(body); + return `/nodes/!${encoded}`; +} + +/** + * Render a linked long name pointing to the node detail page. + * + * @param {string|null} longName Long name text. + * @param {string|null} identifier Node identifier. + * @param {{ className?: string }} [options] Rendering options. + * @returns {string} Escaped HTML string. + */ +function renderNodeLongNameLink(longName, identifier, { className = 'node-long-link' } = {}) { + const text = stringOrNull(longName); + if (!text) return ''; + const href = buildNodeDetailHref(identifier); + if (!href) { + return escapeHtml(text); + } + const classAttr = className ? ` class="${escapeHtml(className)}"` : ''; + return `${escapeHtml(text)}`; +} + /** * Format a frequency value using MHz units when a numeric reading is * available. Non-numeric input is passed through unchanged. @@ -351,7 +387,8 @@ function padTwo(value) { } /** - * Format a timestamp for the message log using ``YYYY-MM-DD HH:MM:SS``. + * Format a timestamp for the message log using ``YYYY-MM-DD HH:MM`` in the + * local time zone. * * @param {*} value Seconds since the epoch. * @param {string|null} isoFallback ISO timestamp to prefer when available. @@ -380,8 +417,7 @@ function formatMessageTimestamp(value, isoFallback = null) { const day = padTwo(date.getDate()); const hours = padTwo(date.getHours()); const minutes = padTwo(date.getMinutes()); - const seconds = padTwo(date.getSeconds()); - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + return `${year}-${month}-${day} ${hours}:${minutes}`; } /** @@ -574,7 +610,7 @@ function hexToRgba(hex, alpha = 1) { } /** - * Format a timestamp as ``YYYY-MM-DD`` using the local time zone. + * Format a timestamp as a day-of-month string using the local time zone. * * @param {number} timestampMs Timestamp expressed in milliseconds. * @returns {string} Compact date string. @@ -582,10 +618,8 @@ function hexToRgba(hex, alpha = 1) { 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}`; + return day; } /** @@ -909,7 +943,7 @@ function renderTelemetrySeries(seriesConfig, points, axis, dims, domainStart, do const tooltip = formatSeriesPointValue(seriesConfig, point.value); const titleMarkup = tooltip ? `${escapeHtml(tooltip)}` : ''; circles.push( - ``, + ``, ); return { cx, cy }; }); @@ -918,7 +952,7 @@ function renderTelemetrySeries(seriesConfig, points, axis, dims, domainStart, do const path = coordinates .map((coord, idx) => `${idx === 0 ? 'M' : 'L'}${coord.cx.toFixed(2)} ${coord.cy.toFixed(2)}`) .join(' '); - line = ``; + line = ``; } return `${line}${circles.join('')}`; } @@ -1068,7 +1102,12 @@ function renderTelemetryChart(spec, entries, nowMs) { */ function renderTelemetryCharts(node, { nowMs = Date.now() } = {}) { const telemetrySource = node?.rawSources?.telemetry; - const rawSnapshots = telemetrySource?.snapshots; + const snapshotFallback = Array.isArray(node?.rawSources?.telemetrySnapshots) + ? node.rawSources.telemetrySnapshots + : null; + const rawSnapshots = Array.isArray(telemetrySource?.snapshots) + ? telemetrySource.snapshots + : snapshotFallback; if (!Array.isArray(rawSnapshots) || rawSnapshots.length === 0) { return ''; } @@ -1745,7 +1784,8 @@ function renderSingleNodeTable(node, renderShortHtml, referenceSeconds = Date.no } const nodeId = stringOrNull(node.nodeId ?? node.node_id) ?? ''; const shortName = stringOrNull(node.shortName ?? node.short_name) ?? null; - const longName = stringOrNull(node.longName ?? node.long_name) ?? ''; + const longName = stringOrNull(node.longName ?? node.long_name); + const longNameLink = renderNodeLongNameLink(longName, nodeId); const role = stringOrNull(node.role) ?? 'CLIENT'; const numericId = numberOrNull(node.nodeNum ?? node.node_num ?? node.num); const badgeSource = node.rawSources?.node && typeof node.rawSources.node === 'object' @@ -1763,7 +1803,8 @@ function renderSingleNodeTable(node, renderShortHtml, referenceSeconds = Date.no const battery = formatBattery(node.battery ?? node.battery_level); const voltage = formatVoltage(node.voltage ?? node.voltageReading); const uptime = formatDurationSeconds(node.uptime ?? node.uptime_seconds ?? node.uptimeSeconds); - const channel = fmtTx(node.channel ?? node.channel_utilization ?? node.channelUtilization ?? null, 3); + const channelUtil = node.channel_utilization ?? node.channelUtilization ?? null; + const channel = fmtTx(channelUtil, 3); const airUtil = fmtTx(node.airUtil ?? node.air_util_tx ?? node.airUtilTx ?? null, 3); const temperature = fmtTemperature(node.temperature ?? node.temp); const humidity = fmtHumidity(node.humidity ?? node.relative_humidity ?? node.relativeHumidity); @@ -1803,7 +1844,7 @@ function renderSingleNodeTable(node, renderShortHtml, referenceSeconds = Date.no ${escapeHtml(nodeId)} ${badgeHtml} - ${escapeHtml(longName)} + ${longNameLink} ${escapeHtml(lastSeen)} ${escapeHtml(role)} ${escapeHtml(hardware)} diff --git a/web/public/assets/js/app/short-info-telemetry.js b/web/public/assets/js/app/short-info-telemetry.js index ad94f9a..97cfc8f 100644 --- a/web/public/assets/js/app/short-info-telemetry.js +++ b/web/public/assets/js/app/short-info-telemetry.js @@ -280,7 +280,7 @@ export const TELEMETRY_FIELDS = [ { key: 'channel', label: 'Channel Util', - sources: ['channel_utilization', 'channelUtilization', 'channel'], + sources: ['channel_utilization', 'channelUtilization'], formatter: value => fmtTx(value), }, { diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index 24d7d64..dc32ef4 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -819,18 +819,18 @@ body.view-map .map-panel--full #map { .node-detail__charts-grid { display: grid; - gap: 16px; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 640px), 1fr)); } .node-detail__chart { border: 1px solid var(--line); border-radius: 10px; background: var(--card); - padding: 12px; + padding: 18px 20px 20px; display: flex; flex-direction: column; - gap: 8px; + gap: 12px; } .node-detail__chart-header { @@ -838,24 +838,24 @@ body.view-map .map-panel--full #map { align-items: baseline; justify-content: space-between; gap: 10px; - font-size: 0.9rem; + font-size: 1rem; margin: 0; } .node-detail__chart-header h4 { margin: 0; - font-size: 1rem; + font-size: 1.2rem; } .node-detail__chart-header span { color: var(--muted); - font-size: 0.85rem; + font-size: 1rem; } .node-detail__chart svg { width: 100%; height: auto; - max-height: 320px; + max-height: 420px; } .node-detail__chart-axis line { @@ -866,7 +866,7 @@ body.view-map .map-panel--full #map { .node-detail__chart-axis text, .node-detail__chart-axis-label { fill: var(--muted); - font-size: 0.75rem; + font-size: 0.95rem; } .node-detail__chart-grid-line { @@ -885,19 +885,19 @@ body.dark .node-detail__chart-grid-line { .node-detail__chart-legend { display: flex; flex-wrap: wrap; - gap: 10px; + gap: 12px 18px; } .node-detail__chart-legend-item { display: flex; align-items: center; gap: 6px; - font-size: 0.85rem; + font-size: 1rem; } .node-detail__chart-legend-swatch { - width: 10px; - height: 10px; + width: 14px; + height: 14px; border-radius: 999px; flex: 0 0 auto; } @@ -910,7 +910,7 @@ body.dark .node-detail__chart-grid-line { .node-detail__chart-legend-text small { color: var(--muted); - font-size: 0.75rem; + font-size: 0.9rem; } .node-detail__identifier { @@ -918,6 +918,17 @@ body.dark .node-detail__chart-grid-line { color: var(--muted); } +.node-long-link { + color: inherit; + text-decoration: underline; + text-decoration-thickness: 1px; +} + +.node-long-link:focus, +.node-long-link:hover { + text-decoration-thickness: 2px; +} + .node-detail__status, .node-detail__error, .node-detail__noscript {