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:
l5y
2025-11-14 20:17:58 +01:00
committed by GitHub
parent a6a63bf12e
commit 12f1801ed2
13 changed files with 213 additions and 67 deletions

3
.gitignore vendored
View File

@@ -73,3 +73,6 @@ web/.config
# JavaScript dependencies
node_modules/
web/node_modules/
# Debug symbols
ignored.txt

View File

@@ -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

View File

@@ -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())

View File

@@ -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.

View File

@@ -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)

View File

@@ -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);
});

View File

@@ -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: {

View File

@@ -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>

View File

@@ -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),

View File

@@ -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,
};

View File

@@ -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, '&#39;');
}
/**
* 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>

View File

@@ -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),
},
{

View File

@@ -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 {