diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index d1c2e4d9..0eff25f1 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -236,6 +236,7 @@ jobs:
BASE_URL=http://localhost:13581 node test-issue-1136-live-region-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1150-404-state-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1146-path-link-contrast-e2e.js 2>&1 | tee -a e2e-output.txt
+ BASE_URL=http://localhost:13581 node test-issue-1147-section-order-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-rebrand-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-theme-e2e.js 2>&1 | tee -a e2e-output.txt
diff --git a/public/nodes.js b/public/nodes.js
index 89a22c9f..d2336099 100644
--- a/public/nodes.js
+++ b/public/nodes.js
@@ -556,7 +556,38 @@
| Hash Prefix | ${n.hash_size ? '' + n.public_key.slice(0, n.hash_size * 2).toUpperCase() + ' (' + n.hash_size + '-byte)' : 'Unknown'}${n.hash_size_inconsistent ? ' ⚠️ varies' : ''} |
-
+
+ ${(() => { const validPackets = adverts.filter(p => p.hash && p.timestamp); return `
+
Recent Packets (${validPackets.length})
+
+ ${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) : '';
+ const obs = p.observer_name || p.observer_id;
+ const snr = p.snr != null ? ` · SNR ${p.snr}dB` : '';
+ const rssi = p.rssi != null ? ` · RSSI ${p.rssi}dBm` : '';
+ const obsBadge = p.observation_count > 1 ? `
👁 ${p.observation_count}` : '';
+ // Show hash size per advert if inconsistent
+ let hashSizeBadge = '';
+ if (n.hash_size_inconsistent && p.payload_type === 4 && p.raw_hex) {
+ const pb = parseInt(p.raw_hex.slice(2, 4), 16);
+ if ((pb & 0x3F) !== 0) {
+ const hs = ((pb >> 6) & 0x3) + 1;
+ const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316';
+ const hsFg = hs === 2 ? '#064e3b' : '#fff';
+ hashSizeBadge = `
${hs}B`;
+ }
+ }
+ return `
+
${renderNodeTimestampHtml(p.timestamp)}
+
${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}
+
Analyze →
+
`;
+ }).join('') : '
No recent packets
'}
+
+ `; })()}
+
${observers.length ? `
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `
Regions: ${regions.map(r => '' + escapeHtml(r) + '').join(' ')}
` : ''; })()}
@@ -598,38 +629,7 @@
-
- ${(() => { const validPackets = adverts.filter(p => p.hash && p.timestamp); return `
-
Recent Packets (${validPackets.length})
-
- ${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) : '';
- const obs = p.observer_name || p.observer_id;
- const snr = p.snr != null ? ` · SNR ${p.snr}dB` : '';
- const rssi = p.rssi != null ? ` · RSSI ${p.rssi}dBm` : '';
- const obsBadge = p.observation_count > 1 ? `
👁 ${p.observation_count}` : '';
- // Show hash size per advert if inconsistent
- let hashSizeBadge = '';
- if (n.hash_size_inconsistent && p.payload_type === 4 && p.raw_hex) {
- const pb = parseInt(p.raw_hex.slice(2, 4), 16);
- if ((pb & 0x3F) !== 0) {
- const hs = ((pb >> 6) & 0x3) + 1;
- const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316';
- const hsFg = hs === 2 ? '#064e3b' : '#fff';
- hashSizeBadge = `
${hs}B`;
- }
- }
- return `
-
${renderNodeTimestampHtml(p.timestamp)}
-
${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}
-
Analyze →
-
`;
- }).join('') : '
No recent packets
'}
-
- `; })()}
-
`;
+ `;
// Map
if (hasLoc) {
@@ -1359,29 +1359,6 @@
-
-
- ${observers.length ? `
- ${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `
Regions: ${regions.join(', ')}
` : ''; })()}
-
Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})
-
- ${observers.map(o => `
- ${escapeHtml(o.observer_name || o.observer_id)}${o.iata ? ' ' + escapeHtml(o.iata) + '' : ''}
- ${o.packetCount} pkts · ${o.avgSnr != null ? 'SNR ' + Number(o.avgSnr).toFixed(1) + 'dB' : ''}${o.avgRssi != null ? ' · RSSI ' + Number(o.avgRssi).toFixed(0) : ''}
-
`).join('')}
-
-
` : ''}
-
-
-
-
-
Paths Through This Node
-
-
-
${(() => { const validPackets = adverts.filter(a => a.hash && a.timestamp); return `
Recent Packets (${validPackets.length})
@@ -1407,6 +1384,29 @@
`; })()}
+
+ ${observers.length ? `
+ ${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `
Regions: ${regions.join(', ')}
` : ''; })()}
+
Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})
+
+ ${observers.map(o => `
+ ${escapeHtml(o.observer_name || o.observer_id)}${o.iata ? ' ' + escapeHtml(o.iata) + '' : ''}
+ ${o.packetCount} pkts · ${o.avgSnr != null ? 'SNR ' + Number(o.avgSnr).toFixed(1) + 'dB' : ''}${o.avgRssi != null ? ' · RSSI ' + Number(o.avgRssi).toFixed(0) : ''}
+
`).join('')}
+
+
` : ''}
+
+
+
+
+
Paths Through This Node
+
+
+
+
`;
// Init map
diff --git a/test-issue-1147-section-order-e2e.js b/test-issue-1147-section-order-e2e.js
new file mode 100644
index 00000000..73213387
--- /dev/null
+++ b/test-issue-1147-section-order-e2e.js
@@ -0,0 +1,163 @@
+/**
+ * #1147 — Side panel + full node detail page must render "Recent Packets"
+ * directly under Overview, BEFORE Paths/Neighbors/Heard By/Clock Skew.
+ *
+ * Operator mental order: identity → packets they originated → paths they
+ * relay → adverts → meta. Recent Packets currently appears LAST; this is
+ * the regression guard that proves the new ordering.
+ *
+ * Acceptance:
+ * - Full node detail page (#/nodes/): index of "Recent Packets"
+ * section header < index of "Paths Through This Node" header AND
+ * < index of "Heard By" header AND < index of "Neighbors" header
+ * AND < index of "Clock Skew" header (when present).
+ * - Side panel (open from /nodes list): same ordering.
+ *
+ * Usage: BASE_URL=http://localhost:13581 node test-issue-1147-section-order-e2e.js
+ */
+'use strict';
+
+const { chromium } = require('playwright');
+
+const BASE = process.env.BASE_URL || 'http://localhost:13581';
+
+let passed = 0, failed = 0;
+async function step(name, fn) {
+ try { await fn(); passed++; console.log(' ✓ ' + name); }
+ catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
+}
+function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
+
+// Pull all rendered headers (section titles in node detail) in DOM order
+// from a given root. Returns array of trimmed text.
+async function sectionHeaders(page, rootSelector) {
+ return await page.$$eval(`${rootSelector} h4`, els =>
+ els.map(e => (e.textContent || '').trim()));
+}
+
+// Find index of first header whose text starts with `prefix`. -1 if absent.
+function indexOfStarts(headers, prefix) {
+ for (let i = 0; i < headers.length; i++) {
+ if (headers[i].startsWith(prefix)) return i;
+ }
+ return -1;
+}
+
+(async () => {
+ const browser = await chromium.launch({
+ headless: true,
+ executablePath: process.env.CHROMIUM_PATH || undefined,
+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
+ });
+ const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 } });
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(15000);
+ page.on('pageerror', (e) => console.error('[pageerror]', e.message));
+
+ console.log(`\n=== #1147 section-order E2E against ${BASE} ===`);
+
+ // ─── Pick a node from the live API ───
+ await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' });
+ const pubkey = await page.evaluate(async () => {
+ const r = await fetch('/api/nodes?limit=20');
+ const d = await r.json();
+ // Prefer a node with an advert_count > 0 so Recent Packets has content,
+ // but ANY node should render the section header (with an empty state).
+ const cand = (d.nodes || []).find(n => (n.advert_count || 0) > 0) ||
+ (d.nodes || [])[0];
+ return cand && cand.public_key;
+ });
+ assert(pubkey, 'No node returned from /api/nodes');
+ console.log(' → using probe pubkey: ' + pubkey.slice(0, 12) + '…');
+
+ // ─── Case 1: Full node detail page ───
+ await step('full page: Recent Packets appears before Paths/Heard By/Neighbors/Clock Skew', async () => {
+ await page.goto(BASE + '/#/nodes/' + encodeURIComponent(pubkey), { waitUntil: 'domcontentloaded' });
+ // Wait for the body container to render (the full detail uses .node-full-card).
+ await page.waitForSelector('.node-full-card', { timeout: 10000 });
+ // Wait until at least one "Recent Packets" header is in the DOM.
+ await page.waitForFunction(() => {
+ return Array.from(document.querySelectorAll('h4'))
+ .some(h => (h.textContent || '').trim().startsWith('Recent Packets'));
+ }, { timeout: 10000 });
+
+ const headers = await sectionHeaders(page, 'body');
+ console.log(' headers (full page): ' + JSON.stringify(headers));
+
+ const iRecent = indexOfStarts(headers, 'Recent Packets');
+ const iPaths = indexOfStarts(headers, 'Paths Through This Node');
+ const iHeard = indexOfStarts(headers, 'Heard By');
+ const iNeigh = indexOfStarts(headers, 'Neighbors');
+ // Clock Skew is hidden by default but rendered later; only enforce ordering
+ // when its container has visible content. Skip if absent.
+
+ assert(iRecent !== -1, 'Recent Packets header not found on full page');
+ assert(iPaths !== -1, 'Paths Through This Node header not found on full page');
+ assert(iRecent < iPaths,
+ `Recent Packets (idx ${iRecent}) must appear BEFORE Paths Through This Node (idx ${iPaths}) on full page`);
+ if (iHeard !== -1) {
+ assert(iRecent < iHeard,
+ `Recent Packets (idx ${iRecent}) must appear BEFORE Heard By (idx ${iHeard}) on full page`);
+ }
+ if (iNeigh !== -1) {
+ assert(iRecent < iNeigh,
+ `Recent Packets (idx ${iRecent}) must appear BEFORE Neighbors (idx ${iNeigh}) on full page`);
+ }
+ });
+
+ // ─── Case 2: Side panel (opened from /nodes list) ───
+ await step('side panel: Recent Packets appears before Paths/Heard By/Neighbors', async () => {
+ await page.goto(BASE + '/#/nodes', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 });
+ await page.waitForSelector('table tbody tr:not([id^=vscroll])', { timeout: 15000 });
+ // Click the row matching our probe pubkey if possible; fall back to first row.
+ const clicked = await page.evaluate((pk) => {
+ const rows = Array.from(document.querySelectorAll('table tbody tr'));
+ // Try data attributes first
+ let target = rows.find(r => (r.getAttribute('data-pubkey') || '').toLowerCase() === pk.toLowerCase());
+ // Fall back: any row whose text contains the pubkey prefix
+ if (!target) target = rows.find(r => r.textContent && r.textContent.toLowerCase().includes(pk.slice(0, 8).toLowerCase()));
+ // Last resort: first non-vscroll row
+ if (!target) target = rows.find(r => !(r.id || '').startsWith('vscroll'));
+ if (target) { target.click(); return true; }
+ return false;
+ }, pubkey);
+ assert(clicked, 'Could not click any node row to open side panel');
+
+ // Wait for side panel to render the detail container.
+ await page.waitForSelector('.node-detail', { timeout: 10000 });
+ // Wait until Recent Packets header lands in the side panel scope.
+ await page.waitForFunction(() => {
+ const root = document.querySelector('.node-detail');
+ if (!root) return false;
+ return Array.from(root.querySelectorAll('h4'))
+ .some(h => (h.textContent || '').trim().startsWith('Recent Packets'));
+ }, { timeout: 10000 });
+
+ const headers = await sectionHeaders(page, '.node-detail');
+ console.log(' headers (side panel): ' + JSON.stringify(headers));
+
+ const iRecent = indexOfStarts(headers, 'Recent Packets');
+ const iPaths = indexOfStarts(headers, 'Paths Through This Node');
+ const iHeard = indexOfStarts(headers, 'Heard By');
+ const iNeigh = indexOfStarts(headers, 'Neighbors');
+
+ assert(iRecent !== -1, 'Recent Packets header not found in side panel');
+ assert(iPaths !== -1, 'Paths Through This Node header not found in side panel');
+ assert(iRecent < iPaths,
+ `Recent Packets (idx ${iRecent}) must appear BEFORE Paths Through This Node (idx ${iPaths}) in side panel`);
+ if (iHeard !== -1) {
+ assert(iRecent < iHeard,
+ `Recent Packets (idx ${iRecent}) must appear BEFORE Heard By (idx ${iHeard}) in side panel`);
+ }
+ if (iNeigh !== -1) {
+ assert(iRecent < iNeigh,
+ `Recent Packets (idx ${iRecent}) must appear BEFORE Neighbors (idx ${iNeigh}) in side panel`);
+ }
+ });
+
+ await browser.close();
+
+ console.log(`\n${passed}/${passed + failed} passed`);
+ process.exit(failed === 0 ? 0 : 1);
+})().catch(e => { console.error(e); process.exit(2); });