diff --git a/web/lib/potato_mesh/application/queries.rb b/web/lib/potato_mesh/application/queries.rb index 4c0fe2b..84a973f 100644 --- a/web/lib/potato_mesh/application/queries.rb +++ b/web/lib/potato_mesh/application/queries.rb @@ -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) diff --git a/web/public/assets/js/app/__tests__/chat-log-tabs.test.js b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js index b41b3e0..e20d0d3 100644 --- a/web/public/assets/js/app/__tests__/chat-log-tabs.test.js +++ b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js @@ -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', () => { diff --git a/web/public/assets/js/app/__tests__/chat-search.test.js b/web/public/assets/js/app/__tests__/chat-search.test.js index 20f0f41..83afc0a 100644 --- a/web/public/assets/js/app/__tests__/chat-search.test.js +++ b/web/public/assets/js/app/__tests__/chat-search.test.js @@ -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: [ diff --git a/web/public/assets/js/app/__tests__/trace-paths.test.js b/web/public/assets/js/app/__tests__/trace-paths.test.js index 8b5031f..5ef0113 100644 --- a/web/public/assets/js/app/__tests__/trace-paths.test.js +++ b/web/public/assets/js/app/__tests__/trace-paths.test.js @@ -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 }); diff --git a/web/public/assets/js/app/chat-log-tabs.js b/web/public/assets/js/app/chat-log-tabs.js index 15bcdc8..1923673 100644 --- a/web/public/assets/js/app/chat-log-tabs.js +++ b/web/public/assets/js/app/chat-log-tabs.js @@ -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, * positions?: Array, * neighbors?: Array, + * traces?: Array, * messages?: Array, * logOnlyMessages?: Array, * 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; } /** diff --git a/web/public/assets/js/app/chat-search.js b/web/public/assets/js/app/chat-search.js index eb5a7b9..338b421 100644 --- a/web/public/assets/js/app/chat-search.js +++ b/web/public/assets/js/app/chat-search.js @@ -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); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 8ba17ff..5b1944f 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -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} 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} 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 = ''; + return `
${parts.join(arrow)}
`; + } + + /** + * 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 = ''; + return `
${sourceHtml}${arrow}${targetHtml}
`; + } + + /** + * 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, * positionEntries?: Array, * neighborEntries?: Array, + * traceEntries?: Array, * 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} traces Trace payloads. + * @param {number} [maxAgeSeconds=TRACE_MAX_AGE_SECONDS] Maximum allowed age in seconds. + * @returns {Array} 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 }); } diff --git a/web/public/assets/js/app/trace-paths.js b/web/public/assets/js/app/trace-paths.js index 7c5ed3c..34e4bc3 100644 --- a/web/public/assets/js/app/trace-paths.js +++ b/web/public/assets/js/app/trace-paths.js @@ -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, }; diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index 629724c..6c0752c 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -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); diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 414148b..1ec942c 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -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 diff --git a/web/views/layouts/app.erb b/web/views/layouts/app.erb index 8ee6b53..b16fd24 100644 --- a/web/views/layouts/app.erb +++ b/web/views/layouts/app.erb @@ -113,10 +113,6 @@ - 🔗 - 🌍 - 💬 - 🪢 <% end %>