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
+ ? ' 📍 Map '
+ : '';
+ return '' +
+ '' + nameHtml + ' ' +
+ '' + roleBadge + ' ' +
+ '' + Number(nb.score).toFixed(2) + ' ' +
+ '' + nb.count + ' ' +
+ '' + renderNodeTimestampHtml(nb.last_seen) + ' ' +
+ '' + conf.icon + ' ' +
+ '' + showOnMap + ' ' +
+ ' ';
+ }).join('');
+ }
+
+ function renderNeighborTable(neighbors, limit) {
+ return '' +
+ 'Neighbor Role Score Obs Last Seen Conf ' +
+ '' + renderNeighborRows(neighbors, limit) + '
';
+ }
+
+ 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 += '';
+ }
+ 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 @@
` : ''}
+
+
Paths Through This Node
@@ -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 @@
` : ''}
+
+
Paths Through This Node
@@ -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 {