mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-26 19:42:43 +02:00
0ba0503547
The repeater filter input now accepts either form: pure-hex input still prefix-matches hop hash tokens, and any input also matches the names of contacts each token resolves to (pubkey-prefix candidates, memoized per token). With 1-byte hashes a name can match via a colliding candidate, so the filter is intentionally inclusive: it keeps messages where the named repeater could be on the path. Verified live via Playwright: hex prefix regression OK, name search returns only messages with a token resolving to the named contact, memoized pass over 606 messages takes <1 ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
842 lines
32 KiB
JavaScript
842 lines
32 KiB
JavaScript
// Path Analyzer panel
|
|
// - bulk channel messages across all channels (GET /api/path-analyzer/messages)
|
|
// - stage 2: days selector + flat message table
|
|
// - later stages: path detail rows, filters, per-repeater stats, map view
|
|
|
|
// ================================================================
|
|
// UI settings + toast (same behavior as repeaters.js)
|
|
// ================================================================
|
|
|
|
const PA_UI_SETTINGS_DEFAULTS = {
|
|
toast_timeout_sec: 2,
|
|
toast_no_autoclose: false,
|
|
toast_position: 'top-left'
|
|
};
|
|
|
|
const PA_TOAST_POSITION_CLASSES = {
|
|
'top-left': ['top-0', 'start-0'],
|
|
'top-right': ['top-0', 'end-0'],
|
|
'bottom-left': ['bottom-0', 'start-0'],
|
|
'bottom-right': ['bottom-0', 'end-0'],
|
|
'center': ['top-50', 'start-50', 'translate-middle']
|
|
};
|
|
const PA_ALL_POSITION_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle'];
|
|
|
|
window.uiSettingsCache = window.uiSettingsCache || { ...PA_UI_SETTINGS_DEFAULTS };
|
|
|
|
function applyToastPosition(position) {
|
|
const classes = PA_TOAST_POSITION_CLASSES[position] || PA_TOAST_POSITION_CLASSES['top-left'];
|
|
document.querySelectorAll('[data-toast-container]').forEach(el => {
|
|
PA_ALL_POSITION_CLASSES.forEach(c => el.classList.remove(c));
|
|
classes.forEach(c => el.classList.add(c));
|
|
});
|
|
}
|
|
|
|
async function loadUiSettings() {
|
|
try {
|
|
const resp = await fetch('/api/ui/settings');
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
window.uiSettingsCache = { ...PA_UI_SETTINGS_DEFAULTS, ...data };
|
|
applyToastPosition(window.uiSettingsCache.toast_position);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load UI settings:', e);
|
|
}
|
|
}
|
|
|
|
function showNotification(message, type = 'info') {
|
|
const toastEl = document.getElementById('notificationToast');
|
|
if (!toastEl) return;
|
|
|
|
const toastBody = toastEl.querySelector('.toast-body');
|
|
if (toastBody) {
|
|
toastBody.textContent = message;
|
|
}
|
|
|
|
const toastHeader = toastEl.querySelector('.toast-header');
|
|
if (toastHeader) {
|
|
toastHeader.className = 'toast-header';
|
|
if (type === 'success') {
|
|
toastHeader.classList.add('bg-success', 'text-white');
|
|
} else if (type === 'danger') {
|
|
toastHeader.classList.add('bg-danger', 'text-white');
|
|
} else if (type === 'warning') {
|
|
toastHeader.classList.add('bg-warning');
|
|
}
|
|
}
|
|
|
|
const cfg = window.uiSettingsCache || {};
|
|
const noAutoclose = !!cfg.toast_no_autoclose;
|
|
const timeoutSec = parseFloat(cfg.toast_timeout_sec);
|
|
const delay = isFinite(timeoutSec) && timeoutSec > 0 ? Math.round(timeoutSec * 1000) : 2000;
|
|
|
|
const toast = new bootstrap.Toast(toastEl, {
|
|
autohide: !noAutoclose,
|
|
delay: delay
|
|
});
|
|
toast.show();
|
|
}
|
|
|
|
// ================================================================
|
|
// Data loading + table rendering
|
|
// ================================================================
|
|
|
|
let paMessages = [];
|
|
let paFilters = { hops: 'any', hashSize: 'any', token: '', sender: '', content: '' };
|
|
let paCurrentView = 'messages'; // 'messages' | 'stats'
|
|
let paContacts = []; // /api/contacts/cached?format=full
|
|
let paStatsSort = { key: 'relayed', dir: -1 };
|
|
|
|
async function paLoadContacts() {
|
|
try {
|
|
const resp = await fetch('/api/contacts/cached?format=full');
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
paContacts = data.contacts || [];
|
|
paTokenNameCache = new Map();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load contacts:', e);
|
|
}
|
|
}
|
|
|
|
// Match a repeater hash token against contacts by public key prefix.
|
|
// 1-byte hashes can collide - callers must handle multiple candidates.
|
|
function paMatchContacts(token) {
|
|
const prefix = token.toLowerCase();
|
|
return paContacts.filter(c => (c.public_key || '').toLowerCase().startsWith(prefix));
|
|
}
|
|
|
|
// token -> lowercase candidate contact names, memoized (contacts are
|
|
// static per page load; cache is rebuilt when they arrive)
|
|
let paTokenNameCache = new Map();
|
|
|
|
function paTokenNames(token) {
|
|
let names = paTokenNameCache.get(token);
|
|
if (!names) {
|
|
names = paMatchContacts(token).map(c => (c.name || '').toLowerCase());
|
|
paTokenNameCache.set(token, names);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
// 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.hashSize !== 'any') {
|
|
const want = parseInt(paFilters.hashSize, 10);
|
|
// Only routed echoes carry a meaningful hash size
|
|
const ok = msg.echoView.some(e => e.hops > 0 && (e.hash_size || 1) === want);
|
|
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))
|
|
));
|
|
if (!ok) return false;
|
|
}
|
|
if (paFilters.sender) {
|
|
if (!(msg.sender || '').toLowerCase().includes(paFilters.sender)) return false;
|
|
}
|
|
if (paFilters.content) {
|
|
if (!(msg.content || '').toLowerCase().includes(paFilters.content)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function paFiltersActive() {
|
|
return paFilters.hops !== 'any' || paFilters.hashSize !== 'any'
|
|
|| paFilters.token !== '' || paFilters.sender !== '' || paFilters.content !== '';
|
|
}
|
|
|
|
function paFormatTime(msg) {
|
|
if (!msg.timestamp) return '—';
|
|
const d = new Date(msg.timestamp * 1000);
|
|
const pad = n => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
|
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
}
|
|
|
|
function paCopyText(text, label) {
|
|
navigator.clipboard.writeText(text).then(
|
|
() => showNotification(`${label} copied to clipboard`, 'success'),
|
|
() => showNotification(`Failed to copy ${label}`, 'danger')
|
|
);
|
|
}
|
|
|
|
function paBuildEchoLine(msg, echo, echoIdx) {
|
|
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);
|
|
|
|
if (echo.hops > 0) {
|
|
const mapBtn = document.createElement('i');
|
|
mapBtn.className = 'bi bi-map pa-echo-mapbtn';
|
|
mapBtn.title = 'Show this path on the map';
|
|
mapBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
paMapSelection = { msgId: msg.id, echoIdx: echoIdx };
|
|
paSwitchView('map');
|
|
});
|
|
line.appendChild(mapBtn);
|
|
}
|
|
|
|
line.addEventListener('click', () => {
|
|
paCopyText(echo.tokens.join(','), 'Route');
|
|
});
|
|
return line;
|
|
}
|
|
|
|
function paRenderTable() {
|
|
const body = document.getElementById('paTableBody');
|
|
body.innerHTML = '';
|
|
|
|
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';
|
|
tdTime.textContent = paFormatTime(msg);
|
|
tr.appendChild(tdTime);
|
|
|
|
const tdChannel = document.createElement('td');
|
|
tdChannel.textContent = msg.channel_name || `#${msg.channel_idx}`;
|
|
tr.appendChild(tdChannel);
|
|
|
|
const tdSender = document.createElement('td');
|
|
tdSender.textContent = msg.is_own ? `${msg.sender || 'Me'} (own)` : (msg.sender || '—');
|
|
tr.appendChild(tdSender);
|
|
|
|
const tdContent = document.createElement('td');
|
|
const preview = document.createElement('div');
|
|
preview.className = 'pa-content-preview';
|
|
preview.textContent = msg.content || '';
|
|
preview.title = msg.content || '';
|
|
tdContent.appendChild(preview);
|
|
tr.appendChild(tdContent);
|
|
|
|
const tdHash = document.createElement('td');
|
|
if (msg.packet_hash) {
|
|
const span = document.createElement('span');
|
|
span.className = 'pa-hash';
|
|
span.textContent = msg.packet_hash;
|
|
span.title = 'Click to copy';
|
|
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>';
|
|
}
|
|
tr.appendChild(tdHash);
|
|
|
|
const tdHops = document.createElement('td');
|
|
tdHops.className = 'text-end';
|
|
tdHops.textContent = (msg.hop_count === null || msg.hop_count === undefined) ? '—' : msg.hop_count;
|
|
tr.appendChild(tdHops);
|
|
|
|
const tdHb = document.createElement('td');
|
|
tdHb.className = 'text-end';
|
|
const hbSizes = [...new Set(msg.echoView.filter(e => e.hops > 0).map(e => e.hash_size || 1))].sort();
|
|
tdHb.textContent = hbSizes.length > 0 ? hbSizes.join(',') : '—';
|
|
tr.appendChild(tdHb);
|
|
|
|
const tdEchoes = document.createElement('td');
|
|
tdEchoes.className = 'text-end';
|
|
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 = 9;
|
|
msg.echoView.forEach((echo, echoIdx) => {
|
|
detailTd.appendChild(paBuildEchoLine(msg, echo, echoIdx));
|
|
});
|
|
detailTr.appendChild(detailTd);
|
|
body.appendChild(detailTr);
|
|
|
|
tr.addEventListener('click', () => {
|
|
tr.classList.toggle('pa-open');
|
|
detailTr.classList.toggle('d-none');
|
|
});
|
|
}
|
|
}
|
|
|
|
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) {
|
|
document.getElementById('paLoading').classList.toggle('d-none', state !== 'loading');
|
|
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('paMapWrap').classList.toggle('d-none', state !== 'map');
|
|
}
|
|
|
|
// ================================================================
|
|
// Per-repeater statistics
|
|
// ================================================================
|
|
|
|
function paComputeStats(filtered) {
|
|
const stats = new Map(); // exact token -> aggregate
|
|
for (const msg of filtered) {
|
|
for (const e of msg.echoView) {
|
|
e.tokens.forEach((tok, i) => {
|
|
let s = stats.get(tok);
|
|
if (!s) {
|
|
s = { token: tok, relayed: 0, msgIds: new Set(), lastHop: 0, snrSum: 0, snrCount: 0 };
|
|
stats.set(tok, s);
|
|
}
|
|
s.relayed++;
|
|
s.msgIds.add(msg.id);
|
|
if (i === e.tokens.length - 1) {
|
|
// SNR is measured at our receiver - attribute it to the
|
|
// final hop only, never to intermediate hops
|
|
s.lastHop++;
|
|
if (e.snr !== null && e.snr !== undefined) {
|
|
s.snrSum += Number(e.snr);
|
|
s.snrCount++;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return [...stats.values()].map(s => ({
|
|
token: s.token,
|
|
relayed: s.relayed,
|
|
messages: s.msgIds.size,
|
|
lastHop: s.lastHop,
|
|
avgSnr: s.snrCount > 0 ? s.snrSum / s.snrCount : null,
|
|
}));
|
|
}
|
|
|
|
function paRenderStats() {
|
|
const body = document.getElementById('paStatsBody');
|
|
body.innerHTML = '';
|
|
|
|
const filtered = paMessages.filter(paMessageMatchesFilters);
|
|
const rows = paComputeStats(filtered);
|
|
|
|
const { key, dir } = paStatsSort;
|
|
rows.sort((a, b) => {
|
|
const av = a[key], bv = b[key];
|
|
if (av === null && bv === null) return 0;
|
|
if (av === null) return 1; // nulls (never last hop) always last
|
|
if (bv === null) return -1;
|
|
return (av < bv ? -1 : av > bv ? 1 : 0) * dir;
|
|
});
|
|
|
|
// Show sort direction on the active header
|
|
document.querySelectorAll('.pa-sortable').forEach(th => {
|
|
th.textContent = th.textContent.replace(/ [▲▼]$/, '')
|
|
+ (th.dataset.sort === key ? (dir === -1 ? ' ▼' : ' ▲') : '');
|
|
});
|
|
|
|
for (const s of rows) {
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'pa-stats-row';
|
|
tr.title = `Filter messages by ${s.token}`;
|
|
|
|
const tdToken = document.createElement('td');
|
|
tdToken.innerHTML = `<span class="pa-hash">${s.token}</span>`;
|
|
tr.appendChild(tdToken);
|
|
|
|
const tdContact = document.createElement('td');
|
|
const candidates = paMatchContacts(s.token);
|
|
if (candidates.length === 1) {
|
|
tdContact.textContent = candidates[0].name || '—';
|
|
} else if (candidates.length > 1) {
|
|
tdContact.innerHTML = `<span class="pa-ambiguous" title="${candidates.map(c => c.name).join(', ')}">ambiguous (${candidates.length})</span>`;
|
|
} else {
|
|
tdContact.textContent = '—';
|
|
}
|
|
tr.appendChild(tdContact);
|
|
|
|
for (const val of [s.relayed, s.messages, s.lastHop]) {
|
|
const td = document.createElement('td');
|
|
td.className = 'text-end';
|
|
td.textContent = val;
|
|
tr.appendChild(td);
|
|
}
|
|
|
|
const tdSnr = document.createElement('td');
|
|
tdSnr.className = 'text-end';
|
|
tdSnr.textContent = s.avgSnr === null ? '—' : `${s.avgSnr.toFixed(1)} dB`;
|
|
tr.appendChild(tdSnr);
|
|
|
|
// Click -> apply this token as the filter and jump to the message list
|
|
tr.addEventListener('click', () => {
|
|
document.getElementById('paTokenFilter').value = s.token;
|
|
paSwitchView('messages');
|
|
});
|
|
|
|
body.appendChild(tr);
|
|
}
|
|
|
|
const counter = document.getElementById('paCounter');
|
|
counter.textContent = paFiltersActive()
|
|
? `${rows.length} repeaters (${filtered.length} of ${paMessages.length} messages)`
|
|
: `${rows.length} repeaters (${paMessages.length} messages)`;
|
|
|
|
if (rows.length === 0) {
|
|
document.getElementById('paEmptyText').textContent =
|
|
filtered.length === 0 ? 'No messages match the current filters.'
|
|
: 'No routed echoes in the current selection.';
|
|
paSetView('empty');
|
|
} else {
|
|
paSetView('stats');
|
|
}
|
|
}
|
|
|
|
// ================================================================
|
|
// Map view
|
|
// ================================================================
|
|
|
|
let paMap = null;
|
|
let paBaseLayer = null; // repeater contact markers
|
|
let paPathLayer = null; // drawn path for the selected echo
|
|
let paMapSelection = { msgId: null, echoIdx: null };
|
|
let paPicks = {}; // token -> public_key chosen by the user (collision disambiguation)
|
|
|
|
function paGeoContact(c) {
|
|
return c.adv_lat !== null && c.adv_lat !== undefined &&
|
|
c.adv_lon !== null && c.adv_lon !== undefined &&
|
|
!(Number(c.adv_lat) === 0 && Number(c.adv_lon) === 0);
|
|
}
|
|
|
|
function paInitMap() {
|
|
if (paMap) return;
|
|
paMap = L.map('paMap').setView([52.0, 19.0], 6);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
}).addTo(paMap);
|
|
paBaseLayer = L.layerGroup().addTo(paMap);
|
|
paPathLayer = L.layerGroup().addTo(paMap);
|
|
}
|
|
|
|
function paPlotBaseMarkers() {
|
|
paBaseLayer.clearLayers();
|
|
paContacts.filter(c => c.type_label === 'REP' && paGeoContact(c)).forEach(c => {
|
|
L.circleMarker([c.adv_lat, c.adv_lon], {
|
|
radius: 5, color: '#6f42c1', weight: 1.5, fillOpacity: 0.5
|
|
}).bindPopup(`<strong>${c.name || '?'}</strong><br><code>${(c.public_key || '').slice(0, 12)}</code>`)
|
|
.addTo(paBaseLayer);
|
|
});
|
|
}
|
|
|
|
// Resolve a hop token to a contact: manual pick wins, else the single
|
|
// geo-located candidate. Returns {contact, candidates} - contact null
|
|
// when unknown or ambiguous.
|
|
function paResolveHop(token) {
|
|
const candidates = paMatchContacts(token);
|
|
const geoCandidates = candidates.filter(paGeoContact);
|
|
if (paPicks[token]) {
|
|
const picked = geoCandidates.find(c => c.public_key === paPicks[token]);
|
|
if (picked) return { contact: picked, candidates: geoCandidates };
|
|
}
|
|
if (geoCandidates.length === 1) return { contact: geoCandidates[0], candidates: geoCandidates };
|
|
return { contact: null, candidates: geoCandidates };
|
|
}
|
|
|
|
function paDrawSelectedEcho() {
|
|
paPathLayer.clearLayers();
|
|
const msg = paMessages.find(m => m.id === paMapSelection.msgId);
|
|
if (!msg || paMapSelection.echoIdx === null) return;
|
|
const echo = msg.echoView[paMapSelection.echoIdx];
|
|
if (!echo) return;
|
|
|
|
const points = []; // {latlng, gapBefore}
|
|
let gapPending = false;
|
|
|
|
// Origin: sender name -> contact with geo (channel messages carry no sender pubkey)
|
|
const origin = paContacts.find(c => c.name === msg.sender && paGeoContact(c));
|
|
if (origin) {
|
|
L.circleMarker([origin.adv_lat, origin.adv_lon], {
|
|
radius: 7, color: '#198754', weight: 2, fillOpacity: 0.8
|
|
}).bindPopup(`<strong>${origin.name}</strong><br>Origin (sender)`).addTo(paPathLayer);
|
|
points.push({ latlng: [origin.adv_lat, origin.adv_lon], gapBefore: false });
|
|
}
|
|
|
|
echo.tokens.forEach((tok, i) => {
|
|
const { contact, candidates } = paResolveHop(tok);
|
|
if (contact) {
|
|
L.circleMarker([contact.adv_lat, contact.adv_lon], {
|
|
radius: 7, color: '#6f42c1', weight: 2, fillOpacity: 0.9
|
|
}).bindPopup(`<strong>${contact.name}</strong><br>Hop ${i + 1}: <code>${tok}</code>`).addTo(paPathLayer);
|
|
points.push({ latlng: [contact.adv_lat, contact.adv_lon], gapBefore: gapPending });
|
|
gapPending = false;
|
|
} else {
|
|
// Ambiguous: show all geo candidates as amber markers, excluded from the line
|
|
candidates.forEach(c => {
|
|
L.circleMarker([c.adv_lat, c.adv_lon], {
|
|
radius: 6, color: '#d39e00', weight: 2, fillOpacity: 0.5, dashArray: '3'
|
|
}).bindPopup(`<strong>${c.name}</strong><br>Candidate for hop ${i + 1}: <code>${tok}</code>`).addTo(paPathLayer);
|
|
});
|
|
gapPending = true;
|
|
}
|
|
});
|
|
|
|
// Polyline: solid between consecutive resolved hops, dashed across skipped ones
|
|
for (let i = 1; i < points.length; i++) {
|
|
L.polyline([points[i - 1].latlng, points[i].latlng], {
|
|
color: '#6f42c1', weight: 3, opacity: 0.8,
|
|
dashArray: points[i].gapBefore ? '6 8' : null
|
|
}).addTo(paPathLayer);
|
|
}
|
|
|
|
const allLatLngs = points.map(p => p.latlng);
|
|
if (allLatLngs.length > 0) {
|
|
paMap.fitBounds(L.latLngBounds(allLatLngs).pad(0.25), { maxZoom: 13 });
|
|
}
|
|
|
|
document.getElementById('paMapClearBtn').classList.remove('d-none');
|
|
}
|
|
|
|
function paBuildLegend(echo) {
|
|
const legend = document.createElement('div');
|
|
legend.className = 'pa-legend';
|
|
echo.tokens.forEach((tok, i) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'pa-legend-hop';
|
|
const { contact, candidates } = paResolveHop(tok);
|
|
const label = document.createElement('span');
|
|
label.innerHTML = `${i + 1}. <span class="pa-hash">${tok}</span>`;
|
|
row.appendChild(label);
|
|
if (contact) {
|
|
const name = document.createElement('span');
|
|
name.textContent = '— ' + contact.name;
|
|
row.appendChild(name);
|
|
} else if (candidates.length > 1) {
|
|
const amb = document.createElement('span');
|
|
amb.className = 'pa-ambiguous';
|
|
amb.textContent = `— ${candidates.length} candidates:`;
|
|
row.appendChild(amb);
|
|
candidates.forEach(c => {
|
|
const chip = document.createElement('span');
|
|
chip.className = 'pa-pick-chip' + (paPicks[tok] === c.public_key ? ' pa-picked' : '');
|
|
chip.textContent = c.name || c.public_key.slice(0, 8);
|
|
chip.title = 'Use this contact for the hop';
|
|
chip.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
paPicks[tok] = c.public_key;
|
|
paRenderMapView();
|
|
});
|
|
row.appendChild(chip);
|
|
});
|
|
} else {
|
|
const unk = document.createElement('span');
|
|
unk.className = 'pa-ambiguous';
|
|
unk.textContent = '— unknown / no position';
|
|
row.appendChild(unk);
|
|
}
|
|
legend.appendChild(row);
|
|
});
|
|
return legend;
|
|
}
|
|
|
|
function paRenderMapView() {
|
|
paInitMap();
|
|
paPlotBaseMarkers();
|
|
|
|
const list = document.getElementById('paMapMsgList');
|
|
list.innerHTML = '';
|
|
const filtered = paMessages.filter(paMessageMatchesFilters)
|
|
.filter(m => m.echoView.some(e => e.hops > 0));
|
|
|
|
// Drop a stale selection (filters changed underneath it)
|
|
if (paMapSelection.msgId !== null && !filtered.some(m => m.id === paMapSelection.msgId)) {
|
|
paMapSelection = { msgId: null, echoIdx: null };
|
|
paPathLayer.clearLayers();
|
|
document.getElementById('paMapClearBtn').classList.add('d-none');
|
|
}
|
|
|
|
if (filtered.length === 0) {
|
|
list.innerHTML = '<div class="small text-muted p-2">No messages with routed echoes match the current filters.</div>';
|
|
}
|
|
|
|
for (const msg of filtered) {
|
|
const selected = msg.id === paMapSelection.msgId;
|
|
const entry = document.createElement('div');
|
|
entry.className = 'pa-map-msg' + (selected ? ' pa-selected' : '');
|
|
|
|
const head = document.createElement('div');
|
|
head.className = 'd-flex justify-content-between gap-2';
|
|
const senderEl = document.createElement('strong');
|
|
senderEl.className = 'text-truncate';
|
|
senderEl.textContent = msg.sender || '—';
|
|
const timeEl = document.createElement('span');
|
|
timeEl.className = 'text-muted text-nowrap';
|
|
timeEl.textContent = paFormatTime(msg).slice(5, 16);
|
|
head.appendChild(senderEl);
|
|
head.appendChild(timeEl);
|
|
entry.appendChild(head);
|
|
|
|
// Content preview: one truncated line; full text when selected
|
|
if (msg.content) {
|
|
const preview = document.createElement('div');
|
|
preview.className = 'pa-map-msg-preview' + (selected ? '' : ' text-truncate');
|
|
preview.textContent = msg.content;
|
|
preview.title = msg.content;
|
|
entry.appendChild(preview);
|
|
}
|
|
|
|
entry.addEventListener('click', () => {
|
|
paMapSelection = { msgId: msg.id, echoIdx: null };
|
|
paRenderMapView();
|
|
});
|
|
|
|
if (selected) {
|
|
msg.echoView.forEach((echo, idx) => {
|
|
if (echo.hops === 0) return;
|
|
const eEl = document.createElement('div');
|
|
eEl.className = 'pa-map-echo' + (idx === paMapSelection.echoIdx ? ' pa-selected' : '');
|
|
const snr = (echo.snr === null || echo.snr === undefined) ? '?' : `${Number(echo.snr).toFixed(1)} dB`;
|
|
eEl.textContent = `${echo.tokens.join(' → ')} (${snr})`;
|
|
eEl.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
paMapSelection.echoIdx = idx;
|
|
paRenderMapView();
|
|
});
|
|
entry.appendChild(eEl);
|
|
|
|
if (idx === paMapSelection.echoIdx) {
|
|
entry.appendChild(paBuildLegend(echo));
|
|
}
|
|
});
|
|
}
|
|
list.appendChild(entry);
|
|
}
|
|
|
|
paSetView('map');
|
|
// Leaflet cannot size itself while the container was display:none -
|
|
// and fitBounds in paDrawSelectedEcho needs the corrected size, so
|
|
// drawing must happen after invalidateSize in the same deferred step
|
|
setTimeout(() => {
|
|
paMap.invalidateSize();
|
|
paDrawSelectedEcho();
|
|
}, 60);
|
|
|
|
// Keep the selected message visible (e.g. after jumping from the table)
|
|
const selectedEl = list.querySelector('.pa-map-msg.pa-selected');
|
|
if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' });
|
|
|
|
const counter = document.getElementById('paCounter');
|
|
counter.textContent = paFiltersActive()
|
|
? `${filtered.length} of ${paMessages.length} messages`
|
|
: `${filtered.length} routed message${filtered.length === 1 ? '' : 's'}`;
|
|
}
|
|
|
|
function paClearMapSelection() {
|
|
paMapSelection = { msgId: null, echoIdx: null };
|
|
paPathLayer.clearLayers();
|
|
document.getElementById('paMapClearBtn').classList.add('d-none');
|
|
paRenderMapView();
|
|
}
|
|
|
|
function paRender() {
|
|
if (paCurrentView === 'stats') {
|
|
paRenderStats();
|
|
} else if (paCurrentView === 'map') {
|
|
paRenderMapView();
|
|
} else {
|
|
paRenderTable();
|
|
}
|
|
}
|
|
|
|
function paSwitchView(view) {
|
|
paCurrentView = view;
|
|
for (const [btnId, v] of [['paViewMessagesBtn', 'messages'],
|
|
['paViewStatsBtn', 'stats'],
|
|
['paViewMapBtn', 'map']]) {
|
|
document.getElementById(btnId).className =
|
|
'btn ' + (view === v ? 'btn-primary' : 'btn-outline-primary');
|
|
}
|
|
paApplyFilters();
|
|
}
|
|
|
|
async function paLoadMessages() {
|
|
const days = document.getElementById('paDaysSelect').value;
|
|
paSetView('loading');
|
|
document.getElementById('paCounter').textContent = '';
|
|
|
|
try {
|
|
const resp = await fetch(`/api/path-analyzer/messages?days=${encodeURIComponent(days)}`);
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) {
|
|
throw new Error(data.error || `HTTP ${resp.status}`);
|
|
}
|
|
// 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');
|
|
paMessages = [];
|
|
}
|
|
|
|
if (paMessages.length === 0) {
|
|
document.getElementById('paEmptyText').textContent = 'No messages in the selected time range.';
|
|
paSetView('empty');
|
|
} else {
|
|
paRender();
|
|
}
|
|
}
|
|
|
|
// ================================================================
|
|
// Filters
|
|
// ================================================================
|
|
|
|
function paReadFilters() {
|
|
paFilters.hops = document.getElementById('paHopsFilter').value;
|
|
paFilters.hashSize = document.getElementById('paHashSizeFilter').value;
|
|
paFilters.token = document.getElementById('paTokenFilter').value.trim().toLowerCase();
|
|
paFilters.sender = document.getElementById('paSenderFilter').value.trim().toLowerCase();
|
|
paFilters.content = document.getElementById('paContentFilter').value.trim().toLowerCase();
|
|
document.getElementById('paClearFiltersBtn').classList.toggle('d-none', !paFiltersActive());
|
|
}
|
|
|
|
function paApplyFilters() {
|
|
paReadFilters();
|
|
if (paMessages.length > 0) {
|
|
paRender();
|
|
}
|
|
}
|
|
|
|
function paClearFilters() {
|
|
document.getElementById('paHopsFilter').value = 'any';
|
|
document.getElementById('paHashSizeFilter').value = 'any';
|
|
document.getElementById('paTokenFilter').value = '';
|
|
document.getElementById('paSenderFilter').value = '';
|
|
document.getElementById('paContentFilter').value = '';
|
|
paApplyFilters();
|
|
}
|
|
|
|
// ================================================================
|
|
// Init
|
|
// ================================================================
|
|
|
|
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('paHashSizeFilter').addEventListener('change', paApplyFilters);
|
|
document.getElementById('paTokenFilter').addEventListener('input', debouncedApply);
|
|
document.getElementById('paSenderFilter').addEventListener('input', debouncedApply);
|
|
document.getElementById('paContentFilter').addEventListener('input', debouncedApply);
|
|
document.getElementById('paClearFiltersBtn').addEventListener('click', paClearFilters);
|
|
|
|
document.getElementById('paViewMessagesBtn').addEventListener('click', () => paSwitchView('messages'));
|
|
document.getElementById('paViewStatsBtn').addEventListener('click', () => paSwitchView('stats'));
|
|
document.getElementById('paViewMapBtn').addEventListener('click', () => paSwitchView('map'));
|
|
document.getElementById('paMapClearBtn').addEventListener('click', paClearMapSelection);
|
|
document.querySelectorAll('.pa-sortable').forEach(th => {
|
|
th.addEventListener('click', () => {
|
|
const k = th.dataset.sort;
|
|
if (paStatsSort.key === k) {
|
|
paStatsSort.dir = -paStatsSort.dir;
|
|
} else {
|
|
paStatsSort = { key: k, dir: -1 };
|
|
}
|
|
paRenderStats();
|
|
});
|
|
});
|
|
|
|
paLoadContacts();
|
|
paLoadMessages();
|
|
});
|