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