Add node name filter to packets page, fix duplicate node WHERE clause

- Autocomplete dropdown searches /api/nodes/search as you type
- Selecting a node filters packets by that node's pubkey
- Fixed duplicate node filter condition in grouped packets query
This commit is contained in:
you
2026-03-18 21:51:02 +00:00
parent bbdbc297ba
commit acc6f9c856
3 changed files with 61 additions and 1 deletions
+37
View File
@@ -184,6 +184,10 @@
</div>
<div class="filter-bar" id="pktFilters">
<input type="text" placeholder="Packet hash…" id="fHash">
<div class="node-filter-wrap" style="position:relative">
<input type="text" placeholder="Node name…" id="fNode" autocomplete="off">
<div class="node-filter-dropdown hidden" id="fNodeDropdown"></div>
</div>
<select id="fObserver"><option value="">All Observers</option></select>
<select id="fRegion"><option value="">All Regions</option></select>
<select id="fType"><option value="">All Types</option></select>
@@ -222,6 +226,39 @@
document.getElementById('fType').addEventListener('change', (e) => { filters.type = e.target.value !== '' ? e.target.value : undefined; loadPackets(); });
document.getElementById('fGroup').addEventListener('click', () => { groupByHash = !groupByHash; loadPackets(); });
// Node name filter with autocomplete
const fNode = document.getElementById('fNode');
const fNodeDrop = document.getElementById('fNodeDropdown');
fNode.value = filters.nodeName || '';
fNode.addEventListener('input', debounce(async (e) => {
const q = e.target.value.trim();
if (!q) {
fNodeDrop.classList.add('hidden');
if (filters.node) { filters.node = undefined; filters.nodeName = undefined; loadPackets(); }
return;
}
try {
const resp = await fetch('/api/nodes/search?q=' + encodeURIComponent(q));
const data = await resp.json();
const nodes = data.nodes || [];
if (nodes.length === 0) { fNodeDrop.classList.add('hidden'); return; }
fNodeDrop.innerHTML = nodes.map(n =>
`<div class="node-filter-option" data-key="${n.public_key}" data-name="${escapeHtml(n.name || n.public_key.slice(0,8))}">${escapeHtml(n.name || n.public_key.slice(0,8))} <span style="color:var(--muted);font-size:0.8em">${n.public_key.slice(0,8)}</span></div>`
).join('');
fNodeDrop.classList.remove('hidden');
fNodeDrop.querySelectorAll('.node-filter-option').forEach(opt => {
opt.addEventListener('click', () => {
filters.node = opt.dataset.key;
filters.nodeName = opt.dataset.name;
fNode.value = opt.dataset.name;
fNodeDrop.classList.add('hidden');
loadPackets();
});
});
} catch {}
}, 250));
fNode.addEventListener('blur', () => { setTimeout(() => fNodeDrop.classList.add('hidden'), 200); });
renderTableRows();
makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths');
}
+24
View File
@@ -1084,3 +1084,27 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
transition: background 0.15s;
}
.replay-live-btn:hover { background: rgba(168, 85, 247, 0.3); }
/* Node filter dropdown */
.node-filter-wrap { display: inline-block; }
.node-filter-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--surface-1, #1e293b);
border: 1px solid var(--border, rgba(255,255,255,0.1));
border-radius: 6px;
max-height: 200px;
overflow-y: auto;
z-index: 100;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.node-filter-dropdown.hidden { display: none; }
.node-filter-option {
padding: 6px 10px;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.1s;
}
.node-filter-option:hover { background: var(--surface-2, rgba(255,255,255,0.08)); }
-1
View File
@@ -288,7 +288,6 @@ app.get('/api/packets', (req, res) => {
if (observer) { where.push('observer_id = @observer'); params.observer = observer; }
if (hash) { where.push('hash = @hash'); params.hash = hash; }
if (since) { where.push('timestamp > @since'); params.since = since; }
if (node) { where.push("(decoded_json LIKE @nodePattern OR decoded_json LIKE @nodeNamePattern)"); if (!params.nodePattern) { params.nodePattern = `%${node}%`; const n = db.db.prepare('SELECT name FROM nodes WHERE public_key = ?').get(node); params.nodeNamePattern = n ? `%${n.name}%` : `%${node}%`; } }
if (node) { where.push("(decoded_json LIKE @nodePattern OR decoded_json LIKE @nodeNamePattern)"); params.nodePattern = `%${node}%`; const n = db.db.prepare('SELECT name FROM nodes WHERE public_key = ?').get(node); params.nodeNamePattern = n ? `%${n.name}%` : `%${node}%`; }
const clause = where.length ? 'WHERE ' + where.join(' AND ') : '';
const packets = db.db.prepare(`SELECT hash, COUNT(DISTINCT observer_id) as observer_count, COUNT(*) as count, MAX(timestamp) as latest, (SELECT observer_id FROM packets pObs WHERE pObs.hash = packets.hash ORDER BY pObs.timestamp ASC LIMIT 1) as observer_id, (SELECT observer_name FROM packets pOn WHERE pOn.hash = packets.hash ORDER BY pOn.timestamp ASC LIMIT 1) as observer_name, (SELECT path_json FROM packets p2 WHERE p2.hash = packets.hash ORDER BY p2.timestamp DESC LIMIT 1) as path_json, (SELECT payload_type FROM packets p3 WHERE p3.hash = packets.hash ORDER BY p3.timestamp DESC LIMIT 1) as payload_type, (SELECT raw_hex FROM packets p4 WHERE p4.hash = packets.hash ORDER BY p4.timestamp DESC LIMIT 1) as raw_hex, (SELECT decoded_json FROM packets p5 WHERE p5.hash = packets.hash ORDER BY p5.timestamp DESC LIMIT 1) as decoded_json FROM packets ${clause} GROUP BY hash ORDER BY latest DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit: Number(limit), offset: Number(offset) });