mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-08 01:42:59 +02:00
Add node detail route and page (#441)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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, '"')
|
||||
.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 =>
|
||||
`<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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user