mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
* nodes: add charts to detail pages * nodes: improve charts on detail pages * fix ignored packet debug loggin * run rufo * address review comments
282 lines
9.8 KiB
JavaScript
282 lines
9.8 KiB
JavaScript
/*
|
|
* 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.
|
|
*/
|
|
|
|
/**
|
|
* Determine whether the provided value behaves like a plain object.
|
|
*
|
|
* @param {*} value Candidate value.
|
|
* @returns {boolean} True when ``value`` is a non-null object.
|
|
*/
|
|
function isObject(value) {
|
|
return value != null && typeof value === 'object';
|
|
}
|
|
|
|
/**
|
|
* Convert a value to a trimmed string when possible.
|
|
*
|
|
* @param {*} value Input value.
|
|
* @returns {string|null} Trimmed string or ``null`` when blank.
|
|
*/
|
|
function toTrimmedString(value) {
|
|
if (value == null) return null;
|
|
const str = String(value).trim();
|
|
return str.length === 0 ? null : str;
|
|
}
|
|
|
|
/**
|
|
* Attempt to coerce the provided value into a finite number.
|
|
*
|
|
* @param {*} value Raw value.
|
|
* @returns {number|null} Finite number or ``null`` when coercion fails.
|
|
*/
|
|
function toFiniteNumber(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;
|
|
}
|
|
|
|
/**
|
|
* Normalise a neighbour entry so that downstream consumers can display it.
|
|
*
|
|
* @param {*} entry Raw neighbour entry.
|
|
* @returns {Object|null} Normalised neighbour reference or ``null`` when invalid.
|
|
*/
|
|
function normaliseNeighbor(entry) {
|
|
if (!isObject(entry)) return null;
|
|
const neighborId = toTrimmedString(entry.neighbor_id ?? entry.neighborId ?? entry.nodeId ?? entry.node_id);
|
|
if (!neighborId) return null;
|
|
const neighborShort = toTrimmedString(entry.neighbor_short_name ?? entry.neighborShortName ?? entry.short_name ?? entry.shortName);
|
|
const neighborLong = toTrimmedString(entry.neighbor_long_name ?? entry.neighborLongName ?? entry.long_name ?? entry.longName);
|
|
const neighborRole = toTrimmedString(entry.neighbor_role ?? entry.neighborRole ?? entry.role) || 'CLIENT';
|
|
const node = {
|
|
node_id: neighborId,
|
|
short_name: neighborShort ?? '',
|
|
long_name: neighborLong ?? '',
|
|
role: neighborRole,
|
|
};
|
|
const snr = toFiniteNumber(entry.snr);
|
|
const rxTime = toFiniteNumber(entry.rx_time ?? entry.rxTime);
|
|
const result = { node };
|
|
if (snr != null) {
|
|
result.snr = snr;
|
|
}
|
|
if (rxTime != null) {
|
|
result.rxTime = rxTime;
|
|
result.rx_time = rxTime;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Convert overlay node details into a map friendly payload.
|
|
*
|
|
* @param {*} source Raw overlay details.
|
|
* @returns {Object} Map node payload containing snake_case keys.
|
|
*/
|
|
export function overlayToPopupNode(source) {
|
|
if (!isObject(source)) {
|
|
return {
|
|
node_id: '',
|
|
node_num: null,
|
|
short_name: '',
|
|
long_name: '',
|
|
role: 'CLIENT',
|
|
neighbors: [],
|
|
};
|
|
}
|
|
|
|
const nodeId = toTrimmedString(source.nodeId ?? source.node_id ?? source.id) ?? '';
|
|
const nodeNum = toFiniteNumber(source.nodeNum ?? source.node_num ?? source.num);
|
|
const role = toTrimmedString(source.role) || 'CLIENT';
|
|
const neighbours = Array.isArray(source.neighbors)
|
|
? source.neighbors.map(normaliseNeighbor).filter(Boolean)
|
|
: [];
|
|
|
|
const payload = {
|
|
node_id: nodeId,
|
|
node_num: nodeNum,
|
|
short_name: toTrimmedString(source.shortName ?? source.short_name ?? source.name) ?? '',
|
|
long_name: toTrimmedString(source.longName ?? source.long_name ?? source.fullName ?? '') ?? '',
|
|
role,
|
|
hw_model: toTrimmedString(source.hwModel ?? source.hw_model ?? source.hardware) ?? '',
|
|
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_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),
|
|
barometric_pressure: toFiniteNumber(source.pressure ?? source.barometric_pressure),
|
|
telemetry_time: toFiniteNumber(source.telemetryTime ?? source.telemetry_time),
|
|
last_heard: toFiniteNumber(source.lastHeard ?? source.last_heard),
|
|
position_time: toFiniteNumber(source.positionTime ?? source.position_time),
|
|
latitude: toFiniteNumber(source.latitude),
|
|
longitude: toFiniteNumber(source.longitude),
|
|
altitude: toFiniteNumber(source.altitude),
|
|
neighbors: neighbours,
|
|
};
|
|
|
|
if (!payload.long_name && payload.short_name) {
|
|
payload.long_name = payload.short_name;
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
/**
|
|
* Attach an asynchronous refresh handler to a Leaflet marker so that
|
|
* up-to-date node information is fetched whenever the marker is clicked.
|
|
*
|
|
* @param {Object} options Behaviour configuration.
|
|
* @param {Object} options.marker Leaflet marker instance supporting ``on``.
|
|
* @param {Function} options.getOverlayFallback Returns the fallback overlay payload.
|
|
* @param {Function} options.refreshNodeInformation Async function fetching node details.
|
|
* @param {Function} options.mergeOverlayDetails Merge function combining fetched and fallback details.
|
|
* @param {Function} options.createRequestToken Generates a token for cancellation tracking.
|
|
* Receives the marker anchor element and the fallback overlay payload.
|
|
* @param {Function} options.isTokenCurrent Tests whether a request token is still current.
|
|
* Receives the marker anchor element and the candidate token value.
|
|
* @param {Function} [options.showLoading] Callback invoked before refreshing.
|
|
* @param {Function} [options.showDetails] Callback invoked with merged overlay details.
|
|
* @param {Function} [options.showError] Callback invoked when refreshing fails.
|
|
* @param {Function} [options.updatePopup] Callback updating the marker popup contents.
|
|
* @param {Function} [options.shouldHandleClick] Predicate that decides whether the click should trigger a refresh.
|
|
* @returns {void}
|
|
*/
|
|
export function attachNodeInfoRefreshToMarker({
|
|
marker,
|
|
getOverlayFallback,
|
|
refreshNodeInformation,
|
|
mergeOverlayDetails,
|
|
createRequestToken,
|
|
isTokenCurrent,
|
|
showLoading,
|
|
showDetails,
|
|
showError,
|
|
updatePopup,
|
|
shouldHandleClick,
|
|
}) {
|
|
if (!isObject(marker) || typeof marker.on !== 'function') {
|
|
throw new TypeError('A Leaflet marker with an on() method is required');
|
|
}
|
|
if (typeof refreshNodeInformation !== 'function') {
|
|
throw new TypeError('A refreshNodeInformation function must be provided');
|
|
}
|
|
if (typeof mergeOverlayDetails !== 'function') {
|
|
throw new TypeError('A mergeOverlayDetails function must be provided');
|
|
}
|
|
if (typeof createRequestToken !== 'function' || typeof isTokenCurrent !== 'function') {
|
|
throw new TypeError('Token management callbacks must be provided');
|
|
}
|
|
|
|
marker.on('click', event => {
|
|
if (event && event.originalEvent) {
|
|
const original = event.originalEvent;
|
|
if (typeof original.preventDefault === 'function') {
|
|
original.preventDefault();
|
|
}
|
|
if (typeof original.stopPropagation === 'function') {
|
|
original.stopPropagation();
|
|
}
|
|
}
|
|
|
|
const fallbackOverlay = typeof getOverlayFallback === 'function' ? getOverlayFallback() : null;
|
|
const anchor = typeof marker.getElement === 'function' ? marker.getElement() : null;
|
|
|
|
if (!isObject(fallbackOverlay)) {
|
|
if (anchor && typeof showDetails === 'function') {
|
|
showDetails(anchor, {});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (typeof shouldHandleClick === 'function' && !shouldHandleClick(anchor, fallbackOverlay)) {
|
|
return;
|
|
}
|
|
|
|
if (typeof updatePopup === 'function') {
|
|
updatePopup(fallbackOverlay);
|
|
}
|
|
|
|
const nodeId = toTrimmedString(fallbackOverlay.nodeId ?? fallbackOverlay.node_id ?? fallbackOverlay.id);
|
|
const nodeNum = toFiniteNumber(fallbackOverlay.nodeNum ?? fallbackOverlay.node_num ?? fallbackOverlay.num);
|
|
|
|
if (!nodeId && nodeNum == null) {
|
|
if (anchor && typeof showDetails === 'function') {
|
|
showDetails(anchor, fallbackOverlay);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const requestToken = createRequestToken(anchor, fallbackOverlay);
|
|
|
|
if (anchor && typeof showLoading === 'function') {
|
|
showLoading(anchor, fallbackOverlay);
|
|
}
|
|
|
|
const reference = { fallback: fallbackOverlay };
|
|
if (nodeId) reference.nodeId = nodeId;
|
|
if (nodeNum != null) reference.nodeNum = nodeNum;
|
|
|
|
let refreshPromise;
|
|
try {
|
|
refreshPromise = Promise.resolve(refreshNodeInformation(reference));
|
|
} catch (error) {
|
|
if (isTokenCurrent(anchor, requestToken)) {
|
|
if (anchor && typeof showError === 'function') {
|
|
showError(anchor, fallbackOverlay, error);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
refreshPromise
|
|
.then(details => {
|
|
if (!isTokenCurrent(anchor, requestToken)) {
|
|
return;
|
|
}
|
|
const merged = mergeOverlayDetails(details, fallbackOverlay);
|
|
if (typeof updatePopup === 'function') {
|
|
updatePopup(merged);
|
|
}
|
|
if (anchor && typeof showDetails === 'function') {
|
|
showDetails(anchor, merged);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
if (!isTokenCurrent(anchor, requestToken)) {
|
|
return;
|
|
}
|
|
if (typeof updatePopup === 'function') {
|
|
updatePopup(fallbackOverlay);
|
|
}
|
|
if (anchor && typeof showError === 'function') {
|
|
showError(anchor, fallbackOverlay, error);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
export const __testUtils = {
|
|
isObject,
|
|
toTrimmedString,
|
|
toFiniteNumber,
|
|
normaliseNeighbor,
|
|
};
|