Skip to content

Commit d8d3427

Browse files
MarekWoclaude
andcommitted
feat(pathanalyzer): chat route click opens the analyzer map deep-linked
Clicking a route in the chat path popup now opens Path Analyzer on the Map view with that message selected and that exact echo path drawn, instead of copying the route to the clipboard. Copy stays available as a small per-route clipboard icon in the popup. Deep link flow: popup click stores {packet_hash, path hex} in window.paDeepLink; the modal show handler builds the iframe URL with ?hash=&path=; the analyzer resolves the message after load (widening the time range once to 7 days if needed), matches the echo by raw path hex (fallback: shortest), and switches to the map. A plain menu open still loads the analyzer without any deep link. Verified live via Playwright: clicked the 3rd (non-shortest) route of a multi-route message - the map opened with exactly that echo selected, including the 3-to-7-day widening retry (message was 4 days old). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bb67c4d commit d8d3427

4 files changed

Lines changed: 121 additions & 11 deletions

File tree

app/static/css/style.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,6 +1557,25 @@ main {
15571557
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
15581558
word-break: break-all;
15591559
cursor: pointer;
1560+
display: flex;
1561+
align-items: flex-start;
1562+
gap: 0.4rem;
1563+
}
1564+
1565+
.path-popup .path-route {
1566+
flex: 1 1 auto;
1567+
min-width: 0;
1568+
}
1569+
1570+
.path-popup .path-copy {
1571+
flex: 0 0 auto;
1572+
padding: 0.1rem 0.15rem;
1573+
opacity: 0.7;
1574+
cursor: pointer;
1575+
}
1576+
1577+
.path-popup .path-copy:hover {
1578+
opacity: 1;
15601579
}
15611580

15621581
.path-popup .path-entry:active {

app/static/js/app.js

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,7 +1187,7 @@ function updateMessageMetaDOM(wrapper, meta) {
11871187
: segments.join('\u2192');
11881188
const pathsData = encodeURIComponent(JSON.stringify(paths));
11891189
const routeLabel = paths.length > 1 ? `Route (${paths.length})` : 'Route';
1190-
metaParts.push(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}')">${routeLabel}: ${shortPath}</span>`);
1190+
metaParts.push(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}', '${meta.packet_hash || ''}')">${routeLabel}: ${shortPath}</span>`);
11911191
}
11921192
const metaInfo = metaParts.join(' | ');
11931193

@@ -1329,7 +1329,7 @@ function createMessageElement(msg) {
13291329
: segments.join('\u2192');
13301330
const pathsData = encodeURIComponent(JSON.stringify(msg.paths));
13311331
const routeLabel = msg.paths.length > 1 ? `Route (${msg.paths.length})` : 'Route';
1332-
metaParts.push(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}')">${routeLabel}: ${shortPath}</span>`);
1332+
metaParts.push(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}', '${msg.packet_hash || ''}')">${routeLabel}: ${shortPath}</span>`);
13331333
}
13341334
const metaInfo = metaParts.join(' | ');
13351335

@@ -1693,7 +1693,7 @@ async function blockContactFromChat(senderName) {
16931693
/**
16941694
* Show paths popup on tap (mobile-friendly, shows all routes)
16951695
*/
1696-
function showPathsPopup(element, encodedPaths) {
1696+
function showPathsPopup(element, encodedPaths, packetHash) {
16971697
// Remove any existing popup
16981698
const existing = document.querySelector('.path-popup');
16991699
if (existing) existing.remove();
@@ -1717,16 +1717,42 @@ function showPathsPopup(element, encodedPaths) {
17171717
const hops = segments.length;
17181718
const entry = document.createElement('div');
17191719
entry.className = 'path-entry';
1720-
entry.innerHTML = `${fullRoute}<span class="path-detail">SNR: ${snr} | Hops: ${hops}</span>`;
1721-
entry.title = 'Tap to copy route';
1722-
entry.addEventListener('click', (e) => {
1720+
1721+
const body = document.createElement('span');
1722+
body.className = 'path-route';
1723+
body.innerHTML = `${fullRoute}<span class="path-detail">SNR: ${snr} | Hops: ${hops}</span>`;
1724+
entry.appendChild(body);
1725+
1726+
const copyBtn = document.createElement('i');
1727+
copyBtn.className = 'bi bi-clipboard path-copy';
1728+
copyBtn.title = 'Copy route';
1729+
copyBtn.addEventListener('click', (e) => {
17231730
e.stopPropagation();
17241731
navigator.clipboard.writeText(commaRoute).then(() => {
1725-
const orig = entry.innerHTML;
1726-
entry.innerHTML = '<span style="opacity:0.8">Copied!</span>';
1727-
setTimeout(() => { entry.innerHTML = orig; }, 1000);
1732+
copyBtn.className = 'bi bi-clipboard-check path-copy';
1733+
setTimeout(() => { copyBtn.className = 'bi bi-clipboard path-copy'; }, 1000);
17281734
});
17291735
});
1736+
entry.appendChild(copyBtn);
1737+
1738+
if (packetHash && p.path && segments.length > 0) {
1739+
entry.title = 'Show this route on the Path Analyzer map';
1740+
entry.addEventListener('click', (e) => {
1741+
e.stopPropagation();
1742+
popup.remove();
1743+
openPathInAnalyzer(packetHash, p.path);
1744+
});
1745+
} else {
1746+
// No packet hash (or direct message): keep the copy behavior
1747+
entry.title = 'Tap to copy route';
1748+
entry.addEventListener('click', (e) => {
1749+
e.stopPropagation();
1750+
navigator.clipboard.writeText(commaRoute).then(() => {
1751+
copyBtn.className = 'bi bi-clipboard-check path-copy';
1752+
setTimeout(() => { copyBtn.className = 'bi bi-clipboard path-copy'; }, 1000);
1753+
});
1754+
});
1755+
}
17301756
popup.appendChild(entry);
17311757
});
17321758

@@ -1757,6 +1783,18 @@ function showPathsPopup(element, encodedPaths) {
17571783
});
17581784
}
17591785

1786+
/**
1787+
* Open the Path Analyzer modal deep-linked to one message + echo path.
1788+
* The modal's show handler (index.html) reads window.paDeepLink and builds
1789+
* the iframe URL from it.
1790+
*/
1791+
function openPathInAnalyzer(packetHash, pathHex) {
1792+
const modalEl = document.getElementById('pathAnalyzerModal');
1793+
if (!modalEl) return;
1794+
window.paDeepLink = { hash: packetHash, path: pathHex };
1795+
bootstrap.Modal.getOrCreateInstance(modalEl).show();
1796+
}
1797+
17601798
/**
17611799
* Load connection status
17621800
*/

app/static/js/path-analyzer.js

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ let paCurrentView = 'messages'; // 'messages' | 'stats' | 'routes' | 'map'
8888
let paContacts = []; // /api/contacts/cached?format=full
8989
let paStatsSort = { key: 'relayed', dir: -1 };
9090
let paRoutesSort = { key: 'echoes', dir: -1 };
91+
let paDeepLink = null; // ?hash=..&path=.. from a chat path popup
92+
let paContactsReady = Promise.resolve();
9193

9294
async function paLoadContacts() {
9395
try {
@@ -978,6 +980,45 @@ async function paLoadMessages() {
978980
} else {
979981
paRender();
980982
}
983+
984+
if (paDeepLink) paApplyDeepLink();
985+
}
986+
987+
// Deep link from the chat path popup: jump to the map view with the
988+
// linked message selected and the linked echo path drawn.
989+
async function paApplyDeepLink() {
990+
const dl = paDeepLink;
991+
const hash = (dl.hash || '').toLowerCase();
992+
const msg = paMessages.find(m => (m.packet_hash || '').toLowerCase() === hash);
993+
994+
if (!msg) {
995+
// The chat can show messages older than the default window - widen
996+
// to the max range once before giving up
997+
const daysSel = document.getElementById('paDaysSelect');
998+
if (!dl.retried && daysSel.value !== '7') {
999+
dl.retried = true;
1000+
daysSel.value = '7';
1001+
paLoadMessages(); // re-enters paApplyDeepLink when done
1002+
return;
1003+
}
1004+
paDeepLink = null;
1005+
showNotification('This message is no longer in the analyzer data (max 7 days).', 'warning');
1006+
return;
1007+
}
1008+
1009+
paDeepLink = null;
1010+
await paContactsReady; // map needs contacts to resolve hop positions
1011+
1012+
let echoIdx = msg.echoView.findIndex(e =>
1013+
e.hops > 0 && (e.path || '').toLowerCase() === (dl.path || '').toLowerCase());
1014+
if (echoIdx === -1) echoIdx = paShortestEchoIdx(msg);
1015+
if (echoIdx === null) {
1016+
showNotification('This message has no routed echoes to draw.', 'warning');
1017+
return;
1018+
}
1019+
1020+
paMapSelection = { msgId: msg.id, echoIdx: echoIdx };
1021+
paSwitchView('map');
9811022
}
9821023

9831024
// ================================================================
@@ -1024,6 +1065,12 @@ function paClearFilters() {
10241065

10251066
document.addEventListener('DOMContentLoaded', () => {
10261067
loadUiSettings();
1068+
1069+
const qs = new URLSearchParams(window.location.search);
1070+
if (qs.get('hash')) {
1071+
paDeepLink = { hash: qs.get('hash'), path: qs.get('path') || '' };
1072+
}
1073+
10271074
document.getElementById('paDaysSelect').addEventListener('change', paLoadMessages);
10281075
document.getElementById('paRefreshBtn').addEventListener('click', paLoadMessages);
10291076

@@ -1068,6 +1115,6 @@ document.addEventListener('DOMContentLoaded', () => {
10681115
});
10691116
});
10701117

1071-
paLoadContacts();
1118+
paContactsReady = paLoadContacts();
10721119
paLoadMessages();
10731120
});

app/templates/index.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,13 @@ <h5 class="modal-title text-white"><i class="bi bi-journal-text"></i> System Log
350350
pathAnalyzerModal.addEventListener('show.bs.modal', function () {
351351
const pathAnalyzerFrame = document.getElementById('pathAnalyzerFrame');
352352
if (pathAnalyzerFrame) {
353-
pathAnalyzerFrame.src = '/path-analyzer';
353+
// Deep link from a chat path popup (openPathInAnalyzer):
354+
// preselect the message + echo on the map view
355+
const dl = window.paDeepLink;
356+
window.paDeepLink = null;
357+
pathAnalyzerFrame.src = dl
358+
? `/path-analyzer?hash=${encodeURIComponent(dl.hash)}&path=${encodeURIComponent(dl.path)}`
359+
: '/path-analyzer';
354360
}
355361
});
356362
}

0 commit comments

Comments
 (0)