// Path Inspector — prefix candidate scoring with map overlay (issue #944).
// IIFE; exports window.PathInspector for testability.
(function () {
'use strict';
var container = null;
var currentResults = null;
function init(app) {
container = app;
var params = new URLSearchParams(location.hash.split('?')[1] || '');
var prefixParam = params.get('prefixes') || '';
container.innerHTML =
'
' +
'
Path Inspector
' +
'
Enter comma or space-separated hex prefixes (1-3 bytes each, e.g. 2C,A1,F4 or 2C A1 F4).
' +
'
' +
'' +
'' +
'
' +
'' +
'' +
'
';
var input = document.getElementById('path-inspector-input');
var btn = document.getElementById('path-inspector-submit');
btn.addEventListener('click', function () { submit(input.value); });
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') submit(input.value);
});
// Auto-run if prefixes in URL.
if (prefixParam) submit(prefixParam);
}
function destroy() {
container = null;
currentResults = null;
}
function parsePrefixes(raw) {
// Accept comma or space separated.
var parts = raw.trim().split(/[\s,]+/).filter(function (s) { return s.length > 0; });
return parts.map(function (p) { return p.toLowerCase(); });
}
function validatePrefixes(prefixes) {
if (prefixes.length === 0) return 'Enter at least one prefix.';
if (prefixes.length > 64) return 'Too many prefixes (max 64).';
var hexRe = /^[0-9a-f]+$/;
var byteLen = -1;
for (var i = 0; i < prefixes.length; i++) {
var p = prefixes[i];
if (!hexRe.test(p)) return 'Invalid hex: ' + p;
if (p.length % 2 !== 0) return 'Odd-length prefix: ' + p;
var bl = p.length / 2;
if (bl > 3) return 'Prefix too long (max 3 bytes): ' + p;
if (byteLen === -1) byteLen = bl;
else if (bl !== byteLen) return 'Mixed prefix lengths not allowed.';
}
return null;
}
function submit(raw) {
var errDiv = document.getElementById('path-inspector-error');
var resultsDiv = document.getElementById('path-inspector-results');
errDiv.textContent = '';
resultsDiv.innerHTML = '';
var prefixes = parsePrefixes(raw);
var err = validatePrefixes(prefixes);
if (err) {
errDiv.textContent = err;
return;
}
// Update URL.
var base = '#/tools/path-inspector';
if (location.hash.indexOf(base) === 0) {
history.replaceState(null, '', base + '?prefixes=' + prefixes.join(','));
}
resultsDiv.innerHTML = '
Loading...
';
fetch('/api/paths/inspect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefixes: prefixes })
})
.then(function (r) {
if (r.status === 503) return r.json().then(function (d) { throw new Error('Service warming up, retry in a few seconds.'); });
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Request failed'); });
return r.json();
})
.then(function (data) {
currentResults = data;
renderResults(data, resultsDiv);
})
.catch(function (e) {
resultsDiv.innerHTML = '';
errDiv.textContent = e.message;
});
}
function renderResults(data, div) {
if (!data.candidates || data.candidates.length === 0) {
div.innerHTML = '
No candidates found. The prefixes may not match any known path-eligible nodes.
';
return;
}
var html = '
' +
'
#
Score
Path
Action
' +
'
';
for (var i = 0; i < data.candidates.length; i++) {
var c = data.candidates[i];
var rowClass = c.speculative ? 'speculative-row' : '';
html += '
';
div.innerHTML = html;
// Wire up Show on Map buttons.
div.querySelectorAll('button[data-idx]').forEach(function (btn) {
btn.addEventListener('click', function () {
var idx = parseInt(btn.dataset.idx);
showOnMap(data.candidates[idx]);
});
});
// Wire up row expand for evidence.
div.querySelectorAll('.path-inspector-table tbody tr:not(.evidence-row)').forEach(function (row) {
row.style.cursor = 'pointer';
row.addEventListener('click', function (e) {
if (e.target.tagName === 'BUTTON') return;
var idx = row.querySelector('button[data-idx]');
if (!idx) return;
var evidenceRow = div.querySelector('tr[data-evidence="' + idx.dataset.idx + '"]');
if (evidenceRow) evidenceRow.classList.toggle('collapsed');
});
});
}
function showOnMap(candidate) {
// Store pending route for map init to pick up.
window._pendingPathInspectorRoute = candidate;
// Switch to map page if not there; map init will draw the route.
if (location.hash.indexOf('#/map') !== 0) {
location.hash = '#/map';
} else {
// Already on map — draw directly.
delete window._pendingPathInspectorRoute;
if (window.routeLayer) window.routeLayer.clearLayers();
// Pass FULL path as hopKeys (not slice(1)) — drawPacketRoute resolves
// each entry against nodes[] for plotting. The 2nd arg is the origin
// OBJECT (with pubkey/lat/lon/name); pass null since the origin is
// already the first hop in the path itself, and drawPacketRoute draws
// a marker for every resolved hop.
if (window.drawPacketRoute) window.drawPacketRoute(candidate.path, null);
}
}
function escapeAttr(s) {
return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>');
}
window.PathInspector = { init: init, destroy: destroy, parsePrefixes: parsePrefixes, validatePrefixes: validatePrefixes };
if (typeof registerPage === 'function') registerPage('path-inspector', { init: init, destroy: destroy });
})();