feat(pathanalyzer): Routes view - hop segment statistics + sequence filter

New 4th view counting consecutive hop segments (n-grams) across all
routed echo paths, regardless of position in the path:
- segment length selector (2/3/4 hops), table sorted by echo count by
  default; columns: Echoes, distinct Messages, As path end (how often
  the segment is the final part of the path reaching us)
- resolved contact names shown under the hash chips (ambiguous/unknown
  marked like elsewhere)
- row click fills the repeater filter with the segment and jumps to the
  message list
- the repeater filter now accepts consecutive sequences chained with
  > or an arrow (e.g. AFE6>6E9A or hash>name); single values behave
  exactly as before, spaces stay usable inside contact names
- view switcher buttons show icons only on xs screens so 4 buttons fit

Idea by Daniel - group paths by recurring hop sequences to see which
routes carry the most traffic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-21 21:38:47 +02:00
parent 0d9d9ecd57
commit afb305a61e
2 changed files with 179 additions and 16 deletions
+145 -12
View File
@@ -84,9 +84,10 @@ function showNotification(message, type = 'info') {
let paMessages = [];
let paFilters = { hops: 'any', hashSize: 'any', token: '', sender: '', content: '' };
let paCurrentView = 'messages'; // 'messages' | 'stats'
let paCurrentView = 'messages'; // 'messages' | 'stats' | 'routes' | 'map'
let paContacts = []; // /api/contacts/cached?format=full
let paStatsSort = { key: 'relayed', dir: -1 };
let paRoutesSort = { key: 'echoes', dir: -1 };
async function paLoadContacts() {
try {
@@ -135,6 +136,13 @@ function paDecodeEcho(echo) {
return { ...echo, tokens: tokens, hops: tokens.length };
}
// One filter element (lowercase) vs one path token: hex input matches the
// hash by prefix; any input also matches resolved contact names
function paTokenMatchesElement(tok, el) {
return (/^[0-9a-f]+$/.test(el) && tok.startsWith(el.toUpperCase()))
|| paTokenNames(tok).some(n => n.includes(el));
}
function paMessageMatchesFilters(msg) {
if (paFilters.hops !== 'any') {
const want = paFilters.hops;
@@ -150,15 +158,16 @@ function paMessageMatchesFilters(msg) {
if (!ok) return false;
}
if (paFilters.token) {
const raw = paFilters.token; // lowercase
const isHex = /^[0-9a-f]+$/.test(raw);
const hexPrefix = raw.toUpperCase();
// Hex input matches hash tokens by prefix; any input also matches
// the names of contacts the token resolves to
const ok = msg.echoView.some(e => e.tokens.some(tok =>
(isHex && tok.startsWith(hexPrefix)) ||
paTokenNames(tok).some(n => n.includes(raw))
));
// '>' (or '→') chains elements into a consecutive-sequence match;
// a single element behaves as before. Spaces are NOT separators -
// contact names may contain them.
const els = paFilters.token.split(/[>→]/).map(s => s.trim()).filter(Boolean);
const ok = els.length > 0 && msg.echoView.some(e => {
for (let i = 0; i + els.length <= e.tokens.length; i++) {
if (els.every((el, j) => paTokenMatchesElement(e.tokens[i + j], el))) return true;
}
return false;
});
if (!ok) return false;
}
if (paFilters.sender) {
@@ -378,6 +387,7 @@ function paSetView(state) {
document.getElementById('paEmpty').classList.toggle('d-none', state !== 'empty');
document.getElementById('paTableWrap').classList.toggle('d-none', state !== 'table');
document.getElementById('paStatsWrap').classList.toggle('d-none', state !== 'stats');
document.getElementById('paRoutesWrap').classList.toggle('d-none', state !== 'routes');
document.getElementById('paMapWrap').classList.toggle('d-none', state !== 'map');
}
@@ -435,7 +445,7 @@ function paRenderStats() {
});
// Show sort direction on the active header
document.querySelectorAll('.pa-sortable').forEach(th => {
document.querySelectorAll('#paStatsWrap .pa-sortable').forEach(th => {
th.textContent = th.textContent.replace(/ [▲▼]$/, '')
+ (th.dataset.sort === key ? (dir === -1 ? ' ▼' : ' ▲') : '');
});
@@ -496,6 +506,113 @@ function paRenderStats() {
}
}
// ================================================================
// Route segment statistics (consecutive hop n-grams)
// ================================================================
function paComputeRoutes(filtered, n) {
const routes = new Map(); // 'A→B→...' -> aggregate
for (const msg of filtered) {
for (const e of msg.echoView) {
const seen = new Set(); // count each segment once per echo
for (let i = 0; i + n <= e.tokens.length; i++) {
const key = e.tokens.slice(i, i + n).join('→');
let r = routes.get(key);
if (!r) {
r = { tokens: e.tokens.slice(i, i + n), echoes: 0, msgIds: new Set(), pathEnd: 0 };
routes.set(key, r);
}
if (!seen.has(key)) {
seen.add(key);
r.echoes++;
r.msgIds.add(msg.id);
}
if (i + n === e.tokens.length) r.pathEnd++;
}
}
}
return [...routes.values()].map(r => ({
tokens: r.tokens,
echoes: r.echoes,
messages: r.msgIds.size,
pathEnd: r.pathEnd,
}));
}
function paRenderRoutes() {
const body = document.getElementById('paRoutesBody');
body.innerHTML = '';
const n = parseInt(document.getElementById('paSegLenSelect').value, 10);
const filtered = paMessages.filter(paMessageMatchesFilters);
const rows = paComputeRoutes(filtered, n);
const { key, dir } = paRoutesSort;
rows.sort((a, b) => (a[key] - b[key]) * dir || b.echoes - a.echoes);
// Show sort direction on the active header
document.querySelectorAll('#paRoutesWrap .pa-sortable').forEach(th => {
th.textContent = th.textContent.replace(/ [▲▼]$/, '')
+ (th.dataset.sort === key ? (dir === -1 ? ' ▼' : ' ▲') : '');
});
for (const r of rows) {
const tr = document.createElement('tr');
tr.className = 'pa-stats-row';
tr.title = 'Filter messages by this segment';
const tdRoute = document.createElement('td');
const hashLine = document.createElement('div');
hashLine.innerHTML = r.tokens
.map(t => `<span class="pa-hash">${t}</span>`)
.join(' <span class="pa-chip-arrow">→</span> ');
tdRoute.appendChild(hashLine);
// Resolved contact names beneath the hashes (skipped when nothing resolves)
const names = r.tokens.map(tok => {
const cands = paMatchContacts(tok);
if (cands.length === 1) return cands[0].name || '—';
return cands.length > 1 ? `ambiguous (${cands.length})` : '—';
});
if (names.some(nm => nm !== '—')) {
const nameLine = document.createElement('div');
nameLine.className = 'small text-muted';
nameLine.textContent = names.join(' → ');
tdRoute.appendChild(nameLine);
}
tr.appendChild(tdRoute);
for (const [val, cls] of [[r.echoes, ''], [r.messages, ' pa-col-msgs'], [r.pathEnd, '']]) {
const td = document.createElement('td');
td.className = 'text-end' + cls;
td.textContent = val;
tr.appendChild(td);
}
// Click -> apply this segment as a sequence filter and jump to the list
tr.addEventListener('click', () => {
document.getElementById('paTokenFilter').value = r.tokens.join('>');
paSwitchView('messages');
});
body.appendChild(tr);
}
const counter = document.getElementById('paCounter');
counter.textContent = paFiltersActive()
? `${rows.length} segments (${filtered.length} of ${paMessages.length} messages)`
: `${rows.length} segments (${paMessages.length} messages)`;
if (rows.length === 0) {
document.getElementById('paEmptyText').textContent =
filtered.length === 0 ? 'No messages match the current filters.'
: `No paths with at least ${n} hops in the current selection.`;
paSetView('empty');
} else {
paSetView('routes');
}
}
// ================================================================
// Map view
// ================================================================
@@ -812,6 +929,8 @@ function paClearMapSelection() {
function paRender() {
if (paCurrentView === 'stats') {
paRenderStats();
} else if (paCurrentView === 'routes') {
paRenderRoutes();
} else if (paCurrentView === 'map') {
paRenderMapView();
} else {
@@ -823,6 +942,7 @@ function paSwitchView(view) {
paCurrentView = view;
for (const [btnId, v] of [['paViewMessagesBtn', 'messages'],
['paViewStatsBtn', 'stats'],
['paViewRoutesBtn', 'routes'],
['paViewMapBtn', 'map']]) {
document.getElementById(btnId).className =
'btn ' + (view === v ? 'btn-primary' : 'btn-outline-primary');
@@ -921,9 +1041,11 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('paViewMessagesBtn').addEventListener('click', () => paSwitchView('messages'));
document.getElementById('paViewStatsBtn').addEventListener('click', () => paSwitchView('stats'));
document.getElementById('paViewRoutesBtn').addEventListener('click', () => paSwitchView('routes'));
document.getElementById('paViewMapBtn').addEventListener('click', () => paSwitchView('map'));
document.getElementById('paMapClearBtn').addEventListener('click', paClearMapSelection);
document.querySelectorAll('.pa-sortable').forEach(th => {
document.getElementById('paSegLenSelect').addEventListener('change', paApplyFilters);
document.querySelectorAll('#paStatsWrap .pa-sortable').forEach(th => {
th.addEventListener('click', () => {
const k = th.dataset.sort;
if (paStatsSort.key === k) {
@@ -934,6 +1056,17 @@ document.addEventListener('DOMContentLoaded', () => {
paRenderStats();
});
});
document.querySelectorAll('#paRoutesWrap .pa-sortable').forEach(th => {
th.addEventListener('click', () => {
const k = th.dataset.sort;
if (paRoutesSort.key === k) {
paRoutesSort.dir = -paRoutesSort.dir;
} else {
paRoutesSort = { key: k, dir: -1 };
}
paRenderRoutes();
});
});
paLoadContacts();
paLoadMessages();
+34 -4
View File
@@ -511,13 +511,16 @@
<div class="pa-toolbar">
<div class="btn-group btn-group-sm" role="group" aria-label="View">
<button id="paViewMessagesBtn" class="btn btn-primary" title="Message list">
<i class="bi bi-list-ul"></i> Messages
<i class="bi bi-list-ul"></i><span class="d-none d-sm-inline"> Messages</span>
</button>
<button id="paViewStatsBtn" class="btn btn-outline-primary" title="Per-repeater statistics">
<i class="bi bi-bar-chart"></i> Repeaters
<i class="bi bi-bar-chart"></i><span class="d-none d-sm-inline"> Repeaters</span>
</button>
<button id="paViewRoutesBtn" class="btn btn-outline-primary" title="Route segment statistics">
<i class="bi bi-signpost-2"></i><span class="d-none d-sm-inline"> Routes</span>
</button>
<button id="paViewMapBtn" class="btn btn-outline-primary" title="Map view">
<i class="bi bi-map"></i> Map
<i class="bi bi-map"></i><span class="d-none d-sm-inline"> Map</span>
</button>
</div>
<button id="paFiltersToggleBtn" class="btn btn-sm btn-outline-secondary d-md-none" type="button"
@@ -557,7 +560,7 @@
</select>
<input id="paTokenFilter" type="text" class="form-control form-control-sm" style="width: 10.5rem;"
placeholder="Repeater (3B or name)"
title="Match a repeater anywhere in a path: hash prefix (hex) or contact name">
title="Match a repeater anywhere in a path: hash prefix (hex) or contact name. Chain with '>' for a consecutive sequence, e.g. AFE6>6E9A">
<input id="paSenderFilter" type="text" class="form-control form-control-sm" style="width: 9.5rem;"
placeholder="Sender" title="Filter by sender name">
<input id="paContentFilter" type="text" class="form-control form-control-sm" style="width: 11rem;"
@@ -616,6 +619,33 @@
<tbody id="paStatsBody"></tbody>
</table>
</div>
<div id="paRoutesWrap" class="d-none">
<div class="d-flex align-items-center gap-2 mb-2">
<label for="paSegLenSelect" class="form-label small text-muted mb-0">Segment length:</label>
<select id="paSegLenSelect" class="form-select form-select-sm" style="width: auto;"
title="Number of consecutive hops per counted segment">
<option value="2" selected>2 hops</option>
<option value="3">3 hops</option>
<option value="4">4 hops</option>
</select>
</div>
<div class="pa-table-wrap">
<table class="table table-hover pa-table mb-0">
<thead>
<tr>
<th>Route</th>
<th class="text-end pa-sortable" data-sort="echoes"
title="Echoes whose path contains this segment (anywhere)">Echoes</th>
<th class="text-end pa-sortable pa-col-msgs" data-sort="messages"
title="Distinct messages whose path contains this segment">Messages</th>
<th class="text-end pa-sortable" data-sort="pathEnd"
title="Echoes where this segment is the end of the path - the route the message took to reach us">As path end</th>
</tr>
</thead>
<tbody id="paRoutesBody"></tbody>
</table>
</div>
</div>
<div id="paMapWrap" class="d-none">
<div id="paMapSidebar">
<div class="d-flex align-items-center justify-content-between mb-1">