mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-25 14:22:11 +00:00
## 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 `<a>` tag whose `href` matched the current hash (set by `replaceState`), making clicks a same-hash no-op. **Fix:** Changed from `<a>` link to a `<button>` with a direct click handler that sets `location.hash`, ensuring the router always fires. **Acceptance:** Click "🔍 Details" → navigates to full-screen node detail view. Closes #856 --- ### #857 — Recent Packets shows bullets but no content **Problem:** The "Recent Packets (N)" section could render entries with missing `hash` or `timestamp`, producing colored dots with no meaningful content beside them. **Fix:** Added `.filter(a => a.hash && a.timestamp)` before rendering, and updated the count header to reflect filtered entries only. **Acceptance:** Recent Packets section only shows entries with valid data; count matches visible items. Closes #857 --- ### #862 — Pubkey prefix search on /#/nodes **Problem:** Search box only matched node names. Operators couldn't search by pubkey prefix. **Fix:** Extended search to detect hex-only queries (`/^[0-9a-f]+$/i`) and match them against pubkey prefix (`startsWith`). Non-hex queries continue matching name as before. Both are composable in the same input. **Acceptance:** - Typing `3f` filters to nodes whose pubkey starts with `3f` - Typing `foo` still filters by name - Search placeholder updated to indicate pubkey support 5 new unit tests added for the search matching logic. Closes #862 --------- Co-authored-by: you <you@example.com>
This commit is contained in:
@@ -286,11 +286,29 @@
|
||||
if (h) h.textContent = 'Neighbors (' + data.neighbors.length + ')';
|
||||
}
|
||||
var html = renderNeighborTable(data.neighbors, limit);
|
||||
if (limit && data.neighbors.length > limit && viewAllPubkey) {
|
||||
html += '<div style="margin-top:6px;text-align:right"><a href="#/nodes/' + encodeURIComponent(viewAllPubkey) + '?section=node-neighbors" style="font-size:12px">View all ' + data.neighbors.length + ' neighbors →</a></div>';
|
||||
if (limit && data.neighbors.length > limit) {
|
||||
html += '<div style="margin-top:6px;text-align:right"><button class="btn-link show-all-neighbors-btn" style="font-size:12px;cursor:pointer;background:none;border:none;color:var(--accent);padding:0">Show all ' + data.neighbors.length + ' neighbors ▼</button></div>';
|
||||
} else if (!limit && data.neighbors.length > 5) {
|
||||
// Collapse toggle when expanded (#855)
|
||||
html += '<div style="margin-top:6px;text-align:right"><button class="btn-link collapse-neighbors-btn" style="font-size:12px;cursor:pointer;background:none;border:none;color:var(--accent);padding:0">Show fewer ▲</button></div>';
|
||||
}
|
||||
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 = `<div class="nodes-page">
|
||||
<div class="nodes-topbar">
|
||||
<input type="text" class="nodes-search" id="nodeSearch" placeholder="Search nodes by name…" aria-label="Search nodes by name">
|
||||
<input type="text" class="nodes-search" id="nodeSearch" placeholder="Search by name or pubkey prefix…" aria-label="Search nodes by name or pubkey prefix">
|
||||
<div class="nodes-counts" id="nodeCounts"></div>
|
||||
</div>
|
||||
<div id="nodesRegionFilter" class="region-filter-container"></div>
|
||||
@@ -541,9 +559,10 @@
|
||||
</div>
|
||||
|
||||
<div class="node-full-card" id="node-packets">
|
||||
<h4>Recent Packets (${adverts.length})</h4>
|
||||
${(() => { const validPackets = adverts.filter(p => p.hash && p.timestamp); return `
|
||||
<h4>Recent Packets (${validPackets.length})</h4>
|
||||
<div class="node-activity-list">
|
||||
${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 @@
|
||||
</div>`;
|
||||
}).join('') : '<div class="text-muted">No recent packets</div>'}
|
||||
</div>
|
||||
`; })()}
|
||||
</div>`;
|
||||
|
||||
// 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 <a href="#/nodes/PUBKEY">
|
||||
// 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 @@
|
||||
<div class="node-detail">
|
||||
<div class="node-detail-name">${escapeHtml(n.name || '(unnamed)')}${dupBadge}</div>
|
||||
<div class="node-detail-role">${renderNodeBadges(n, roleColor)}
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}" class="btn-primary" style="display:inline-block;text-decoration:none;font-size:11px;padding:2px 8px;margin-left:8px">🔍 Details</a>
|
||||
<button class="btn-primary node-detail-btn" data-pubkey="${encodeURIComponent(n.public_key)}" aria-label="View details for ${escapeHtml(n.name || n.public_key)}" style="font-size:11px;padding:2px 8px;margin-left:8px;cursor:pointer">🔍 Details</button>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px">📊 Analytics</a>
|
||||
</div>
|
||||
${renderStatusExplanation(n)}
|
||||
@@ -1233,9 +1241,10 @@
|
||||
</div>
|
||||
|
||||
<div class="node-detail-section">
|
||||
<h4>Recent Packets (${adverts.length})</h4>
|
||||
${(() => { const validPackets = adverts.filter(a => a.hash && a.timestamp); return `
|
||||
<h4>Recent Packets (${validPackets.length})</h4>
|
||||
<div id="advertTimeline">
|
||||
${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 @@
|
||||
</div>`;
|
||||
}).join('') : '<div class="text-muted" style="padding:8px">No recent packets</div>'}
|
||||
</div>
|
||||
`; })()}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
Reference in New Issue
Block a user