feat(pathanalyzer): path display and filters (stage 3)

- Message rows expand to show each echo as per-hop repeater hash chips
  (A3 -> 3B -> 5E) with direction badge, SNR and received time; tokens
  split per that echo's own hash_size (same logic as showPathsPopup),
  0-hop echoes shown as 'Direct (flood, 0 hops)'
- Chip click copies the repeater hash, echo line click copies the comma
  route (parity with the chat path popup)
- Filter bar (client-side, combinable, 150ms debounce): hop count
  (any/0/1/2/3/4+, matches if any echo has that hop count), repeater
  hash token (hex-normalized prefix match, bridges mixed hash sizes),
  sender substring; Clear button appears when any filter is active
- 'N of M messages' counter and a dedicated empty state when filters
  match nothing

Verified live via Playwright: expand/collapse, both copy actions,
hops=2 yields only rows with a 2-hop echo (39/602), combined
token+hops filter, sender filter, clear resets to full set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-19 19:01:23 +02:00
parent 9668651a61
commit 07d2c347ba
2 changed files with 261 additions and 7 deletions
+168 -6
View File
@@ -83,6 +83,41 @@ function showNotification(message, type = 'info') {
// ================================================================
let paMessages = [];
let paFilters = { hops: 'any', token: '', sender: '' };
// Split an echo's path hex into per-hop tokens using that echo's own
// hash_size (same logic as showPathsPopup in app.js; trailing partial kept)
function paDecodeEcho(echo) {
const chunkLen = (echo.hash_size || 1) * 2;
const tokens = [];
const hex = echo.path || '';
for (let i = 0; i < hex.length; i += chunkLen) {
tokens.push(hex.substring(i, i + chunkLen).toUpperCase());
}
return { ...echo, tokens: tokens, hops: tokens.length };
}
function paMessageMatchesFilters(msg) {
if (paFilters.hops !== 'any') {
const want = paFilters.hops;
const ok = msg.echoView.some(e =>
want === '4+' ? e.hops >= 4 : e.hops === parseInt(want, 10));
if (!ok) return false;
}
if (paFilters.token) {
const t = paFilters.token;
const ok = msg.echoView.some(e => e.tokens.some(tok => tok.startsWith(t)));
if (!ok) return false;
}
if (paFilters.sender) {
if (!(msg.sender || '').toLowerCase().includes(paFilters.sender)) return false;
}
return true;
}
function paFiltersActive() {
return paFilters.hops !== 'any' || paFilters.token !== '' || paFilters.sender !== '';
}
function paFormatTime(msg) {
if (!msg.timestamp) return '—';
@@ -99,12 +134,65 @@ function paCopyText(text, label) {
);
}
function paBuildEchoLine(echo) {
const line = document.createElement('div');
line.className = 'pa-echo-line';
line.title = 'Click to copy route';
const dirBadge = document.createElement('span');
dirBadge.className = 'badge ' + (echo.direction === 'outgoing' ? 'text-bg-primary' : 'text-bg-secondary');
dirBadge.textContent = echo.direction === 'outgoing' ? 'out' : 'in';
line.appendChild(dirBadge);
if (echo.hops === 0) {
const direct = document.createElement('span');
direct.className = 'pa-direct';
direct.textContent = 'Direct (flood, 0 hops)';
line.appendChild(direct);
} else {
echo.tokens.forEach((tok, i) => {
if (i > 0) {
const arrow = document.createElement('i');
arrow.className = 'bi bi-arrow-right pa-chip-arrow';
line.appendChild(arrow);
}
const chip = document.createElement('span');
chip.className = 'pa-chip';
chip.textContent = tok;
chip.title = `Copy repeater hash ${tok}`;
chip.addEventListener('click', (e) => {
e.stopPropagation();
paCopyText(tok, 'Repeater hash');
});
line.appendChild(chip);
});
}
const meta = document.createElement('span');
meta.className = 'pa-echo-meta ms-1';
const snr = (echo.snr === null || echo.snr === undefined) ? '?' : `${Number(echo.snr).toFixed(1)} dB`;
meta.textContent = `SNR: ${snr} | ${echo.received_at || ''}`;
line.appendChild(meta);
line.addEventListener('click', () => {
paCopyText(echo.tokens.join(','), 'Route');
});
return line;
}
function paRenderTable() {
const body = document.getElementById('paTableBody');
body.innerHTML = '';
for (const msg of paMessages) {
const filtered = paMessages.filter(paMessageMatchesFilters);
for (const msg of filtered) {
const tr = document.createElement('tr');
tr.className = 'pa-msg-row' + (msg.echoView.length === 0 ? ' pa-no-echoes' : '');
const tdCaret = document.createElement('td');
tdCaret.innerHTML = '<i class="bi bi-chevron-right pa-caret"></i>';
tr.appendChild(tdCaret);
const tdTime = document.createElement('td');
tdTime.className = 'pa-time';
@@ -133,7 +221,10 @@ function paRenderTable() {
span.className = 'pa-hash';
span.textContent = msg.packet_hash;
span.title = 'Click to copy';
span.addEventListener('click', () => paCopyText(msg.packet_hash, 'Packet hash'));
span.addEventListener('click', (e) => {
e.stopPropagation();
paCopyText(msg.packet_hash, 'Packet hash');
});
tdHash.appendChild(span);
} else {
tdHash.innerHTML = '<span class="text-muted small">no path data</span>';
@@ -147,14 +238,44 @@ function paRenderTable() {
const tdEchoes = document.createElement('td');
tdEchoes.className = 'text-end';
tdEchoes.textContent = msg.echoes.length;
tdEchoes.textContent = msg.echoView.length;
tr.appendChild(tdEchoes);
body.appendChild(tr);
if (msg.echoView.length > 0) {
const detailTr = document.createElement('tr');
detailTr.className = 'd-none';
const detailTd = document.createElement('td');
detailTd.className = 'pa-echo-cell';
detailTd.colSpan = 8;
for (const echo of msg.echoView) {
detailTd.appendChild(paBuildEchoLine(echo));
}
detailTr.appendChild(detailTd);
body.appendChild(detailTr);
tr.addEventListener('click', () => {
tr.classList.toggle('pa-open');
detailTr.classList.toggle('d-none');
});
}
}
document.getElementById('paCounter').textContent =
`${paMessages.length} message${paMessages.length === 1 ? '' : 's'}`;
const counter = document.getElementById('paCounter');
counter.textContent = paFiltersActive()
? `${filtered.length} of ${paMessages.length} messages`
: `${paMessages.length} message${paMessages.length === 1 ? '' : 's'}`;
// Empty state when filters exclude everything
if (paMessages.length > 0) {
if (filtered.length === 0) {
document.getElementById('paEmptyText').textContent = 'No messages match the current filters.';
paSetView('empty');
} else {
paSetView('table');
}
}
}
function paSetView(state) {
@@ -176,6 +297,9 @@ async function paLoadMessages() {
}
// Newest first for the analysis table (API returns ascending)
paMessages = (data.messages || []).slice().reverse();
paMessages.forEach(msg => {
msg.echoView = (msg.echoes || []).map(paDecodeEcho);
});
} catch (e) {
console.error('Failed to load messages:', e);
showNotification(`Failed to load messages: ${e.message}`, 'danger');
@@ -183,13 +307,40 @@ async function paLoadMessages() {
}
if (paMessages.length === 0) {
document.getElementById('paEmptyText').textContent = 'No messages in the selected time range.';
paSetView('empty');
} else {
paRenderTable();
paSetView('table');
}
}
// ================================================================
// Filters
// ================================================================
function paReadFilters() {
paFilters.hops = document.getElementById('paHopsFilter').value;
// Normalize token input to hex characters only (matches chip display casing)
paFilters.token = document.getElementById('paTokenFilter').value
.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
paFilters.sender = document.getElementById('paSenderFilter').value.trim().toLowerCase();
document.getElementById('paClearFiltersBtn').classList.toggle('d-none', !paFiltersActive());
}
function paApplyFilters() {
paReadFilters();
if (paMessages.length > 0) {
paRenderTable();
}
}
function paClearFilters() {
document.getElementById('paHopsFilter').value = 'any';
document.getElementById('paTokenFilter').value = '';
document.getElementById('paSenderFilter').value = '';
paApplyFilters();
}
// ================================================================
// Init
// ================================================================
@@ -198,5 +349,16 @@ document.addEventListener('DOMContentLoaded', () => {
loadUiSettings();
document.getElementById('paDaysSelect').addEventListener('change', paLoadMessages);
document.getElementById('paRefreshBtn').addEventListener('click', paLoadMessages);
let filterDebounce = null;
const debouncedApply = () => {
clearTimeout(filterDebounce);
filterDebounce = setTimeout(paApplyFilters, 150);
};
document.getElementById('paHopsFilter').addEventListener('change', paApplyFilters);
document.getElementById('paTokenFilter').addEventListener('input', debouncedApply);
document.getElementById('paSenderFilter').addEventListener('input', debouncedApply);
document.getElementById('paClearFiltersBtn').addEventListener('click', paClearFilters);
paLoadMessages();
});
+93 -1
View File
@@ -103,6 +103,81 @@
white-space: nowrap;
}
.pa-caret {
display: inline-block;
transition: transform 0.15s;
font-size: 0.7rem;
width: 1rem;
}
tr.pa-open .pa-caret {
transform: rotate(90deg);
}
.pa-msg-row {
cursor: pointer;
}
.pa-msg-row.pa-no-echoes {
cursor: default;
}
.pa-msg-row.pa-no-echoes .pa-caret {
visibility: hidden;
}
.pa-echo-cell {
background-color: var(--bg-surface, rgba(0, 0, 0, 0.03));
padding: 0.35rem 0.75rem 0.35rem 2rem;
}
.pa-echo-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.2rem 0;
cursor: pointer;
border-radius: 0.25rem;
}
.pa-echo-line:hover {
background-color: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
.pa-echo-meta {
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
white-space: nowrap;
}
.pa-chip {
font-family: var(--bs-font-monospace, monospace);
font-size: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
padding: 0.05rem 0.35rem;
cursor: pointer;
background-color: var(--card-bg, transparent);
}
.pa-chip:hover {
background-color: #6f42c1;
border-color: #6f42c1;
color: #fff;
}
.pa-chip-arrow {
font-size: 0.7rem;
color: var(--text-secondary, #6c757d);
}
.pa-direct {
font-size: 0.75rem;
font-style: italic;
color: var(--text-secondary, #6c757d);
}
.empty-state {
text-align: center;
color: var(--text-secondary, #6c757d);
@@ -129,6 +204,22 @@
<button id="paRefreshBtn" class="btn btn-sm btn-outline-secondary" title="Reload">
<i class="bi bi-arrow-clockwise"></i>
</button>
<div class="vr d-none d-sm-block"></div>
<select id="paHopsFilter" class="form-select form-select-sm" style="width: auto;" title="Filter by hop count (any echo)">
<option value="any" selected>Any hops</option>
<option value="0">0 hops</option>
<option value="1">1 hop</option>
<option value="2">2 hops</option>
<option value="3">3 hops</option>
<option value="4+">4+ hops</option>
</select>
<input id="paTokenFilter" type="text" class="form-control form-control-sm" style="width: 9.5rem;"
placeholder="Repeater hash (3B)" title="Match a repeater hash anywhere in a path (prefix match)">
<input id="paSenderFilter" type="text" class="form-control form-control-sm" style="width: 9.5rem;"
placeholder="Sender" title="Filter by sender name">
<button id="paClearFiltersBtn" class="btn btn-sm btn-outline-secondary d-none" title="Clear filters">
<i class="bi bi-x-lg"></i> Clear
</button>
<span id="paCounter" class="small text-muted ms-auto"></span>
</div>
@@ -140,12 +231,13 @@
</div>
<div id="paEmpty" class="empty-state d-none">
<i class="bi bi-signpost-split"></i>
No messages in the selected time range.
<span id="paEmptyText">No messages in the selected time range.</span>
</div>
<div id="paTableWrap" class="pa-table-wrap d-none">
<table class="table table-hover pa-table mb-0">
<thead>
<tr>
<th style="width: 1.5rem;"></th>
<th>Time</th>
<th>Channel</th>
<th>Sender</th>