From 943eb69937a41d52c53e9e6be66eff26b15bc880 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Thu, 2 Apr 2026 22:36:47 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20neighbors=20section=20in=20node=20detai?= =?UTF-8?q?l=20page=20(#482)=20=E2=80=94=20milestone=205=20(#510)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add a "Neighbors" section to the node detail page, showing first-hop neighbor relationships derived from the neighbor affinity graph (M2 API). Part of #482 β€” Milestone 5 per [spec](https://github.com/Kpa-clawbot/CoreScope/blob/spec/482-neighbor-affinity/docs/specs/neighbor-affinity-graph.md). ## What's Added ### Full-screen detail view (`#/nodes/{pubkey}`) - New `node-full-card` section between "Heard By" and "Paths Through This Node" - Table with columns: **Neighbor** (linked), **Role** (badge), **Score**, **Obs**, **Last Seen**, **Conf** (confidence indicator) - Confidence indicators per spec: - 🟒 HIGH: auto-resolved, β‰₯3 observations, score β‰₯ 0.5 - 🟑 MEDIUM: 2+ observations - πŸ”΄ LOW: single observation - ⚠️ AMBIGUOUS: multiple candidates - Click neighbor name β†’ navigate to their detail page - πŸ“ Map button per resolved neighbor row ### Condensed panel view (right panel) - Shows top 5 neighbors only - "View all N neighbors β†’" link navigates to full detail page with `?section=node-neighbors` ### Deep linking - `?section=node-neighbors` auto-scrolls to the neighbors section (uses existing scroll mechanism) ### Data fetching - `GET /api/nodes/{pubkey}/neighbors` via existing `api()` helper - Cached per-node for 5 minutes (panel lifetime) - Loading spinner, empty state, error state ### States - **Loading**: spinner with "Loading neighbors…" - **Empty**: "No neighbor data available yet. Neighbor relationships are built from observed packet paths over time." - **Error**: "Could not load neighbor data" ## Tests - 2 new Playwright E2E tests: 1. Section exists with correct table columns (or empty state) 2. Loading spinner visible during fetch ## Files Changed - `public/nodes.js` β€” neighbor section rendering + data fetching helpers - `test-e2e-playwright.js` β€” 2 new E2E tests --------- Co-authored-by: you --- public/nodes.js | 126 +++++++++++++++++++++++++++++++++++++++++ test-e2e-playwright.js | 59 +++++++++++++++++++ 2 files changed, 185 insertions(+) diff --git a/public/nodes.js b/public/nodes.js index bcc7fed5..841b4723 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -175,6 +175,110 @@ return `
Adverts show varying hash sizes (${sizes.join('-byte, ')}-byte). This is a known bug where automatic adverts ignore the configured multibyte path setting. Fixed in repeater v1.14.1.
`; } + // ─── Neighbor section helpers ─────────────────────────────────────────────── + + // Cache: pubkey β†’ { data, ts } + var _neighborCache = {}; + + function getConfidenceIndicator(entry) { + if (entry.ambiguous) return { icon: '⚠️', label: 'AMBIGUOUS', cls: 'confidence-ambiguous' }; + if (entry.count <= 1) return { icon: 'πŸ”΄', label: 'LOW', cls: 'confidence-low' }; + if (entry.score >= 0.5 && entry.count >= 3) return { icon: '🟒', label: 'HIGH', cls: 'confidence-high' }; + return { icon: '🟑', label: 'MEDIUM', cls: 'confidence-medium' }; + } + + function renderNeighborRows(neighbors, limit) { + var sorted = neighbors.slice().sort(function(a, b) { + return (b.score || b.affinity || 0) - (a.score || a.affinity || 0); + }); + var items = limit ? sorted.slice(0, limit) : sorted; + return items.map(function(nb) { + var conf = getConfidenceIndicator(nb); + var name = nb.name || (nb.prefix + '… (unknown)'); + var nameHtml = nb.pubkey + ? '' + escapeHtml(name) + '' + : '' + escapeHtml(name) + ''; + var role = nb.role || 'β€”'; + var roleBadge = nb.role + ? '' + escapeHtml(role) + '' + : 'β€”'; + var scoreTitle = 'Observations: ' + nb.count; + if (nb.avg_snr != null) scoreTitle += ' Β· Avg SNR: ' + Number(nb.avg_snr).toFixed(1) + ' dB'; + var showOnMap = nb.pubkey + ? ' ' + : ''; + return '' + + '' + nameHtml + '' + + '' + roleBadge + '' + + '' + Number(nb.score).toFixed(2) + '' + + '' + nb.count + '' + + '' + renderNodeTimestampHtml(nb.last_seen) + '' + + '' + conf.icon + '' + + '' + showOnMap + '' + + ''; + }).join(''); + } + + function renderNeighborTable(neighbors, limit) { + return '' + + '' + + '' + renderNeighborRows(neighbors, limit) + '
NeighborRoleScoreObsLast SeenConf
'; + } + + function fetchAndRenderNeighbors(pubkey, containerId, opts) { + opts = opts || {}; + var limit = opts.limit || 0; + var headerSelector = opts.headerSelector; + var viewAllPubkey = opts.viewAllPubkey; + + // Check cache + var cached = _neighborCache[pubkey]; + if (cached && (Date.now() - cached.ts < 300000)) { // 5 min cache + renderNeighborData(cached.data, containerId, limit, headerSelector, viewAllPubkey); + return; + } + + api('/nodes/' + encodeURIComponent(pubkey) + '/neighbors', { ttl: CLIENT_TTL.nodeDetail }).then(function(data) { + _neighborCache[pubkey] = { data: data, ts: Date.now() }; + renderNeighborData(data, containerId, limit, headerSelector, viewAllPubkey); + }).catch(function() { + var el = document.getElementById(containerId); + if (el) el.innerHTML = '
Could not load neighbor data
'; + }); + } + + function renderNeighborData(data, containerId, limit, headerSelector, viewAllPubkey) { + var el = document.getElementById(containerId); + if (!el) return; + if (!data || !data.neighbors || !data.neighbors.length) { + el.innerHTML = '
No neighbor data available yet. Neighbor relationships are built from observed packet paths over time.
'; + if (headerSelector) { + var h = document.querySelector(headerSelector); + if (h) h.textContent = 'Neighbors (0)'; + } + return; + } + if (headerSelector) { + var h = document.querySelector(headerSelector); + if (h) h.textContent = 'Neighbors (' + data.neighbors.length + ')'; + } + var html = renderNeighborTable(data.neighbors, limit); + if (limit && data.neighbors.length > limit && viewAllPubkey) { + html += '
View all ' + data.neighbors.length + ' neighbors β†’
'; + } + el.innerHTML = html; + + // Wire up "Show on Map" buttons via event delegation + el.addEventListener('click', function(e) { + var btn = e.target.closest('.neighbor-show-map'); + if (!btn) return; + var pk = btn.getAttribute('data-pubkey'); + if (pk) location.hash = '#/map?node=' + encodeURIComponent(pk); + }); + } + + // ─── End neighbor helpers ───────────────────────────────────────────────── + let directNode = null; // set when navigating directly to #/nodes/:pubkey let regionChangeHandler = null; @@ -347,6 +451,11 @@ ` : ''} +
+

Neighbors

+
Loading neighbors…
+
+

Paths Through This Node

Loading paths…
@@ -427,6 +536,11 @@ } catch {} } + // Fetch neighbors for this node (full-screen view) + fetchAndRenderNeighbors(n.public_key, 'fullNeighborsContent', { + headerSelector: '#fullNeighborsHeader' + }); + // Fetch paths through this node (full-screen view) api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => { const el = document.getElementById('fullPathsContent'); @@ -819,6 +933,11 @@
` : ''} +
+

Neighbors

+
Loading neighbors…
+
+

Paths Through This Node

Loading paths…
@@ -889,6 +1008,13 @@ } catch {} } + // Fetch neighbors for this node (condensed panel β€” top 5) + fetchAndRenderNeighbors(n.public_key, 'panelNeighborsContent', { + limit: 5, + headerSelector: '#panelNeighborsHeader', + viewAllPubkey: n.public_key + }); + // Fetch paths through this node api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => { const el = document.getElementById('pathsContent'); diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index 624244ac..c026fd21 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -1308,6 +1308,7 @@ async function run() { await page.evaluate(() => localStorage.removeItem('cs-theme-overrides')); }); +<<<<<<< HEAD // --- Group: Show Neighbors (#484 fix) --- await test('Show Neighbors populates neighborPubkeys from affinity API', async () => { @@ -1452,6 +1453,64 @@ async function run() { await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`); await page.unroute(`**/api/nodes/${testPubkey}/paths*`); }); + // ─── Neighbor section tests ─────────────────────────────────────────────── + + await test('Node detail: neighbors section exists with correct columns', async () => { + // Navigate to a node detail page (use the first node in the list) + await page.goto(BASE + '/#/nodes'); + await page.waitForSelector('#nodesBody tr[data-key]', { timeout: 10000 }); + // Get the first node's pubkey from the row's data-key attribute + const pubkey = await page.$eval('#nodesBody tr[data-key]', el => el.dataset.key); + await page.goto(BASE + '/#/nodes/' + pubkey); + await page.waitForSelector('#node-neighbors', { timeout: 10000 }); + // Check the section exists + const header = await page.$eval('#fullNeighborsHeader', el => el.textContent); + assert(header.startsWith('Neighbors'), 'Header should start with "Neighbors", got: ' + header); + // Wait for content to load (either table or empty state) + await page.waitForFunction(() => { + const el = document.getElementById('fullNeighborsContent'); + return el && !el.innerHTML.includes('spinner'); + }, { timeout: 10000 }); + const hasTable = await page.$('#fullNeighborsContent .data-table'); + if (hasTable) { + // Check columns + const headers = await page.$$eval('#fullNeighborsContent thead th', ths => ths.map(t => t.textContent)); + assert(headers.includes('Neighbor'), 'Should have Neighbor column'); + assert(headers.includes('Role'), 'Should have Role column'); + assert(headers.includes('Score'), 'Should have Score column'); + assert(headers.includes('Obs'), 'Should have Obs column'); + assert(headers.includes('Last Seen'), 'Should have Last Seen column'); + assert(headers.includes('Conf'), 'Should have Conf column'); + } else { + // Empty state + const text = await page.$eval('#fullNeighborsContent', el => el.textContent); + assert(text.includes('No neighbor data') || text.includes('Could not load'), 'Should show empty or error state'); + } + }); + + await test('Node detail: neighbors section loading state', async () => { + // Navigate to a node - the section should initially show a spinner + await page.goto(BASE + '/#/nodes'); + await page.waitForSelector('#nodesBody tr[data-key]', { timeout: 10000 }); + const pubkey = await page.$eval('#nodesBody tr[data-key]', el => el.dataset.key); + // Intercept API to delay response + await page.route('**/api/nodes/*/neighbors*', async route => { + await new Promise(r => setTimeout(r, 500)); + await route.continue(); + }); + await page.goto(BASE + '/#/nodes/' + pubkey); + // Check spinner appears + const spinnerVisible = await page.waitForSelector('#fullNeighborsContent .spinner', { timeout: 5000 }).then(() => true).catch(() => false); + assert(spinnerVisible, 'Loading spinner should be visible initially'); + // Wait for loading to finish + await page.waitForFunction(() => { + const el = document.getElementById('fullNeighborsContent'); + return el && !el.innerHTML.includes('spinner'); + }, { timeout: 15000 }); + await page.unroute('**/api/nodes/*/neighbors*'); + }); + + // ─── End neighbor section tests ─────────────────────────────────────────── // Extract frontend coverage if instrumented server is running try {