web: fix traces rendering (#535)

* web: fix traces rendering

* web: remove icon shortcuts

* web: further refine the trace routes
This commit is contained in:
l5y
2025-12-08 19:48:33 +01:00
committed by GitHub
parent 88f699f4ec
commit 844204f64d
11 changed files with 600 additions and 31 deletions
@@ -584,6 +584,10 @@ module PotatoMesh
db.results_as_hash = true
params = []
where_clauses = []
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
where_clauses << "COALESCE(rx_time, 0) >= ?"
params << min_rx_time
if node_ref
tokens = node_reference_tokens(node_ref)
@@ -168,6 +168,7 @@ test('buildChatTabModel includes telemetry, position, and neighbor events', () =
telemetry: [{ node_id: nodeId, rx_time: NOW - 30 }],
positions: [{ node_id: nodeId, rx_time: NOW - 20 }],
neighbors: [{ node_id: nodeId, neighbor_id: neighborId, rx_time: NOW - 10 }],
traces: [{ id: 5_000, src: nodeId, hops: [neighborId], dest: '!charlie', rx_time: NOW - 5 }],
messages: [],
nowSeconds: NOW,
windowSeconds: WINDOW
@@ -178,11 +179,35 @@ test('buildChatTabModel includes telemetry, position, and neighbor events', () =
CHAT_LOG_ENTRY_TYPES.NODE_INFO,
CHAT_LOG_ENTRY_TYPES.TELEMETRY,
CHAT_LOG_ENTRY_TYPES.POSITION,
CHAT_LOG_ENTRY_TYPES.NEIGHBOR
CHAT_LOG_ENTRY_TYPES.NEIGHBOR,
CHAT_LOG_ENTRY_TYPES.TRACE
]);
assert.equal(model.logEntries[0].nodeId, nodeId);
const lastEntry = model.logEntries[model.logEntries.length - 1];
assert.equal(lastEntry.neighborId, neighborId);
const neighborEntry = model.logEntries.find(entry => entry.type === CHAT_LOG_ENTRY_TYPES.NEIGHBOR);
assert.ok(neighborEntry);
assert.equal(neighborEntry.neighborId, neighborId);
const traceEntry = model.logEntries.find(entry => entry.type === CHAT_LOG_ENTRY_TYPES.TRACE);
assert.ok(traceEntry);
assert.deepEqual(traceEntry.traceLabels, [nodeId, neighborId, '!charlie']);
});
test('buildChatTabModel normalises numeric traceroute hops into canonical IDs', () => {
const source = 0xabcdef01;
const hops = ['0xABCDEF02', '!abcdef03', 123];
const dest = 0xabcdef04;
const model = buildChatTabModel({
nodes: [],
traces: [{ rx_time: NOW - 5, src: source, hops, dest }],
nowSeconds: NOW,
windowSeconds: WINDOW
});
const traceEntry = model.logEntries.find(entry => entry.type === CHAT_LOG_ENTRY_TYPES.TRACE);
assert.ok(traceEntry);
assert.equal(traceEntry.nodeId, '!abcdef01');
assert.deepEqual(
traceEntry.tracePath.map(hop => hop.id),
['!abcdef01', '!abcdef02', '!abcdef03', '!0000007b', '!abcdef04']
);
});
test('buildChatTabModel merges dedicated encrypted log feed without altering channels', () => {
@@ -74,6 +74,18 @@ test('chatLogEntryMatchesQuery inspects neighbor node context', () => {
assert.equal(chatLogEntryMatchesQuery(entry, query), true);
});
test('chatLogEntryMatchesQuery inspects traceroute hop labels', () => {
const entry = {
type: CHAT_LOG_ENTRY_TYPES.TRACE,
traceLabels: ['!alpha', '!bravo', '!charlie'],
tracePath: [{ id: '!alpha' }, { id: '!bravo' }, { id: '!charlie' }]
};
const query = normaliseChatFilterQuery('bravo');
assert.equal(chatLogEntryMatchesQuery(entry, query), true);
const missQuery = normaliseChatFilterQuery('delta');
assert.equal(chatLogEntryMatchesQuery(entry, missQuery), false);
});
test('filterChatModel filters both log entries and channel messages', () => {
const model = {
logEntries: [
@@ -19,8 +19,13 @@ import assert from 'node:assert/strict';
import { buildTraceSegments, __testUtils } from '../trace-paths.js';
const { coerceFiniteNumber, findNode, resolveNodeCoordinates } = __testUtils;
const { buildNodeIndex } = __testUtils;
const {
coerceFiniteNumber,
findNode,
resolveNodeCoordinates,
canonicalNodeIdFromNumeric,
buildNodeIndex
} = __testUtils;
test('buildTraceSegments connects source, hops, and destination when coordinates exist', () => {
const traces = [
@@ -43,6 +48,29 @@ test('buildTraceSegments connects source, hops, and destination when coordinates
assert.equal(segments[0].color, 'color:ROUTER');
assert.equal(segments[1].color, 'color:CLIENT');
assert.equal(segments[0].rxTime, 1700);
assert.deepEqual(
segments[0].pathNodes.map(node => node.node_id),
['2658361180', '19088743', '4242424242']
);
});
test('buildTraceSegments links traces to canonical node IDs when numeric references are provided', () => {
const traces = [
{ id: 9_010, src: 0xbead_f00d, hops: [0xcafe_babe], dest: 0xfeed_c0de, rx_time: 1900 },
];
const nodes = [
{ node_id: '!beadf00d', latitude: 0, longitude: 0, role: 'ROUTER' },
{ node_id: '!cafebabe', latitude: 1, longitude: 1, role: 'CLIENT' },
{ node_id: '!feedc0de', latitude: 2, longitude: 2, role: 'CLIENT' },
];
const segments = buildTraceSegments(traces, nodes, { colorForNode: () => '#abcdef' });
assert.equal(segments.length, 2);
assert.deepEqual(segments[0].latlngs, [[0, 0], [1, 1]]);
assert.deepEqual(segments[1].latlngs, [[1, 1], [2, 2]]);
assert.equal(segments[0].color, '#abcdef');
assert.equal(segments[1].color, '#abcdef');
});
test('buildTraceSegments drops paths through hops without locations', () => {
@@ -98,13 +126,24 @@ test('helper utilities coerce values and locate nodes', () => {
assert.equal(coerceFiniteNumber(null), null);
assert.equal(coerceFiniteNumber(' '), null);
assert.equal(coerceFiniteNumber('7'), 7);
assert.equal(coerceFiniteNumber('!beadf00d'), 0xbeadf00d);
assert.equal(coerceFiniteNumber('0x10'), 16);
const byId = new Map([['!id', { node_id: '!id', latitude: 1, longitude: 2 }]]);
const byNum = new Map([[99, { node_id: '!other', latitude: 0, longitude: 0 }]]);
const byId = new Map([
['!id', { node_id: '!id', latitude: 1, longitude: 2 }],
['!beadf00d', { node_id: '!beadf00d', latitude: 3, longitude: 4 }]
]);
const byNum = new Map([
[99, { node_id: '!other', latitude: 0, longitude: 0 }],
[0xbeadf00d, { node_id: '!beadf00d', latitude: 3, longitude: 4 }]
]);
assert.equal(findNode(byId, byNum, '!id').node_id, '!id');
assert.equal(findNode(byId, byNum, 99).node_id, '!other');
assert.equal(findNode(byId, new Map(), 0xbeadf00d).node_id, '!beadf00d');
assert.equal(findNode(byId, byNum, 100), null);
assert.equal(canonicalNodeIdFromNumeric(0xbeadf00d), '!beadf00d');
const coords = resolveNodeCoordinates({ latitude: 5, longitude: 6, distance_km: 10 }, { limitDistance: true, maxDistanceKm: 15 });
assert.deepEqual(coords, [5, 6]);
const outOfRange = resolveNodeCoordinates({ latitude: 0, longitude: 0, distance_km: 20 }, { limitDistance: true, maxDistanceKm: 15 });
+118 -11
View File
@@ -30,7 +30,8 @@ export const MAX_CHANNEL_INDEX = 9;
* NODE_INFO: 'node-info',
* TELEMETRY: 'telemetry',
* POSITION: 'position',
* NEIGHBOR: 'neighbor'
* NEIGHBOR: 'neighbor',
* TRACE: 'trace'
* }}
*/
export const CHAT_LOG_ENTRY_TYPES = Object.freeze({
@@ -39,6 +40,7 @@ export const CHAT_LOG_ENTRY_TYPES = Object.freeze({
TELEMETRY: 'telemetry',
POSITION: 'position',
NEIGHBOR: 'neighbor',
TRACE: 'trace',
MESSAGE_ENCRYPTED: 'message-encrypted'
});
@@ -70,6 +72,7 @@ function resolveSnapshotList(entry) {
* telemetry?: Array<Object>,
* positions?: Array<Object>,
* neighbors?: Array<Object>,
* traces?: Array<Object>,
* messages?: Array<Object>,
* logOnlyMessages?: Array<Object>,
* nowSeconds: number,
@@ -87,6 +90,7 @@ export function buildChatTabModel({
telemetry = [],
positions = [],
neighbors = [],
traces = [],
messages = [],
logOnlyMessages = [],
nowSeconds,
@@ -156,6 +160,34 @@ export function buildChatTabModel({
}
}
for (const trace of traces || []) {
if (!trace) continue;
const ts = resolveTimestampSeconds(trace.rx_time ?? trace.rxTime, trace.rx_iso ?? trace.rxIso);
if (ts == null || ts < cutoff) continue;
const path = buildTracePath(trace);
if (path.length < 2) continue;
const firstHop = path[0] || {};
const traceLabels = path
.map(hop => {
if (!hop || typeof hop !== 'object') return null;
const candidates = [hop.id, hop.raw];
if (Number.isFinite(hop.num)) {
candidates.push(String(hop.num));
}
return candidates.find(val => val != null && String(val).trim().length > 0) ?? null;
})
.filter(value => value != null && value !== '');
logEntries.push({
ts,
type: CHAT_LOG_ENTRY_TYPES.TRACE,
trace,
tracePath: path,
traceLabels,
nodeId: firstHop.id ?? null,
nodeNum: firstHop.num ?? null
});
}
const encryptedLogEntries = [];
const encryptedLogKeys = new Set();
@@ -345,10 +377,59 @@ function pickFirstPropertyValue(source, keys) {
* @param {*} value Arbitrary payload candidate.
* @returns {?string} Canonical node identifier.
*/
function coerceFiniteNumber(value) {
if (value == null) return null;
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.startsWith('!')) {
const hex = trimmed.slice(1);
if (!/^[0-9A-Fa-f]+$/.test(hex)) return null;
const parsedHex = Number.parseInt(hex, 16);
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
}
if (/^0[xX][0-9A-Fa-f]+$/.test(trimmed)) {
const parsedHex = Number.parseInt(trimmed, 16);
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function canonicalNodeIdFromNumeric(ref) {
if (!Number.isFinite(ref)) return null;
const unsigned = ref >>> 0;
const hex = unsigned.toString(16).padStart(8, '0');
return `!${hex}`;
}
function normaliseNodeId(value) {
if (!value || typeof value !== 'object') return null;
const raw = value.node_id ?? value.nodeId ?? null;
return typeof raw === 'string' && raw.trim().length ? raw.trim() : null;
if (value == null) return null;
if (typeof value === 'number') {
return canonicalNodeIdFromNumeric(value);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
const canonicalFromNumeric = canonicalNodeIdFromNumeric(coerceFiniteNumber(trimmed));
return canonicalFromNumeric ?? trimmed;
}
if (typeof value !== 'object') return null;
const rawId = value.node_id ?? value.nodeId ?? null;
if (rawId != null) {
const canonical = normaliseNodeId(rawId);
if (canonical) return canonical;
}
const numericRef = value.node_num ?? value.nodeNum ?? value.num;
const numericId = canonicalNodeIdFromNumeric(coerceFiniteNumber(numericRef));
if (numericId) return numericId;
return null;
}
/**
@@ -366,6 +447,29 @@ function normaliseNeighborId(value) {
return null;
}
/**
* Build an ordered trace path of node identifiers and numeric references.
*
* @param {Object} trace Trace payload.
* @returns {Array<{id: ?string, num: ?number, raw: *}>} Ordered hop descriptors.
*/
function buildTracePath(trace) {
const path = [];
const append = value => {
if (value == null || value === '') return;
const id = normaliseNodeId(value);
const num = normaliseNodeNum({ num: value });
path.push({ id, num, raw: value });
};
append(trace.src ?? trace.source ?? trace.from);
const hops = Array.isArray(trace.hops) ? trace.hops : [];
for (const hop of hops) {
append(hop);
}
append(trace.dest ?? trace.destination ?? trace.to);
return path;
}
/**
* Extract a finite node number from a payload when available.
*
@@ -373,14 +477,17 @@ function normaliseNeighborId(value) {
* @returns {?number} Canonical numeric identifier.
*/
function normaliseNodeNum(value) {
if (!value || typeof value !== 'object') return null;
const raw = value.node_num ?? value.nodeNum ?? value.num;
if (raw == null || raw === '') return null;
if (typeof raw === 'number') {
return Number.isFinite(raw) ? raw : null;
if (Number.isFinite(value)) {
return Math.trunc(value);
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
const fromObject = value && typeof value === 'object'
? coerceFiniteNumber(value.node_num ?? value.nodeNum ?? value.num)
: null;
if (fromObject != null) {
return Math.trunc(fromObject);
}
const parsed = coerceFiniteNumber(value);
return parsed != null ? Math.trunc(parsed) : null;
}
/**
+1
View File
@@ -110,6 +110,7 @@ export function chatLogEntryMatchesQuery(entry, query) {
candidates.push(...collectSearchValues(entry.position));
candidates.push(...collectSearchValues(entry.neighbor));
candidates.push(...collectSearchValues(entry.neighborNode));
candidates.push(...(Array.isArray(entry.traceLabels) ? entry.traceLabels : []));
if (entry.nodeId) candidates.push(entry.nodeId);
if (entry.nodeNum != null && entry.nodeNum !== '') candidates.push(entry.nodeNum);
if (entry.neighborId) candidates.push(entry.neighborId);
+303 -6
View File
@@ -190,6 +190,7 @@ export function initializeApp(config) {
});
const NODE_LIMIT = 1000;
const TRACE_LIMIT = 200;
const TRACE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
const SNAPSHOT_LIMIT = SNAPSHOT_WINDOW;
const CHAT_LIMIT = MESSAGE_LIMIT;
const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
@@ -452,8 +453,11 @@ export function initializeApp(config) {
const AUTO_FIT_PADDING_PX = 12;
const MAX_INITIAL_ZOOM = 13;
let neighborLinesLayer = null;
let traceLinesLayer = null;
let neighborLinesVisible = true;
let traceLinesVisible = true;
let neighborLinesToggleButton = null;
let traceLinesToggleButton = null;
let markersLayer = null;
let tileDomObserver = null;
const fullscreenChangeEvents = [
@@ -1244,6 +1248,7 @@ export function initializeApp(config) {
});
neighborLinesLayer = L.layerGroup().addTo(map);
traceLinesLayer = L.layerGroup().addTo(map);
markersLayer = L.layerGroup().addTo(map);
if (typeof navigator !== 'undefined' && navigator && navigator.onLine === false) {
@@ -1336,6 +1341,38 @@ export function initializeApp(config) {
updateNeighborLinesToggleState();
}
/**
* Synchronise the traceroute line toggle button with the active state.
*
* @returns {void}
*/
function updateTraceLinesToggleState() {
if (!traceLinesToggleButton) return;
const label = traceLinesVisible ? 'Hide trace lines' : 'Show trace lines';
traceLinesToggleButton.textContent = label;
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false');
traceLinesToggleButton.setAttribute('aria-label', label);
}
/**
* Toggle the Leaflet layer that renders traceroute connections.
*
* @param {boolean} visible Whether to show traceroute paths.
* @returns {void}
*/
function setTraceLinesVisibility(visible) {
traceLinesVisible = Boolean(visible);
if (traceLinesLayer && map) {
const hasLayer = map.hasLayer(traceLinesLayer);
if (traceLinesVisible && !hasLayer) {
traceLinesLayer.addTo(map);
} else if (!traceLinesVisible && hasLayer) {
map.removeLayer(traceLinesLayer);
}
}
updateTraceLinesToggleState();
}
/**
* Refresh the legend buttons to reflect the active role filters.
*
@@ -1433,6 +1470,15 @@ export function initializeApp(config) {
});
updateNeighborLinesToggleState();
traceLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-traces', toggle);
traceLinesToggleButton.type = 'button';
traceLinesToggleButton.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
setTraceLinesVisibility(!traceLinesVisible);
});
updateTraceLinesToggleState();
const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', toggle);
resetButton.type = 'button';
resetButton.textContent = 'Clear filters';
@@ -1764,6 +1810,37 @@ export function initializeApp(config) {
return value.replace(/[^a-zA-Z0-9_-]/g, chr => `\\${chr}`);
}
/**
* Parse a node identifier or numeric reference into a finite number.
*
* @param {*} ref Identifier or numeric reference.
* @returns {number|null} Parsed number or ``null``.
*/
function parseNodeNumericRef(ref) {
if (ref == null) return null;
if (typeof ref === 'number') {
return Number.isFinite(ref) ? ref : null;
}
if (typeof ref === 'string') {
const trimmed = ref.trim();
if (!trimmed) return null;
if (trimmed.startsWith('!')) {
const hex = trimmed.slice(1);
if (!/^[0-9A-Fa-f]+$/.test(hex)) return null;
const parsedHex = Number.parseInt(hex, 16);
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
}
if (/^0[xX][0-9A-Fa-f]+$/.test(trimmed)) {
const parsedHex = Number.parseInt(trimmed, 16);
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
const parsed = Number(ref);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* Populate the ``nodesById`` index for quick lookups.
*
@@ -1781,9 +1858,13 @@ export function initializeApp(config) {
: (typeof node.nodeId === 'string' ? node.nodeId : null);
if (nodeIdRaw) {
nodesById.set(nodeIdRaw.trim(), node);
const numericFromId = parseNodeNumericRef(nodeIdRaw);
if (numericFromId != null && !nodesByNum.has(numericFromId)) {
nodesByNum.set(numericFromId, node);
}
}
const nodeNumRaw = node.num ?? node.node_num ?? node.nodeNum;
const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw);
const nodeNum = parseNodeNumericRef(nodeNumRaw);
if (Number.isFinite(nodeNum)) {
nodesByNum.set(nodeNum, node);
}
@@ -2400,6 +2481,8 @@ export function initializeApp(config) {
return createPositionChatEntry(entry, context);
case CHAT_LOG_ENTRY_TYPES.NEIGHBOR:
return createNeighborChatEntry(entry, context);
case CHAT_LOG_ENTRY_TYPES.TRACE:
return createTraceChatEntry(entry, context);
case CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED:
return entry?.message ? createMessageChatEntry(entry.message) : null;
default:
@@ -2446,6 +2529,138 @@ export function initializeApp(config) {
return div;
}
/**
* Convert a trace path into user-friendly labels using cached node metadata.
*
* @param {Array<{id: ?string, num: ?number, raw: *}>} tracePath Ordered hop references.
* @returns {Array<string>} Display labels for each hop.
*/
function formatTracePathLabels(tracePath) {
if (!Array.isArray(tracePath)) return [];
const labels = [];
for (const hop of tracePath) {
if (!hop || typeof hop !== 'object') continue;
const node = resolveNodeForHop(hop);
const fallbackId = hop.id ?? (Number.isFinite(hop.num) ? String(hop.num) : (hop.raw != null ? String(hop.raw) : ''));
const shortName = node ? normalizeNodeNameValue(node.short_name ?? node.shortName) : null;
const label = shortName || (node ? (getNodeDisplayNameForOverlay(node) || fallbackId) : fallbackId);
if (label) {
labels.push(String(label));
}
}
return labels;
}
function createTraceChatEntry(entry, context) {
if (!entry || !Array.isArray(entry.tracePath) || entry.tracePath.length < 2) {
return null;
}
const sourceHop = entry.tracePath[0] || null;
const sourceNode = resolveNodeForHop(sourceHop);
const labels = formatTracePathLabels(entry.tracePath);
const labelText = labels.length ? labels.join(', ') : 'Traceroute';
const labelSuffix = `: ${escapeHtml(labelText)}`;
return createAnnouncementEntry({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: context.longName || context.nodeId || labels[0] || 'Traceroute',
role: context.role,
metadataSource: sourceNode || context.metadataSource,
nodeData: sourceNode || context.nodeData,
messageHtml: `${renderEmojiHtml('👣')} ${renderAnnouncementCopy('Caught trace', labelSuffix)}`
});
}
/**
* Build tooltip HTML showing styled short-name badges for a trace path.
*
* @param {Array<Object>} pathNodes Ordered node payloads along the trace.
* @returns {string} HTML fragment or ``''`` when unavailable.
*/
function buildTraceTooltipHtml(pathNodes) {
if (!Array.isArray(pathNodes) || pathNodes.length < 2) {
return '';
}
const parts = pathNodes
.map(node => {
if (!node || typeof node !== 'object') {
return null;
}
const short = normalizeNodeNameValue(node.short_name ?? node.shortName) || (typeof node.node_id === 'string' ? node.node_id : '');
const long = normalizeNodeNameValue(node.long_name ?? node.longName) || '';
return renderShortHtml(short, node.role, long, node);
})
.filter(Boolean);
if (!parts.length) return '';
const arrow = '<span class="trace-tooltip__arrow" aria-hidden="true">→</span>';
return `<div class="trace-tooltip__content">${parts.join(arrow)}</div>`;
}
/**
* Build tooltip HTML for a neighbor segment showing styled short-name badges.
*
* @param {{sourceNode?: Object, targetNode?: Object, sourceShortName?: string, targetShortName?: string, sourceRole?: string, targetRole?: string}} segment Neighbor segment descriptor.
* @returns {string} HTML fragment or ``''`` when unavailable.
*/
function buildNeighborTooltipHtml(segment) {
if (!segment) return '';
const sourceNode = segment.sourceNode || null;
const targetNode = segment.targetNode || null;
const sourceShort = normalizeNodeNameValue(
segment.sourceShortName ||
(sourceNode ? sourceNode.short_name ?? sourceNode.shortName : null) ||
(sourceNode && typeof sourceNode.node_id === 'string' ? sourceNode.node_id : '')
);
const targetShort = normalizeNodeNameValue(
segment.targetShortName ||
(targetNode ? targetNode.short_name ?? targetNode.shortName : null) ||
(targetNode && typeof targetNode.node_id === 'string' ? targetNode.node_id : '')
);
if (!sourceShort || !targetShort) return '';
const sourceLong = normalizeNodeNameValue(sourceNode?.long_name ?? sourceNode?.longName) || '';
const targetLong = normalizeNodeNameValue(targetNode?.long_name ?? targetNode?.longName) || '';
const sourceHtml = renderShortHtml(sourceShort, segment.sourceRole, sourceLong, sourceNode || {});
const targetHtml = renderShortHtml(targetShort, segment.targetRole, targetLong, targetNode || {});
const arrow = '<span class="trace-tooltip__arrow" aria-hidden="true">→</span>';
return `<div class="trace-tooltip__content">${sourceHtml}${arrow}${targetHtml}</div>`;
}
/**
* Resolve a node reference for a trace hop using cached node indices.
*
* @param {{id?: string, num?: number}|null} hop Trace hop descriptor.
* @returns {?Object} Node payload when available.
*/
function resolveNodeForHop(hop) {
if (!hop || typeof hop !== 'object') {
return null;
}
const id = typeof hop.id === 'string' ? hop.id.trim() : null;
const idCandidates = [];
if (id) {
idCandidates.push(id);
idCandidates.push(id.toUpperCase());
idCandidates.push(id.toLowerCase());
}
for (const candidate of idCandidates) {
if (candidate && nodesById instanceof Map && nodesById.has(candidate)) {
return nodesById.get(candidate);
}
}
const numericCandidates = [];
if (Number.isFinite(hop.num)) numericCandidates.push(hop.num);
const parsedFromId = parseNodeNumericRef(id);
if (parsedFromId != null) numericCandidates.push(parsedFromId);
const parsedFromNum = parseNodeNumericRef(hop.num);
if (parsedFromNum != null) numericCandidates.push(parsedFromNum);
for (const numeric of numericCandidates) {
if (Number.isFinite(numeric) && nodesByNum instanceof Map && nodesByNum.has(numeric)) {
return nodesByNum.get(numeric);
}
}
return null;
}
/**
* Derive display context for a chat log entry by inspecting node payloads.
*
@@ -2809,6 +3024,7 @@ export function initializeApp(config) {
* telemetryEntries?: Array<Object>,
* positionEntries?: Array<Object>,
* neighborEntries?: Array<Object>,
* traceEntries?: Array<Object>,
* filterQuery?: string
* }} params Render inputs.
* @returns {void}
@@ -2820,6 +3036,7 @@ export function initializeApp(config) {
telemetryEntries = [],
positionEntries = [],
neighborEntries = [],
traceEntries = [],
filterQuery = ''
}) {
if (!CHAT_ENABLED || !chatEl) return;
@@ -2834,6 +3051,7 @@ export function initializeApp(config) {
telemetry: telemetryEntries,
positions: positionEntries,
neighbors: neighborEntries,
traces: traceEntries,
messages,
logOnlyMessages: encryptedMessages,
nowSeconds,
@@ -3283,7 +3501,8 @@ export function initializeApp(config) {
const effectiveLimit = Math.min(safeLimit, NODE_LIMIT);
const r = await fetch(`/api/traces?limit=${effectiveLimit}`, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
const traces = await r.json();
return filterRecentTraces(traces, TRACE_MAX_AGE_SECONDS);
}
/**
@@ -3343,6 +3562,28 @@ export function initializeApp(config) {
return null;
}
/**
* Filter trace entries to discard packets older than the configured window.
*
* @param {Array<Object>} traces Trace payloads.
* @param {number} [maxAgeSeconds=TRACE_MAX_AGE_SECONDS] Maximum allowed age in seconds.
* @returns {Array<Object>} Recent trace entries.
*/
function filterRecentTraces(traces, maxAgeSeconds = TRACE_MAX_AGE_SECONDS) {
if (!Array.isArray(traces)) {
return [];
}
if (!Number.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0) {
return [...traces];
}
const nowSeconds = Math.floor(Date.now() / 1000);
const cutoff = nowSeconds - maxAgeSeconds;
return traces.filter(trace => {
const rxTime = resolveTimestampSeconds(trace?.rx_time ?? trace?.rxTime, trace?.rx_iso ?? trace?.rxIso);
return rxTime != null && rxTime >= cutoff;
});
}
/**
* Merge recent position packets into the node list.
*
@@ -3623,6 +3864,9 @@ export function initializeApp(config) {
if (neighborLinesLayer) {
neighborLinesLayer.clearLayers();
}
if (traceLinesLayer) {
traceLinesLayer.clearLayers();
}
markersLayer.clearLayers();
const pts = [];
const nodesById = new Map();
@@ -3632,7 +3876,7 @@ export function initializeApp(config) {
if (typeof nodeId !== 'string' || nodeId.length === 0) continue;
nodesById.set(nodeId, node);
}
const traceSegments = neighborLinesLayer
const traceSegments = traceLinesLayer
? buildTraceSegments(allTraces, nodes, {
limitDistance: LIMIT_DISTANCE,
maxDistanceKm: MAX_DISTANCE_KM,
@@ -3724,6 +3968,21 @@ export function initializeApp(config) {
opacity: 0.42,
className: 'neighbor-connection-line'
}).addTo(neighborLinesLayer);
if (polyline && typeof polyline.bindTooltip === 'function') {
const tooltipHtml = buildNeighborTooltipHtml({
...segment,
sourceNode: nodesById.get(segment.sourceId),
targetNode: nodesById.get(segment.targetId)
});
if (tooltipHtml) {
polyline.bindTooltip(tooltipHtml, {
direction: 'center',
opacity: 0.92,
sticky: true,
className: 'trace-tooltip'
});
}
}
if (polyline && typeof polyline.on === 'function') {
polyline.on('click', event => {
if (event && event.originalEvent) {
@@ -3741,6 +4000,13 @@ export function initializeApp(config) {
? event.originalEvent.target
: null;
const anchorEl = polyline.getElement() || clickTarget;
if (polyline && typeof polyline.isTooltipOpen === 'function' && typeof polyline.openTooltip === 'function') {
if (polyline.isTooltipOpen()) {
polyline.closeTooltip();
} else {
polyline.openTooltip();
}
}
if (!anchorEl) return;
if (overlayStack.isOpen(anchorEl)) {
overlayStack.close(anchorEl);
@@ -3752,7 +4018,7 @@ export function initializeApp(config) {
});
}
if (neighborLinesLayer && traceSegments.length) {
if (traceLinesLayer && traceSegments.length) {
traceSegments
.sort((a, b) => {
const rxA = Number.isFinite(a.rxTime) ? a.rxTime : -Infinity;
@@ -3761,13 +4027,43 @@ export function initializeApp(config) {
return rxA - rxB;
})
.forEach(segment => {
L.polyline(segment.latlngs, {
const polyline = L.polyline(segment.latlngs, {
color: segment.color,
weight: 2,
opacity: 0.42,
dashArray: '6 6',
className: 'neighbor-connection-line trace-connection-line'
}).addTo(neighborLinesLayer);
}).addTo(traceLinesLayer);
if (polyline && typeof polyline.bindTooltip === 'function') {
const tooltipHtml = buildTraceTooltipHtml(segment.pathNodes);
if (tooltipHtml) {
polyline.bindTooltip(tooltipHtml, {
direction: 'center',
opacity: 0.92,
sticky: true,
className: 'trace-tooltip'
});
}
}
if (polyline && typeof polyline.on === 'function') {
polyline.on('click', event => {
if (event && event.originalEvent) {
if (typeof event.originalEvent.preventDefault === 'function') {
event.originalEvent.preventDefault();
}
if (typeof event.originalEvent.stopPropagation === 'function') {
event.originalEvent.stopPropagation();
}
}
if (polyline && typeof polyline.isTooltipOpen === 'function' && typeof polyline.openTooltip === 'function') {
if (polyline.isTooltipOpen()) {
polyline.closeTooltip();
} else {
polyline.openTooltip();
}
}
});
}
});
}
@@ -3917,6 +4213,7 @@ export function initializeApp(config) {
telemetryEntries: allTelemetryEntries,
positionEntries: allPositionEntries,
neighborEntries: allNeighbors,
traceEntries: allTraces,
filterQuery
});
}
+52 -3
View File
@@ -22,8 +22,26 @@
*/
function coerceFiniteNumber(value) {
if (value == null) return null;
if (typeof value === 'string' && value.trim().length === 0) return null;
const num = typeof value === 'number' ? value : Number(value);
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length === 0) return null;
if (trimmed.startsWith('!')) {
const hex = trimmed.slice(1);
if (!/^[0-9A-Fa-f]+$/.test(hex)) return null;
const parsedHex = Number.parseInt(hex, 16);
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
}
if (/^0[xX][0-9A-Fa-f]+$/.test(trimmed)) {
const parsedHex = Number.parseInt(trimmed, 16);
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
@@ -64,6 +82,19 @@ function buildNodeIndex(nodes) {
return { byId, byNum };
}
/**
* Convert a numeric node reference into the canonical hex-prefixed identifier.
*
* @param {number} ref Numeric node identifier.
* @returns {string|null} Canonical identifier or ``null`` when invalid.
*/
function canonicalNodeIdFromNumeric(ref) {
if (!Number.isFinite(ref)) return null;
const unsigned = ref >>> 0;
const hex = unsigned.toString(16).padStart(8, '0');
return `!${hex}`;
}
/**
* Locate a node by either string identifier or numeric reference.
*
@@ -80,6 +111,12 @@ function findNode(byId, byNum, ref) {
}
if (numeric != null) {
if (byNum.has(numeric)) return byNum.get(numeric) || null;
const canonicalId = canonicalNodeIdFromNumeric(numeric);
if (canonicalId) {
if (byId.has(canonicalId)) return byId.get(canonicalId) || null;
const canonicalUpper = canonicalId.toUpperCase();
if (byId.has(canonicalUpper)) return byId.get(canonicalUpper) || null;
}
const asString = String(numeric);
if (byId.has(asString)) return byId.get(asString) || null;
}
@@ -163,6 +200,8 @@ export function buildTraceSegments(traces, nodes, { limitDistance = false, maxDi
const path = extractTracePath(trace);
if (path.length < 2) continue;
const rxTime = coerceFiniteNumber(trace.rx_time ?? trace.rxTime);
const nodesWithCoords = [];
const segmentsForTrace = [];
let previous = null;
for (const ref of path) {
@@ -172,8 +211,9 @@ export function buildTraceSegments(traces, nodes, { limitDistance = false, maxDi
previous = null;
continue;
}
nodesWithCoords.push(node);
if (previous) {
segments.push({
segmentsForTrace.push({
latlngs: [previous.coords, coords],
color: colorResolver(previous.node),
traceId: trace.id ?? trace.packet_id ?? trace.trace_id,
@@ -182,6 +222,14 @@ export function buildTraceSegments(traces, nodes, { limitDistance = false, maxDi
}
previous = { node, coords };
}
if (segmentsForTrace.length) {
const pathNodes = nodesWithCoords.slice();
segmentsForTrace.forEach(segment => {
segment.pathNodes = pathNodes;
});
segments.push(...segmentsForTrace);
}
}
return segments;
@@ -193,4 +241,5 @@ export const __testUtils = {
findNode,
resolveNodeCoordinates,
extractTracePath,
canonicalNodeIdFromNumeric,
};
+19
View File
@@ -121,6 +121,25 @@ tbody tr:nth-child(even) td {
stroke-dasharray: 6 6;
}
.leaflet-tooltip.trace-tooltip {
background: var(--bg2);
color: var(--fg);
border: 1px solid var(--line);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
padding: 6px 8px;
font-size: 13px;
}
.trace-tooltip__content {
display: inline-flex;
align-items: center;
gap: 6px;
}
.trace-tooltip__arrow {
opacity: 0.7;
}
.neighbor-snr {
margin-left: 4px;
color: var(--muted);
+20
View File
@@ -4657,6 +4657,26 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
expect(JSON.parse(last_response.body)).to eq([])
end
it "excludes traces older than one week" do
clear_database
now = Time.now.to_i
recent_rx = now - (PotatoMesh::Config.week_seconds / 2)
stale_rx = now - (PotatoMesh::Config.week_seconds + 60)
payload = [
{ "id" => 50_001, "src" => 1, "dest" => 2, "rx_time" => recent_rx, "metrics" => {} },
{ "id" => 50_002, "src" => 3, "dest" => 4, "rx_time" => stale_rx, "metrics" => {} },
]
post "/api/traces", payload.to_json, auth_headers
expect(last_response).to be_ok
get "/api/traces"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |row| row["id"] }
expect(ids).to eq([50_001])
end
end
describe "GET /nodes/:id" do
-4
View File
@@ -113,10 +113,6 @@
<option value=""><%= Rack::Utils.escape_html("Select region ...") %></option>
</select>
</div>
<a href="/federation" class="federation-link" aria-label="View federation network" target="_blank" rel="noopener noreferrer">🔗</a>
<a href="/map" class="federation-link" aria-label="Open map in new tab" target="_blank" rel="noopener noreferrer">🌍</a>
<a href="/chat" class="federation-link" aria-label="Open chat in new tab" target="_blank" rel="noopener noreferrer">💬</a>
<a href="/nodes" class="federation-link" aria-label="Open nodes in new tab" target="_blank" rel="noopener noreferrer">🪢</a>
</div>
<% end %>
</header>