From c99aa1dadf467daa670caa1658a4b9be3e826ea0 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Tue, 21 Apr 2026 10:24:27 -0700 Subject: [PATCH] fix(#855, #856, #857) + feat(#862): /nodes detail panel + search improvements (#868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Four related `/nodes` page fixes batched to avoid merge conflicts (all touch `public/nodes.js`). --- ### #855 — "Show all neighbors" link doesn't expand **Problem:** The "View all N neighbors →" link in the side panel navigated to the full detail page instead of expanding the truncated list inline. **Fix:** Replaced navigation link with an inline "Show all N neighbors ▼" button that re-renders the neighbor table without the limit. **Acceptance:** Click the button → all neighbors appear in the same panel without page navigation. Closes #855 --- ### #856 — "Details" button is a no-op **Problem:** The "🔍 Details" link in the side panel was an `` tag whose `href` matched the current hash (set by `replaceState`), making clicks a same-hash no-op. **Fix:** Changed from `` link to a `'; + } else if (!limit && data.neighbors.length > 5) { + // Collapse toggle when expanded (#855) + html += '
'; } el.innerHTML = html; + // Wire "Show all neighbors" expand button (#855) + var expandBtn = el.querySelector('.show-all-neighbors-btn'); + if (expandBtn) { + expandBtn.addEventListener('click', function() { + renderNeighborData(data, containerId, 0, headerSelector, null); + }); + } + // Wire collapse button (#855) + var collapseBtn = el.querySelector('.collapse-neighbors-btn'); + if (collapseBtn) { + collapseBtn.addEventListener('click', function() { + renderNeighborData(data, containerId, 5, headerSelector, null); + }); + } + // Initialize TableSort on neighbor table var neighborTable = el.querySelector('.neighbor-sort-table'); if (neighborTable && window.TableSort) { @@ -355,7 +373,7 @@ app.innerHTML = `
- +
@@ -541,9 +559,10 @@
-

Recent Packets (${adverts.length})

+ ${(() => { const validPackets = adverts.filter(p => p.hash && p.timestamp); return ` +

Recent Packets (${validPackets.length})

- ${adverts.length ? adverts.map(p => { + ${validPackets.length ? validPackets.map(p => { let decoded; try { decoded = JSON.parse(p.decoded_json); } catch {} const typeLabel = p.payload_type === 4 ? '📡 Advert' : p.payload_type === 5 ? '💬 Channel' : p.payload_type === 2 ? '✉️ DM' : '📦 Packet'; const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : ''; @@ -569,6 +588,7 @@
`; }).join('') : '
No recent packets
'}
+ `; })()} `; // Map @@ -882,8 +902,7 @@ let filtered = _allNodes; if (activeTab !== 'all') filtered = filtered.filter(n => (n.role || '').toLowerCase() === activeTab); if (search) { - const q = search.toLowerCase(); - filtered = filtered.filter(n => (n.name || '').toLowerCase().includes(q) || (n.public_key || '').toLowerCase().includes(q)); + filtered = filtered.filter(n => window._nodesMatchesSearch(n, search)); } if (lastHeard) { const ms = { '1h': 3600000, '2h': 7200000, '6h': 21600000, '12h': 43200000, '24h': 86400000, '48h': 172800000, '3d': 259200000, '7d': 604800000, '14d': 1209600000, '30d': 2592000000 }[lastHeard]; @@ -1054,24 +1073,13 @@ // #630: Close button for node detail panel (important for mobile full-screen overlay) document.getElementById('nodesRight').addEventListener('click', function(e) { - // #778: Details/Analytics links don't navigate because replaceState - // already set the hash to #/nodes/PUBKEY, so clicking - // is a same-hash no-op. For the detail link (same page), call init() - // directly — faster than a full router teardown/rebuild cycle. - // For analytics (different page), force hashchange via replaceState + assign. + // #778/#856: Analytics link — force hashchange via replaceState + assign. + // (Details button is handled separately via .node-detail-btn click listener) var link = e.target.closest('a.btn-primary[href^="#/nodes/"]'); if (link) { e.preventDefault(); var href = link.getAttribute('href'); - if (href.indexOf('/analytics') === -1) { - // Detail link — re-init with the pubkey directly; - // destroy() first to clean up WS handlers, maps, listeners - destroy(); - var pubkey = href.replace('#/nodes/', '').split('/')[0]; - var appEl = document.getElementById('app'); - init(appEl, decodeURIComponent(pubkey)); - history.replaceState(null, '', href); - } else { + if (href.indexOf('/analytics') !== -1) { // Analytics link — different page, force hashchange via replaceState + assign history.replaceState(null, '', '#/'); location.hash = href.substring(1); @@ -1182,7 +1190,7 @@
${escapeHtml(n.name || '(unnamed)')}${dupBadge}
${renderStatusExplanation(n)} @@ -1233,9 +1241,10 @@
-

Recent Packets (${adverts.length})

+ ${(() => { const validPackets = adverts.filter(a => a.hash && a.timestamp); return ` +

Recent Packets (${validPackets.length})

- ${adverts.length ? adverts.map(a => { + ${validPackets.length ? validPackets.map(a => { let decoded; try { decoded = JSON.parse(a.decoded_json); } catch {} const pType = PAYLOAD_TYPES[a.payload_type] || 'Packet'; @@ -1254,6 +1263,7 @@
`; }).join('') : '
No recent packets
'}
+ `; })()} `; @@ -1297,6 +1307,15 @@ } catch {} } + // #856: Wire "Details" button to navigate to full-screen node view + var detailBtn = panel.querySelector('.node-detail-btn'); + if (detailBtn) { + detailBtn.addEventListener('click', function() { + var pk = detailBtn.getAttribute('data-pubkey'); + location.hash = '#/nodes/' + pk; + }); + } + // Fetch neighbors for this node (condensed panel — top 5) fetchAndRenderNeighbors(n.public_key, 'panelNeighborsContent', { limit: 5, @@ -1406,4 +1425,14 @@ window._nodesRenderNodeTimestampText = renderNodeTimestampText; window._nodesGetStatusInfo = getStatusInfo; window._nodesGetStatusTooltip = getStatusTooltip; + + // #862: Expose search filter logic for testing + window._nodesMatchesSearch = function(node, query) { + if (!query) return true; + var q = query.toLowerCase(); + var isHex = /^[0-9a-f]+$/i.test(q); + if ((node.name || '').toLowerCase().includes(q)) return true; + if (isHex && (node.public_key || '').toLowerCase().startsWith(q)) return true; + return false; + }; })(); diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 2d6b719..2610a7b 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -6040,6 +6040,82 @@ console.log('\n=== analytics.js: renderCollisionsFromServer collision table ===' }); } +// ─── #862: Pubkey prefix search ────────────────────────────────────────────── +{ + const ctx = makeSandbox(); + ctx.ROLE_COLORS = { repeater: '#22c55e', room: '#6366f1', companion: '#3b82f6', sensor: '#f59e0b' }; + ctx.ROLE_STYLE = {}; + ctx.TYPE_COLORS = {}; + ctx.getNodeStatus = () => 'active'; + ctx.getHealthThresholds = () => ({ staleMs: 600000, degradedMs: 1800000, silentMs: 86400000 }); + ctx.timeAgo = () => '1m ago'; + ctx.truncate = (s) => s; + ctx.escapeHtml = (s) => String(s || ''); + ctx.payloadTypeName = () => 'Advert'; + ctx.payloadTypeColor = () => 'advert'; + ctx.registerPage = () => {}; + ctx.RegionFilter = { init: () => {}, onChange: () => () => {}, getRegionParam: () => '' }; + ctx.debouncedOnWS = () => null; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.debounce = (fn) => fn; + ctx.api = () => Promise.resolve({ nodes: [], counts: {} }); + ctx.invalidateApiCache = () => {}; + ctx.CLIENT_TTL = { nodeList: 90000, nodeDetail: 240000, nodeHealth: 240000 }; + ctx.initTabBar = () => {}; + ctx.getFavorites = () => []; + ctx.favStar = () => ''; + ctx.bindFavStars = () => {}; + ctx.makeColumnsResizable = () => {}; + ctx.Set = Set; + ctx.HEALTH_THRESHOLDS = { infraSilentMs: 86400000, nodeSilentMs: 7200000 }; + loadInCtx(ctx, 'public/nodes.js'); + + const matchesSearch = ctx.window._nodesMatchesSearch; + + test('#862: _nodesMatchesSearch matches name substring', () => { + const node = { name: 'MyRepeater', public_key: '3faebb0011223344' }; + assert.strictEqual(matchesSearch(node, 'repeat'), true); + assert.strictEqual(matchesSearch(node, 'REPEAT'), true); + }); + + test('#862: _nodesMatchesSearch matches pubkey prefix (hex)', () => { + const node = { name: 'MyRepeater', public_key: '3faebb0011223344' }; + assert.strictEqual(matchesSearch(node, '3f'), true); + assert.strictEqual(matchesSearch(node, '3fae'), true); + assert.strictEqual(matchesSearch(node, '3FAEBB'), true); + }); + + test('#862: _nodesMatchesSearch does NOT match pubkey substring (only prefix)', () => { + const node = { name: 'MyRepeater', public_key: '3faebb0011223344' }; + assert.strictEqual(matchesSearch(node, 'aebb'), false); + }); + + test('#862: _nodesMatchesSearch returns true for empty query', () => { + const node = { name: 'Test', public_key: 'abcdef1234567890' }; + assert.strictEqual(matchesSearch(node, ''), true); + assert.strictEqual(matchesSearch(node, null), true); + }); + + test('#862: _nodesMatchesSearch mixed query (non-hex) only matches name', () => { + const node = { name: 'alpha', public_key: 'abcdef1234567890' }; + assert.strictEqual(matchesSearch(node, 'xyz'), false); + assert.strictEqual(matchesSearch(node, 'alph'), true); + }); + + test('#862: _nodesMatchesSearch hex-named node — name "cafe" with pubkey "deadbeef..."', () => { + const node = { name: 'cafe', public_key: 'deadbeef11223344' }; + // "cafe" matches by name (substring), NOT pubkey prefix + assert.strictEqual(matchesSearch(node, 'cafe'), true); + // "dead" matches by pubkey prefix + assert.strictEqual(matchesSearch(node, 'dead'), true); + // "cafe" should NOT match pubkey (not a prefix of "deadbeef") + assert.strictEqual(matchesSearch(node, 'beef'), false); // not a prefix, not in name + // "ca" matches name substring + assert.strictEqual(matchesSearch(node, 'ca'), true); + }); +} + // ===== SUMMARY ===== Promise.allSettled(pendingTests).then(() => { console.log(`\n${'═'.repeat(40)}`);