mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-06 00:33:09 +02:00
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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user