Add node detail route and page (#441)

This commit is contained in:
l5y
2025-11-13 17:19:20 +01:00
committed by GitHub
parent c823347175
commit cb843d5774
7 changed files with 1155 additions and 21 deletions
+119 -21
View File
@@ -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)
@@ -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) => `<span class="short-name" data-role="${role}">${short}</span>`,
},
);
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: () => '<span>ok</span>' };
const fn = await resolveRenderShortHtml();
assert.equal(fn('X'), '<span>ok</span>');
} 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'), '<span class="short-name">AB</span>');
} 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 => `<span class="short-name">${short}</span>`;
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 => `<span>${short}</span>`;
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 => `<span>${short}</span>`;
const result = await initializeNodeDetailPage({ document: documentStub, renderShortHtml });
assert.equal(result, false);
assert.equal(element.innerHTML.includes('Node reference unavailable'), true);
});
+6
View File
@@ -1729,6 +1729,12 @@ let messagesById = new Map();
return `<span class="short-name" style="background:${color}"${titleAttr}${infoAttr}>${padded}</span>`;
}
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.
+555
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 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 =>
`<div class="node-detail__row"><dt>${escapeHtml(entry.label)}</dt><dd>${escapeHtml(entry.value)}</dd></div>`,
);
if (rows.length === 0) return '';
return `<dl class="node-detail__list">${rows.join('')}</dl>`;
}
/**
* Render neighbor information as an unordered list.
*
* @param {Array<Object>} 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 `<li>${parts.join(' — ')}</li>`;
})
.filter(item => item != null);
if (items.length === 0) return '';
return `<ul class="node-detail__list">${items.join('')}</ul>`;
}
/**
* Render a message list using basic formatting.
*
* @param {Array<Object>} 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 `<li>${parts.join(' — ')}</li>`;
})
.filter(item => item != null);
if (items.length === 0) return '';
return `<ul class="node-detail__list">${items.join('')}</ul>`;
}
/**
* Render the node detail layout to an HTML fragment.
*
* @param {Object} node Normalised node payload.
* @param {{
* neighbors?: Array<Object>,
* messages?: Array<Object>,
* 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(`<section class="node-detail__section"><h3>Configuration</h3>${configHtml}</section>`);
}
if (telemetryHtml) {
sections.push(`<section class="node-detail__section"><h3>Telemetry</h3>${telemetryHtml}</section>`);
}
if (positionHtml) {
sections.push(`<section class="node-detail__section"><h3>Position</h3>${positionHtml}</section>`);
}
if (Array.isArray(neighbors) && neighbors.length > 0 && neighborsHtml) {
sections.push(`<section class="node-detail__section"><h3>Neighbors</h3>${neighborsHtml}</section>`);
}
if (Array.isArray(messages) && messages.length > 0 && messagesHtml) {
sections.push(`<section class="node-detail__section"><h3>Messages</h3>${messagesHtml}</section>`);
}
const identifierHtml = identifier ? `<span class="node-detail__identifier">[${escapeHtml(identifier)}]</span>` : '';
const nameHtml = longName ? `<span class="node-detail__name">${escapeHtml(longName)}</span>` : '';
return `
<header class="node-detail__header">
<h2 class="node-detail__title">${roleAwareBadge}${nameHtml}${identifierHtml}</h2>
</header>
<div class="node-detail__content">
${sections.join('')}
</div>
`;
}
/**
* 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<Function>} 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 => `<span class="short-name">${escapeHtml(short ?? '?')}</span>`;
}
/**
* 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<Array<Object>>} 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<boolean>} ``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 = '<p class="node-detail__error">Node reference unavailable.</p>';
return false;
}
const identifier = stringOrNull(referenceData.nodeId) ?? null;
const nodeNum = numberOrNull(referenceData.nodeNum);
if (!identifier && nodeNum == null) {
root.innerHTML = '<p class="node-detail__error">Node identifier missing.</p>';
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 = '<p class="node-detail__error">Failed to load node details.</p>';
return false;
}
}
export const __testUtils = {
stringOrNull,
numberOrNull,
escapeHtml,
formatFrequency,
formatBattery,
formatVoltage,
formatUptime,
formatTimestamp,
buildConfigurationEntries,
buildTelemetryEntries,
buildPositionEntries,
renderDefinitionList,
renderNeighbors,
renderMessages,
renderNodeDetailHtml,
parseReferencePayload,
resolveRenderShortHtml,
fetchMessages,
};
+77
View File
@@ -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;
}
+19
View File
@@ -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
+45
View File
@@ -0,0 +1,45 @@
<!--
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.
-->
<% reference_json = node_reference_json || "{}"
short_display = node_page_short_name || "Loading"
long_display = node_page_long_name
identifier_display = node_page_identifier || "" %>
<section
id="nodeDetail"
class="node-detail"
data-node-reference="<%= Rack::Utils.escape_html(reference_json) %>"
data-private-mode="<%= private_mode ? "true" : "false" %>"
>
<header class="node-detail__header">
<h2 class="node-detail__title">
<span class="node-detail__badge" data-node-badge><%= Rack::Utils.escape_html(short_display) %></span>
<% if long_display %>
<span class="node-detail__name" data-node-long-name><%= Rack::Utils.escape_html(long_display) %></span>
<% end %>
<% if identifier_display && !identifier_display.empty? %>
<span class="node-detail__identifier" data-node-identifier>[<%= Rack::Utils.escape_html(identifier_display) %>]</span>
<% end %>
</h2>
</header>
<p class="node-detail__status" data-node-status>Loading node details…</p>
<noscript>
<p class="node-detail__noscript">This page requires JavaScript to display node information.</p>
</noscript>
</section>
<script type="module">
import { initializeNodeDetailPage } from '/assets/js/app/node-page.js';
initializeNodeDetailPage();
</script>