mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-26 19:42:43 +02:00
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:
+145
-12
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user