diff --git a/web/lib/potato_mesh/application/routes/root.rb b/web/lib/potato_mesh/application/routes/root.rb index ba493ca..81a5189 100644 --- a/web/lib/potato_mesh/application/routes/root.rb +++ b/web/lib/potato_mesh/application/routes/root.rb @@ -42,33 +42,107 @@ module PotatoMesh # # @param template [Symbol] identifier for the ERB template. # @param view_mode [Symbol, String] logical view identifier for CSS hooks. + # @param extra_locals [Hash] additional locals merged into the rendering context. # @return [String] rendered ERB output. - def render_root_view(template, view_mode: :dashboard) + def render_root_view(template, view_mode: :dashboard, extra_locals: {}) meta = meta_configuration config = frontend_app_config theme = resolve_initial_theme view_mode_sym = view_mode.respond_to?(:to_sym) ? view_mode.to_sym : view_mode - erb template, layout: :"layouts/app", locals: { - site_name: meta[:name], - meta_title: meta[:title], - meta_name: meta[:name], - meta_description: meta[:description], - channel: sanitized_channel, - frequency: sanitized_frequency, - map_center_lat: PotatoMesh::Config.map_center_lat, - map_center_lon: PotatoMesh::Config.map_center_lon, - max_distance_km: PotatoMesh::Config.max_distance_km, - contact_link: sanitized_contact_link, - contact_link_url: sanitized_contact_link_url, - version: app_constant(:APP_VERSION), - private_mode: private_mode?, - federation_enabled: federation_enabled?, - refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds, - app_config_json: JSON.generate(config), - initial_theme: theme, - current_view_mode: view_mode_sym, - } + base_locals = { + site_name: meta[:name], + meta_title: meta[:title], + meta_name: meta[:name], + meta_description: meta[:description], + channel: sanitized_channel, + frequency: sanitized_frequency, + map_center_lat: PotatoMesh::Config.map_center_lat, + map_center_lon: PotatoMesh::Config.map_center_lon, + max_distance_km: PotatoMesh::Config.max_distance_km, + contact_link: sanitized_contact_link, + contact_link_url: sanitized_contact_link_url, + version: app_constant(:APP_VERSION), + private_mode: private_mode?, + federation_enabled: federation_enabled?, + refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds, + app_config_json: JSON.generate(config), + initial_theme: theme, + current_view_mode: view_mode_sym, + } + sanitized_locals = extra_locals.is_a?(Hash) ? extra_locals : {} + merged_locals = base_locals.merge(sanitized_locals) + + erb template, layout: :"layouts/app", locals: merged_locals + end + + # Remove keys with +nil+ values from the provided hash, returning a + # shallow copy. Hash#compact is only available in newer Ruby + # versions; this helper keeps behaviour consistent across supported + # releases. + # + # @param value [Hash, nil] collection subject to filtering. + # @return [Hash] hash excluding +nil+ values. + def reject_nil_values(value) + return {} unless value.is_a?(Hash) + + value.each_with_object({}) do |(key, entry), memo| + memo[key] = entry unless entry.nil? + end + end + + # Assemble the payload embedded into the node detail view. The + # payload provides a canonical identifier alongside any cached node, + # telemetry, or position rows that may already exist in the + # database. When no persisted data is available the method returns + # +nil+ so the caller can surface a 404 error. + # + # @param node_ref [Object] raw node identifier from the request. + # @return [Hash, nil] structured node reference payload or nil when + # the node cannot be located. + def build_node_detail_reference(node_ref) + tokens = canonical_node_parts(node_ref) + search_ref = tokens ? tokens.first : node_ref + + node_row = query_nodes(1, node_ref: search_ref).first + telemetry_row = query_telemetry(1, node_ref: search_ref).first + position_row = query_positions(1, node_ref: search_ref).first + + candidates = [node_row, telemetry_row, position_row].compact + return nil if candidates.empty? + + canonical_id = string_or_nil(node_row&.fetch("node_id", nil)) + canonical_id ||= string_or_nil(telemetry_row&.fetch("node_id", nil)) + canonical_id ||= string_or_nil(position_row&.fetch("node_id", nil)) + canonical_id ||= string_or_nil(tokens&.fetch(0, nil)) + if canonical_id + canonical_id = canonical_id.start_with?("!") ? canonical_id : "!#{canonical_id}" + end + return nil unless canonical_id + + numeric_id = coerce_integer(node_row&.fetch("num", nil)) + numeric_id ||= coerce_integer(telemetry_row&.fetch("node_num", nil)) + numeric_id ||= coerce_integer(position_row&.fetch("node_num", nil)) + numeric_id ||= tokens&.fetch(1, nil) + + short_id = string_or_nil(node_row&.fetch("short_name", nil)) + short_id ||= string_or_nil(telemetry_row&.fetch("short_name", nil)) + short_id ||= string_or_nil(position_row&.fetch("short_name", nil)) + short_id ||= tokens&.fetch(2, nil) + + fallback_row = node_row || telemetry_row || position_row + fallback = fallback_row ? compact_api_row(fallback_row) : nil + telemetry = telemetry_row ? compact_api_row(telemetry_row) : nil + position = position_row ? compact_api_row(position_row) : nil + + { + "nodeId" => canonical_id, + "nodeNum" => numeric_id, + "shortId" => short_id, + "fallback" => fallback, + "telemetry" => telemetry, + "position" => position, + } end end @@ -111,6 +185,30 @@ module PotatoMesh 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) + halt 404, "Not Found" unless reference_payload + + fallback = reference_payload["fallback"] || {} + short_name = string_or_nil(fallback["short_name"]) || reference_payload["shortId"] + long_name = string_or_nil(fallback["long_name"]) + role = string_or_nil(fallback["role"]) + canonical_id = string_or_nil(reference_payload["nodeId"]) + + render_root_view( + :node_detail, + view_mode: :node_detail, + extra_locals: { + node_reference_json: JSON.generate(reject_nil_values(reference_payload)), + node_page_short_name: short_name, + node_page_long_name: long_name, + node_page_role: role, + node_page_identifier: canonical_id, + }, + ) + 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 new file mode 100644 index 0000000..f657be2 --- /dev/null +++ b/web/public/assets/js/app/__tests__/node-page.test.js @@ -0,0 +1,334 @@ +/* + * 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 { initializeNodeDetailPage, __testUtils } from '../node-page.js'; + +const { + stringOrNull, + numberOrNull, + formatFrequency, + formatBattery, + formatVoltage, + formatUptime, + formatTimestamp, + buildConfigurationEntries, + buildTelemetryEntries, + buildPositionEntries, + renderDefinitionList, + renderNeighbors, + renderMessages, + renderNodeDetailHtml, + parseReferencePayload, + resolveRenderShortHtml, + fetchMessages, +} = __testUtils; + +test('format helpers normalise values as expected', () => { + assert.equal(stringOrNull(' foo '), 'foo'); + assert.equal(stringOrNull(''), null); + assert.equal(numberOrNull('42'), 42); + assert.equal(numberOrNull('abc'), null); + assert.equal(formatFrequency(915), '915.000 MHz'); + assert.equal(formatFrequency('2400000'), '2.400 MHz'); + assert.equal(formatFrequency('custom'), 'custom'); + assert.equal(formatBattery(87.135), '87.1%'); + assert.equal(formatVoltage(4.105), '4.11 V'); + assert.equal(formatUptime(3661), '1h 1m 1s'); + assert.match(formatTimestamp(1_700_000_000), /T/); +}); + +test('buildConfigurationEntries collects modem and role details', () => { + const entries = buildConfigurationEntries({ + modemPreset: 'LongFast', + loraFreq: 915, + role: 'ROUTER', + hwModel: 'T-Beam', + nodeNum: 7, + snr: 9.42, + lastHeard: 1_700_000_001, + }); + assert.deepEqual(entries.map(entry => entry.label), [ + 'Modem preset', + 'LoRa frequency', + 'Role', + 'Hardware model', + 'Node number', + 'SNR', + 'Last heard', + ]); +}); + +test('buildTelemetryEntries merges additional metrics', () => { + const entries = buildTelemetryEntries({ + battery: 75.2, + voltage: 4.12, + uptime: 12_345, + channel: 1.23, + airUtil: 0.45, + temperature: 21.5, + humidity: 55.5, + pressure: 1013.4, + telemetry: { + current: 0.53, + gas_resistance: 10_000, + iaq: 42, + distance: 1.23, + lux: 35, + uv_lux: 3.5, + wind_direction: 180, + wind_speed: 2.5, + wind_gust: 4.1, + rainfall_1h: 0.12, + rainfall_24h: 1.02, + telemetry_time: 1_700_000_123, + }, + }); + const labels = entries.map(entry => entry.label); + assert.ok(labels.includes('Battery')); + assert.ok(labels.includes('Voltage')); + assert.ok(labels.includes('Uptime')); + assert.ok(labels.includes('Channel utilisation')); + assert.ok(labels.includes('Air util (TX)')); + assert.ok(labels.includes('Temperature')); + assert.ok(labels.includes('Humidity')); + assert.ok(labels.includes('Pressure')); + assert.ok(labels.includes('Current')); + assert.ok(labels.includes('Gas resistance')); + assert.ok(labels.includes('IAQ')); + assert.ok(labels.includes('Distance')); + assert.ok(labels.includes('Lux')); + assert.ok(labels.includes('UV index')); + assert.ok(labels.includes('Wind direction')); + assert.ok(labels.includes('Wind speed')); + assert.ok(labels.includes('Wind gust')); + assert.ok(labels.includes('Rainfall (1h)')); + assert.ok(labels.includes('Rainfall (24h)')); + assert.ok(labels.includes('Telemetry time')); +}); + +test('buildPositionEntries includes precision metadata', () => { + const entries = buildPositionEntries({ + latitude: 52.52, + longitude: 13.405, + altitude: 42, + position: { + sats_in_view: 12, + precision_bits: 7, + location_source: 'GPS', + position_time: 1_700_000_050, + rx_time: 1_700_000_055, + }, + }); + const labels = entries.map(entry => entry.label); + assert.ok(labels.includes('Latitude')); + assert.ok(labels.includes('Longitude')); + assert.ok(labels.includes('Altitude')); + assert.ok(labels.includes('Satellites')); + assert.ok(labels.includes('Precision bits')); + assert.ok(labels.includes('Location source')); + assert.ok(labels.includes('Position time')); + assert.ok(labels.includes('RX time')); +}); + +test('render helpers ignore empty values', () => { + const listHtml = renderDefinitionList([ + { label: 'Valid', value: 'ok' }, + { label: 'Empty', value: '' }, + ]); + assert.equal(listHtml.includes('Valid'), true); + assert.equal(listHtml.includes('Empty'), false); + + const neighborsHtml = renderNeighbors([ + { neighbor_id: '!ally', snr: 9.5, rx_time: 1_700_000_321 }, + null, + ]); + assert.equal(neighborsHtml.includes('!ally'), true); + + const messagesHtml = renderMessages([ + { text: 'hello', rx_time: 1_700_000_400, from_id: '!src', to_id: '!dst' }, + { emoji: '😊', rx_time: 1_700_000_401 }, + ]); + assert.equal(messagesHtml.includes('hello'), true); + assert.equal(messagesHtml.includes('😊'), true); +}); + +test('renderNodeDetailHtml composes sections when data exists', () => { + const html = renderNodeDetailHtml( + { + shortName: 'NODE', + longName: 'Example Node', + nodeId: '!abcd', + role: 'CLIENT', + modemPreset: 'LongFast', + loraFreq: 915, + battery: 60, + voltage: 4.1, + uptime: 1_000, + temperature: 22, + humidity: 50, + pressure: 1005, + latitude: 52.5, + longitude: 13.4, + altitude: 40, + }, + { + neighbors: [{ neighbor_id: '!ally', snr: 7.5 }], + messages: [{ text: 'Hello', rx_time: 1_700_000_111 }], + renderShortHtml: (short, role) => `${short}`, + }, + ); + assert.equal(html.includes('Configuration'), true); + assert.equal(html.includes('Telemetry'), true); + assert.equal(html.includes('Position'), true); + assert.equal(html.includes('Neighbors'), true); + assert.equal(html.includes('Messages'), true); + assert.equal(html.includes('Example Node'), true); + assert.equal(html.includes('!ally'), true); +}); + +test('parseReferencePayload returns null for invalid JSON', () => { + assert.equal(parseReferencePayload('{'), null); + assert.deepEqual(parseReferencePayload('{"nodeId":"!abc"}'), { nodeId: '!abc' }); +}); + +test('resolveRenderShortHtml prefers global implementation when available', async () => { + const original = globalThis.PotatoMesh; + try { + globalThis.PotatoMesh = { renderShortHtml: () => 'ok' }; + const fn = await resolveRenderShortHtml(); + assert.equal(fn('X'), 'ok'); + } finally { + globalThis.PotatoMesh = original; + } +}); + +test('resolveRenderShortHtml falls back when no implementation is exposed', async () => { + const original = globalThis.PotatoMesh; + try { + delete globalThis.PotatoMesh; + const fn = await resolveRenderShortHtml(); + assert.equal(typeof fn, 'function'); + assert.equal(fn('AB'), 'AB'); + } finally { + globalThis.PotatoMesh = original; + } +}); + +test('fetchMessages handles HTTP responses and uses defaults', async () => { + const calls = []; + const fetchImpl = async (url, options) => { + calls.push({ url, options }); + return { + status: 200, + ok: true, + json: async () => [{ text: 'hi', rx_time: 1 }], + }; + }; + const messages = await fetchMessages('!node', { fetchImpl }); + assert.equal(messages.length, 1); + assert.equal(calls[0].options.cache, 'no-store'); +}); + +test('fetchMessages returns an empty list when the endpoint is missing', async () => { + const fetchImpl = async () => ({ status: 404, ok: false, json: async () => ({}) }); + const messages = await fetchMessages('!node', { fetchImpl }); + assert.deepEqual(messages, []); +}); + +test('initializeNodeDetailPage hydrates the container with node data', async () => { + const element = { + dataset: { + nodeReference: JSON.stringify({ nodeId: '!node', fallback: { short_name: 'NODE' } }), + privateMode: 'false', + }, + innerHTML: '', + }; + const documentStub = { + querySelector: selector => (selector === '#nodeDetail' ? element : null), + }; + const refreshImpl = async reference => { + assert.equal(reference.nodeId, '!node'); + return { + shortName: 'NODE', + longName: 'Node Long', + nodeId: '!node', + role: 'CLIENT', + modemPreset: 'LongFast', + loraFreq: 915, + battery: 66, + voltage: 4.1, + uptime: 100, + latitude: 52.5, + longitude: 13.4, + altitude: 42, + neighbors: [{ neighbor_id: '!ally', snr: 5.5 }], + rawSources: { node: { node_id: '!node', role: 'CLIENT' } }, + }; + }; + const fetchImpl = async () => ({ + status: 200, + ok: true, + json: async () => [{ text: 'hello', rx_time: 1_700_000_222 }], + }); + const renderShortHtml = short => `${short}`; + const result = await initializeNodeDetailPage({ + document: documentStub, + refreshImpl, + fetchImpl, + renderShortHtml, + }); + assert.equal(result, true); + assert.equal(element.innerHTML.includes('Node Long'), true); + assert.equal(element.innerHTML.includes('Neighbors'), true); + assert.equal(element.innerHTML.includes('Messages'), true); +}); + +test('initializeNodeDetailPage reports an error when refresh fails', async () => { + const element = { + dataset: { + nodeReference: JSON.stringify({ nodeId: '!missing' }), + privateMode: 'false', + }, + innerHTML: '', + }; + const documentStub = { querySelector: () => element }; + const refreshImpl = async () => { + throw new Error('boom'); + }; + const renderShortHtml = short => `${short}`; + const result = await initializeNodeDetailPage({ + document: documentStub, + refreshImpl, + renderShortHtml, + }); + assert.equal(result, false); + assert.equal(element.innerHTML.includes('Failed to load'), true); +}); + +test('initializeNodeDetailPage handles missing reference payloads', async () => { + const element = { + dataset: {}, + innerHTML: '', + }; + const documentStub = { querySelector: () => element }; + const renderShortHtml = short => `${short}`; + const result = await initializeNodeDetailPage({ document: documentStub, renderShortHtml }); + assert.equal(result, false); + assert.equal(element.innerHTML.includes('Node reference unavailable'), true); +}); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index ce682d8..e01dd36 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -1729,6 +1729,12 @@ let messagesById = new Map(); return `${padded}`; } + const potatoMeshNamespace = globalThis.PotatoMesh || (globalThis.PotatoMesh = {}); + potatoMeshNamespace.renderShortHtml = renderShortHtml; + potatoMeshNamespace.getRoleColor = getRoleColor; + potatoMeshNamespace.getRoleKey = getRoleKey; + potatoMeshNamespace.normalizeRole = normalizeRole; + /** * Escape a CSS selector fragment with a defensive fallback for * environments lacking ``CSS.escape`` support. diff --git a/web/public/assets/js/app/node-page.js b/web/public/assets/js/app/node-page.js new file mode 100644 index 0000000..e3ec1fe --- /dev/null +++ b/web/public/assets/js/app/node-page.js @@ -0,0 +1,555 @@ +/* + * 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 { refreshNodeInformation } from './node-details.js'; +import { + fmtAlt, + fmtHumidity, + fmtPressure, + fmtTemperature, + fmtTx, + fmtCurrent, + fmtGasResistance, + fmtDistance, + fmtLux, + fmtWindDirection, + fmtWindSpeed, +} from './short-info-telemetry.js'; + +const DEFAULT_FETCH_OPTIONS = Object.freeze({ cache: 'no-store' }); +const MESSAGE_LIMIT = 50; +const RENDER_WAIT_INTERVAL_MS = 20; +const RENDER_WAIT_TIMEOUT_MS = 500; + +/** + * Convert a candidate value into a trimmed string. + * + * @param {*} value Raw value. + * @returns {string|null} Trimmed string or ``null``. + */ +function stringOrNull(value) { + if (value == null) return null; + const str = String(value).trim(); + return str.length === 0 ? null : str; +} + +/** + * Attempt to coerce a value into a finite number. + * + * @param {*} value Raw value. + * @returns {number|null} Finite number or ``null``. + */ +function numberOrNull(value) { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + if (value == null || value === '') return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +/** + * Escape HTML sensitive characters from the provided string. + * + * @param {string} input Raw HTML string. + * @returns {string} Escaped HTML representation. + */ +function escapeHtml(input) { + const str = input == null ? '' : String(input); + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Format a frequency value using MHz units when a numeric reading is + * available. Non-numeric input is passed through unchanged. + * + * @param {*} value Raw frequency value. + * @returns {string|null} Formatted frequency string or ``null``. + */ +function formatFrequency(value) { + if (value == null || value === '') return null; + const numeric = numberOrNull(value); + if (numeric == null) { + return stringOrNull(value); + } + const abs = Math.abs(numeric); + if (abs >= 1_000_000) { + return `${(numeric / 1_000_000).toFixed(3)} MHz`; + } + if (abs >= 1_000) { + return `${(numeric / 1_000).toFixed(3)} MHz`; + } + return `${numeric.toFixed(3)} MHz`; +} + +/** + * Format a battery reading as a percentage with a single decimal place. + * + * @param {*} value Raw battery value. + * @returns {string|null} Formatted percentage or ``null``. + */ +function formatBattery(value) { + const numeric = numberOrNull(value); + if (numeric == null) return null; + return `${numeric.toFixed(1)}%`; +} + +/** + * Format a voltage reading with two decimal places. + * + * @param {*} value Raw voltage value. + * @returns {string|null} Formatted voltage string. + */ +function formatVoltage(value) { + const numeric = numberOrNull(value); + if (numeric == null) return null; + return `${numeric.toFixed(2)} V`; +} + +/** + * Convert an uptime reading in seconds to a concise human-readable string. + * + * @param {*} value Raw uptime value. + * @returns {string|null} Formatted uptime string or ``null`` when invalid. + */ +function formatUptime(value) { + const numeric = numberOrNull(value); + if (numeric == null) return null; + const seconds = Math.floor(numeric); + const parts = []; + const days = Math.floor(seconds / 86_400); + if (days > 0) parts.push(`${days}d`); + const hours = Math.floor((seconds % 86_400) / 3_600); + if (hours > 0) parts.push(`${hours}h`); + const minutes = Math.floor((seconds % 3_600) / 60); + if (minutes > 0) parts.push(`${minutes}m`); + const remainSeconds = seconds % 60; + if (parts.length === 0 || remainSeconds > 0) { + parts.push(`${remainSeconds}s`); + } + return parts.join(' '); +} + +/** + * Format a numeric timestamp expressed in seconds since the epoch. + * + * @param {*} value Raw timestamp value. + * @param {string|null} isoFallback ISO formatted string to prefer. + * @returns {string|null} ISO timestamp string. + */ +function formatTimestamp(value, isoFallback = null) { + const iso = stringOrNull(isoFallback); + if (iso) return iso; + const numeric = numberOrNull(value); + if (numeric == null) return null; + try { + return new Date(numeric * 1000).toISOString(); + } catch (error) { + return null; + } +} + +/** + * Build the configuration definition list entries for the provided node. + * + * @param {Object} node Normalised node payload. + * @returns {Array<{label: string, value: string}>} Configuration entries. + */ +function buildConfigurationEntries(node) { + const entries = []; + if (!node || typeof node !== 'object') return entries; + const modem = stringOrNull(node.modemPreset ?? node.modem_preset); + if (modem) entries.push({ label: 'Modem preset', value: modem }); + const freq = formatFrequency(node.loraFreq ?? node.lora_freq); + if (freq) entries.push({ label: 'LoRa frequency', value: freq }); + const role = stringOrNull(node.role); + if (role) entries.push({ label: 'Role', value: role }); + const hwModel = stringOrNull(node.hwModel ?? node.hw_model); + if (hwModel) entries.push({ label: 'Hardware model', value: hwModel }); + const nodeNum = numberOrNull(node.nodeNum ?? node.node_num ?? node.num); + if (nodeNum != null) entries.push({ label: 'Node number', value: String(nodeNum) }); + const snr = numberOrNull(node.snr); + if (snr != null) entries.push({ label: 'SNR', value: `${snr.toFixed(1)} dB` }); + const lastSeen = formatTimestamp(node.lastHeard, node.lastSeenIso ?? node.last_seen_iso); + if (lastSeen) entries.push({ label: 'Last heard', value: lastSeen }); + return entries; +} + +/** + * Build telemetry entries incorporating additional environmental metrics. + * + * @param {Object} node Normalised node payload. + * @returns {Array<{label: string, value: string}>} Telemetry entries. + */ +function buildTelemetryEntries(node) { + const entries = []; + if (!node || typeof node !== 'object') return entries; + const battery = formatBattery(node.battery ?? node.battery_level); + if (battery) entries.push({ label: 'Battery', value: battery }); + const voltage = formatVoltage(node.voltage); + if (voltage) entries.push({ label: 'Voltage', value: voltage }); + const uptime = formatUptime(node.uptime ?? node.uptime_seconds); + if (uptime) entries.push({ label: 'Uptime', value: uptime }); + const channel = fmtTx(node.channel ?? node.channel_utilization ?? node.channelUtilization ?? null, 3); + if (channel) entries.push({ label: 'Channel utilisation', value: channel }); + const airUtil = fmtTx(node.airUtil ?? node.air_util_tx ?? node.airUtilTx ?? null, 3); + if (airUtil) entries.push({ label: 'Air util (TX)', value: airUtil }); + const temperature = fmtTemperature(node.temperature ?? node.temp); + if (temperature) entries.push({ label: 'Temperature', value: temperature }); + const humidity = fmtHumidity(node.humidity ?? node.relative_humidity ?? node.relativeHumidity); + if (humidity) entries.push({ label: 'Humidity', value: humidity }); + const pressure = fmtPressure(node.pressure ?? node.barometric_pressure ?? node.barometricPressure); + if (pressure) entries.push({ label: 'Pressure', value: pressure }); + + const telemetry = node.telemetry && typeof node.telemetry === 'object' ? node.telemetry : {}; + const current = fmtCurrent(telemetry.current); + if (current) entries.push({ label: 'Current', value: current }); + const gas = fmtGasResistance(telemetry.gas_resistance ?? telemetry.gasResistance); + if (gas) entries.push({ label: 'Gas resistance', value: gas }); + const iaq = numberOrNull(telemetry.iaq); + if (iaq != null) entries.push({ label: 'IAQ', value: String(Math.round(iaq)) }); + const distance = fmtDistance(telemetry.distance); + if (distance) entries.push({ label: 'Distance', value: distance }); + const lux = fmtLux(telemetry.lux); + if (lux) entries.push({ label: 'Lux', value: lux }); + const uv = fmtLux(telemetry.uv_lux ?? telemetry.uvLux); + if (uv) entries.push({ label: 'UV index', value: uv }); + const windDir = fmtWindDirection(telemetry.wind_direction ?? telemetry.windDirection); + if (windDir) entries.push({ label: 'Wind direction', value: windDir }); + const windSpeed = fmtWindSpeed(telemetry.wind_speed ?? telemetry.windSpeed); + if (windSpeed) entries.push({ label: 'Wind speed', value: windSpeed }); + const windGust = fmtWindSpeed(telemetry.wind_gust ?? telemetry.windGust); + if (windGust) entries.push({ label: 'Wind gust', value: windGust }); + const rainfallHour = fmtDistance(telemetry.rainfall_1h ?? telemetry.rainfall1h); + if (rainfallHour) entries.push({ label: 'Rainfall (1h)', value: rainfallHour }); + const rainfallDay = fmtDistance(telemetry.rainfall_24h ?? telemetry.rainfall24h); + if (rainfallDay) entries.push({ label: 'Rainfall (24h)', value: rainfallDay }); + + const telemetryTimestamp = formatTimestamp( + telemetry.telemetry_time ?? node.telemetryTime, + telemetry.telemetry_time_iso ?? node.telemetryTimeIso, + ); + if (telemetryTimestamp) entries.push({ label: 'Telemetry time', value: telemetryTimestamp }); + + return entries; +} + +/** + * Build the positional metadata entries for the provided node. + * + * @param {Object} node Normalised node payload. + * @returns {Array<{label: string, value: string}>} Position entries. + */ +function buildPositionEntries(node) { + const entries = []; + if (!node || typeof node !== 'object') return entries; + const latitude = numberOrNull(node.latitude ?? node.lat); + if (latitude != null) entries.push({ label: 'Latitude', value: latitude.toFixed(6) }); + const longitude = numberOrNull(node.longitude ?? node.lon); + if (longitude != null) entries.push({ label: 'Longitude', value: longitude.toFixed(6) }); + const altitude = fmtAlt(node.altitude ?? node.alt, ' m'); + if (altitude) entries.push({ label: 'Altitude', value: altitude }); + + const position = node.position && typeof node.position === 'object' ? node.position : {}; + const sats = numberOrNull(position.sats_in_view ?? position.satsInView); + if (sats != null) entries.push({ label: 'Satellites', value: String(sats) }); + const precision = numberOrNull(position.precision_bits ?? position.precisionBits); + if (precision != null) entries.push({ label: 'Precision bits', value: String(precision) }); + const source = stringOrNull(position.location_source ?? position.locationSource); + if (source) entries.push({ label: 'Location source', value: source }); + const positionTimestamp = formatTimestamp( + node.positionTime ?? position.position_time, + node.positionTimeIso ?? position.position_time_iso, + ); + if (positionTimestamp) entries.push({ label: 'Position time', value: positionTimestamp }); + const rxTimestamp = formatTimestamp(position.rx_time, position.rx_iso); + if (rxTimestamp) entries.push({ label: 'RX time', value: rxTimestamp }); + return entries; +} + +/** + * Render a definition list as HTML. + * + * @param {Array<{label: string, value: string}>} entries Definition entries. + * @returns {string} HTML string for the definition list. + */ +function renderDefinitionList(entries) { + if (!Array.isArray(entries) || entries.length === 0) { + return ''; + } + const rows = entries + .filter(entry => stringOrNull(entry?.label) && stringOrNull(entry?.value)) + .map(entry => + `
${escapeHtml(entry.label)}
${escapeHtml(entry.value)}
`, + ); + if (rows.length === 0) return ''; + return `
${rows.join('')}
`; +} + +/** + * Render neighbor information as an unordered list. + * + * @param {Array} neighbors Neighbor records. + * @returns {string} HTML string for the neighbor section. + */ +function renderNeighbors(neighbors) { + if (!Array.isArray(neighbors) || neighbors.length === 0) return ''; + const items = neighbors + .map(entry => { + if (!entry || typeof entry !== 'object') return null; + const neighborId = stringOrNull(entry.neighbor_id ?? entry.neighborId ?? entry.node_id ?? entry.nodeId); + const snr = numberOrNull(entry.snr); + const rx = formatTimestamp(entry.rx_time, entry.rx_iso); + const parts = []; + if (neighborId) parts.push(escapeHtml(neighborId)); + if (snr != null) parts.push(`${snr.toFixed(1)} dB`); + if (rx) parts.push(escapeHtml(rx)); + if (parts.length === 0) return null; + return `
  • ${parts.join(' — ')}
  • `; + }) + .filter(item => item != null); + if (items.length === 0) return ''; + return ``; +} + +/** + * Render a message list using basic formatting. + * + * @param {Array} messages Message records. + * @returns {string} HTML string for the messages section. + */ +function renderMessages(messages) { + if (!Array.isArray(messages) || messages.length === 0) return ''; + const items = messages + .map(message => { + if (!message || typeof message !== 'object') return null; + const text = stringOrNull(message.text) || stringOrNull(message.emoji); + if (!text) return null; + const rx = formatTimestamp(message.rx_time, message.rx_iso); + const fromId = stringOrNull(message.from_id ?? message.fromId); + const toId = stringOrNull(message.to_id ?? message.toId); + const parts = []; + if (rx) parts.push(escapeHtml(rx)); + if (fromId || toId) { + const route = [fromId, toId].filter(Boolean).map(escapeHtml).join(' → '); + if (route) parts.push(route); + } + parts.push(escapeHtml(text)); + return `
  • ${parts.join(' — ')}
  • `; + }) + .filter(item => item != null); + if (items.length === 0) return ''; + return ``; +} + +/** + * Render the node detail layout to an HTML fragment. + * + * @param {Object} node Normalised node payload. + * @param {{ + * neighbors?: Array, + * messages?: Array, + * renderShortHtml: Function, + * }} options Rendering options. + * @returns {string} HTML fragment representing the detail view. + */ +function renderNodeDetailHtml(node, { neighbors = [], messages = [], renderShortHtml }) { + const roleAwareBadge = typeof renderShortHtml === 'function' + ? renderShortHtml(node.shortName ?? node.short_name, node.role, node.longName ?? node.long_name, node.rawSources?.node ?? node) + : escapeHtml(node.shortName ?? node.short_name ?? '?'); + const longName = stringOrNull(node.longName ?? node.long_name); + const identifier = stringOrNull(node.nodeId ?? node.node_id); + + const configHtml = renderDefinitionList(buildConfigurationEntries(node)); + const telemetryHtml = renderDefinitionList(buildTelemetryEntries(node)); + const positionHtml = renderDefinitionList(buildPositionEntries(node)); + const neighborsHtml = renderNeighbors(neighbors); + const messagesHtml = renderMessages(messages); + + const sections = []; + if (configHtml) { + sections.push(`

    Configuration

    ${configHtml}
    `); + } + if (telemetryHtml) { + sections.push(`

    Telemetry

    ${telemetryHtml}
    `); + } + if (positionHtml) { + sections.push(`

    Position

    ${positionHtml}
    `); + } + if (Array.isArray(neighbors) && neighbors.length > 0 && neighborsHtml) { + sections.push(`

    Neighbors

    ${neighborsHtml}
    `); + } + if (Array.isArray(messages) && messages.length > 0 && messagesHtml) { + sections.push(`

    Messages

    ${messagesHtml}
    `); + } + + const identifierHtml = identifier ? `[${escapeHtml(identifier)}]` : ''; + const nameHtml = longName ? `${escapeHtml(longName)}` : ''; + + return ` +
    +

    ${roleAwareBadge}${nameHtml}${identifierHtml}

    +
    +
    + ${sections.join('')} +
    + `; +} + +/** + * Parse the serialized reference payload embedded in the DOM. + * + * @param {string} raw Raw JSON string. + * @returns {Object|null} Parsed object or ``null`` when invalid. + */ +function parseReferencePayload(raw) { + const trimmed = stringOrNull(raw); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch (error) { + console.warn('Failed to parse node reference payload', error); + return null; + } +} + +/** + * Resolve the canonical renderShortHtml implementation, waiting briefly for + * the dashboard to expose it when necessary. + * + * @param {Function|undefined} override Explicit override supplied by tests. + * @returns {Promise} Badge rendering implementation. + */ +async function resolveRenderShortHtml(override) { + if (typeof override === 'function') return override; + const deadline = Date.now() + RENDER_WAIT_TIMEOUT_MS; + while (Date.now() < deadline) { + const candidate = globalThis.PotatoMesh?.renderShortHtml; + if (typeof candidate === 'function') { + return candidate; + } + await new Promise(resolve => setTimeout(resolve, RENDER_WAIT_INTERVAL_MS)); + } + return short => `${escapeHtml(short ?? '?')}`; +} + +/** + * Fetch recent messages for a node. Private mode bypasses the request. + * + * @param {string} identifier Canonical node identifier. + * @param {{fetchImpl?: Function, includeEncrypted?: boolean, privateMode?: boolean}} options Fetch options. + * @returns {Promise>} Resolved message collection. + */ +async function fetchMessages(identifier, { fetchImpl, includeEncrypted = false, privateMode = false } = {}) { + if (privateMode) return []; + const fetchFn = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch; + if (typeof fetchFn !== 'function') { + throw new TypeError('A fetch implementation is required to load node messages'); + } + const encodedId = encodeURIComponent(String(identifier)); + const encryptedFlag = includeEncrypted ? '&encrypted=1' : ''; + const url = `/api/messages/${encodedId}?limit=${MESSAGE_LIMIT}${encryptedFlag}`; + const response = await fetchFn(url, DEFAULT_FETCH_OPTIONS); + if (response.status === 404) return []; + if (!response.ok) { + throw new Error(`Failed to load node messages (HTTP ${response.status})`); + } + const payload = await response.json(); + return Array.isArray(payload) ? payload : []; +} + +/** + * Initialise the node detail page by hydrating the DOM with fetched data. + * + * @param {{ + * document?: Document, + * fetchImpl?: Function, + * refreshImpl?: Function, + * renderShortHtml?: Function, + * }} options Optional overrides for testing. + * @returns {Promise} ``true`` when the node was rendered successfully. + */ +export async function initializeNodeDetailPage(options = {}) { + const documentRef = options.document ?? globalThis.document; + if (!documentRef || typeof documentRef.querySelector !== 'function') { + throw new TypeError('A document with querySelector support is required'); + } + const root = documentRef.querySelector('#nodeDetail'); + if (!root) return false; + + const referenceData = parseReferencePayload(root.dataset?.nodeReference ?? null); + if (!referenceData) { + root.innerHTML = '

    Node reference unavailable.

    '; + return false; + } + + const identifier = stringOrNull(referenceData.nodeId) ?? null; + const nodeNum = numberOrNull(referenceData.nodeNum); + if (!identifier && nodeNum == null) { + root.innerHTML = '

    Node identifier missing.

    '; + return false; + } + + const refreshImpl = typeof options.refreshImpl === 'function' ? options.refreshImpl : refreshNodeInformation; + const renderShortHtml = await resolveRenderShortHtml(options.renderShortHtml); + const privateMode = (root.dataset?.privateMode ?? '').toLowerCase() === 'true'; + + try { + const node = await refreshImpl(referenceData, { fetchImpl: options.fetchImpl }); + const messages = await fetchMessages(identifier ?? node.nodeId ?? node.node_id ?? nodeNum, { + fetchImpl: options.fetchImpl, + privateMode, + }); + const html = renderNodeDetailHtml(node, { + neighbors: node.neighbors, + messages, + renderShortHtml, + }); + root.innerHTML = html; + return true; + } catch (error) { + console.error('Failed to render node detail page', error); + root.innerHTML = '

    Failed to load node details.

    '; + return false; + } +} + +export const __testUtils = { + stringOrNull, + numberOrNull, + escapeHtml, + formatFrequency, + formatBattery, + formatVoltage, + formatUptime, + formatTimestamp, + buildConfigurationEntries, + buildTelemetryEntries, + buildPositionEntries, + renderDefinitionList, + renderNeighbors, + renderMessages, + renderNodeDetailHtml, + parseReferencePayload, + resolveRenderShortHtml, + fetchMessages, +}; diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index 2e4406b..5091470 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -779,6 +779,83 @@ body.view-map .map-panel--full #map { background: rgba(0, 0, 0, 0.08); } +.node-detail { + max-width: 960px; + margin: 0 auto; + padding: 24px 20px 40px; +} + +.node-detail__header { + margin-bottom: 12px; +} + +.node-detail__title { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; + font-size: 1.4rem; +} + +.node-detail__badge { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 1.2rem; +} + +.node-detail__identifier { + font-family: ui-monospace, Menlo, Consolas, monospace; + color: var(--muted); +} + +.node-detail__status, +.node-detail__error, +.node-detail__noscript { + margin: 16px 0; +} + +.node-detail__error { + color: #b00020; +} + +.node-detail__content { + display: grid; + gap: 24px; +} + +.node-detail__section h3 { + margin: 0 0 10px; + font-size: 1.1rem; +} + +.node-detail__list { + margin: 0; + padding: 0; + list-style: none; +} + +.node-detail__row { + display: grid; + grid-template-columns: minmax(140px, 200px) 1fr; + gap: 8px; + margin-bottom: 6px; +} + +.node-detail__row dt { + font-weight: 600; + color: var(--muted); + margin: 0; +} + +.node-detail__row dd { + margin: 0; + font-family: ui-monospace, Menlo, Consolas, monospace; +} + +.node-detail__list li { + margin-bottom: 4px; + font-family: ui-monospace, Menlo, Consolas, monospace; +} + .short-info-content { margin: 0; } diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 776d21d..8eb492d 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -4222,4 +4222,23 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(filtered.first).not_to have_key("portnum") end end + + describe "GET /nodes/:id" do + before do + import_nodes_fixture + end + + it "renders the node detail page with embedded reference data" do + node = nodes_fixture.first + get "/nodes/#{node["node_id"]}" + expect(last_response).to be_ok + expect(last_response.body).to include("data-node-reference=") + expect(last_response.body).to include(node["node_id"]) + end + + it "returns 404 when the node cannot be located" do + get "/nodes/!deadbeef" + expect(last_response.status).to eq(404) + end + end end diff --git a/web/views/node_detail.erb b/web/views/node_detail.erb new file mode 100644 index 0000000..2e6ba33 --- /dev/null +++ b/web/views/node_detail.erb @@ -0,0 +1,45 @@ + +<% reference_json = node_reference_json || "{}" + short_display = node_page_short_name || "Loading" + long_display = node_page_long_name + identifier_display = node_page_identifier || "" %> +
    " +> +
    +

    + <%= Rack::Utils.escape_html(short_display) %> + <% if long_display %> + <%= Rack::Utils.escape_html(long_display) %> + <% end %> + <% if identifier_display && !identifier_display.empty? %> + [<%= Rack::Utils.escape_html(identifier_display) %>] + <% end %> +

    +
    +

    Loading node details…

    + +
    +