Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions public/nodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -747,9 +747,11 @@

<div class="node-full-card" id="node-packets">
${(() => { const validPackets = adverts.filter(p => p.hash && p.timestamp); return `
<h4 title="Adverts this node originated. The section is limited to adverts because they are the only packet type attributable to an originating node: transmissions.from_pubkey is populated for ADVERTs only, so a relayed CHAN or TXT packet cannot be traced back to its sender without path resolution.">Recent Adverts (${validPackets.length})</h4>
<h4 title="Adverts this node originated. The section is limited to adverts because they are the only packet type attributable to an originating node: transmissions.from_pubkey is populated for ADVERTs only, so a relayed CHAN or TXT packet cannot be traced back to its sender without path resolution. Groups use available observations; older history may be incomplete.">Recent Adverts (${validPackets.length})</h4>
<div class="node-activity-list">
${validPackets.length ? validPackets.map(p => {
${window.groupRecentAdverts(validPackets).map(group => `<div class="node-advert-group" data-advert-kind="${group.kind}">
<h5 title="Count in this recent sample, not the node's lifetime total.">${group.label} (${group.adverts.length})</h5>
${group.adverts.length ? group.adverts.map(p => {
let decoded; try { decoded = JSON.parse(p.decoded_json); } catch {}
const typeLabel = p.payload_type === 4 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Advert' : p.payload_type === 5 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chat-circle"/></svg> Channel' : p.payload_type === 2 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope"/></svg> DM' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-package"/></svg> Packet';
const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : '';
Expand All @@ -773,7 +775,8 @@
<span>${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}</span>
<a href="#/packets/${p.hash}" class="ch-analyze-link" style="margin-left:8px;font-size:0.8em">Analyze →</a>
</div>`;
}).join('') : '<div class="text-muted">No recent packets</div>'}
}).join('') : '<div class="text-muted">None in this recent sample</div>'}
</div>`).join('')}
</div>
`; })()}
</div>
Expand Down Expand Up @@ -1738,9 +1741,11 @@

<div class="node-detail-section">
${(() => { const validPackets = adverts.filter(a => a.hash && a.timestamp); return `
<h4 title="Adverts this node originated. The section is limited to adverts because they are the only packet type attributable to an originating node: transmissions.from_pubkey is populated for ADVERTs only, so a relayed CHAN or TXT packet cannot be traced back to its sender without path resolution.">Recent Adverts (${validPackets.length})</h4>
<h4 title="Adverts this node originated. The section is limited to adverts because they are the only packet type attributable to an originating node: transmissions.from_pubkey is populated for ADVERTs only, so a relayed CHAN or TXT packet cannot be traced back to its sender without path resolution. Groups use available observations; older history may be incomplete.">Recent Adverts (${validPackets.length})</h4>
<div id="advertTimeline">
${validPackets.length ? validPackets.map(a => {
${window.groupRecentAdverts(validPackets).map(group => `<div class="node-advert-group" data-advert-kind="${group.kind}">
<h5 title="Count in this recent sample, not the node's lifetime total.">${group.label} (${group.adverts.length})</h5>
${group.adverts.length ? group.adverts.map(a => {
let decoded;
try { decoded = JSON.parse(a.decoded_json); } catch {}
const pType = PAYLOAD_TYPES[a.payload_type] || 'Packet';
Expand All @@ -1757,7 +1762,8 @@
<br><a href="#/packets/${a.hash}" class="ch-analyze-link">Analyze →</a>
</div>
</div>`;
}).join('') : '<div class="text-muted" style="padding:8px">No recent packets</div>'}
}).join('') : '<div class="text-muted">None in this recent sample</div>'}
</div>`).join('')}
</div>
`; })()}
</div>
Expand Down
23 changes: 23 additions & 0 deletions public/roles.js
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,29 @@
};
};

// The API accumulates route evidence across observations of the same advert.
// A canonical frame or selected observation cannot prove an exclusive kind.
window.classifyRecentAdvert = function (advert) {
var kind = advert && advert.advert_kind;
if (kind === 'flood' || kind === 'mixed') return kind;
return kind === 'zero_hop' ? 'zero-hop' : 'other';
};

// One pass over the API's bounded recent sample; keep input order and rows intact.
window.groupRecentAdverts = function (adverts) {
var groups = [
{ kind: 'flood', label: 'Flood adverts', adverts: [] },
{ kind: 'mixed', label: 'Mixed flood / zero-hop adverts', adverts: [] },
{ kind: 'zero-hop', label: 'Zero-hop adverts', adverts: [] },
{ kind: 'other', label: 'Other / unknown adverts', adverts: [] },
];
adverts.forEach(function (advert) {
var kind = window.classifyRecentAdvert(advert);
groups[kind === 'flood' ? 0 : kind === 'mixed' ? 1 : kind === 'zero-hop' ? 2 : 3].adverts.push(advert);
});
return groups.filter(function (group) { return group.kind === 'flood' || group.kind === 'zero-hop' || group.adverts.length; });
};

/** Render a skew sparkline SVG (inline, word-sized) */
window.renderSkewSparkline = function(samples, w, h) {
w = w || 120; h = h || 24;
Expand Down
1 change: 1 addition & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2060,6 +2060,7 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
.btn-primary:hover { background: var(--accent-hover); }

/* Advert Timeline */
.node-advert-group h5 { margin: 8px 0; font-size: 12px; color: var(--text-muted); }
.advert-entry {
display: flex; align-items: flex-start; gap: 10px; padding: 8px 0;
border-bottom: 1px solid var(--border);
Expand Down
82 changes: 82 additions & 0 deletions tests/e2e/test-e2e-playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,88 @@ async function run() {

console.log(`\nRunning E2E tests against ${BASE}\n`);

// API contract fixtures exercise both real node renderers at desktop/mobile sizes.
for (const width of [1280, 375]) {
await test(`#2073 recent adverts grouped in both node views at ${width}px`, async () => {
const fixtureContext = await browser.newContext({ viewport: { width, height: 900 } });
const fixturePage = await fixtureContext.newPage();
const pubkey = 'a'.repeat(64);
const node = { public_key: pubkey, name: 'Advert fixture', role: 'repeater',
last_seen: new Date().toISOString(), advert_count: 900 };
const routes = [
{ advert_kind: 'flood', route_type: 1, raw_hex: '110000' },
{ advert_kind: 'zero_hop', route_type: 2, raw_hex: '120000' },
{ advert_kind: 'flood', route_type: 0, raw_hex: '10010203040000' },
{ advert_kind: 'zero_hop', route_type: 3, raw_hex: '13010203040000' },
{ advert_kind: 'other', route_type: 2, raw_hex: '1201ab00', path_json: '[]' },
{ route_type: null, raw_hex: null },
{ route_type: 2, raw_hex: '1200zz' },
{ advert_kind: 'mixed', route_type: 1, raw_hex: '110000' },
{ advert_kind: 'mixed', route_type: 2, raw_hex: '120000' },
{ route_type: 1, raw_hex: '110000' },
{ advert_kind: 'future_kind', route_type: 2, raw_hex: '120000' },
];
let adverts = routes.map((route, i) => ({ ...route, hash: String(i + 1).repeat(16),
timestamp: new Date(Date.now() - i * 60000).toISOString(), payload_type: 4,
observer_name: `Fixture observer ${i + 1}`, snr: 7 + i, rssi: -80 - i, observation_count: 2 }));
await fixturePage.route('**/api/nodes**', async route => {
const path = new URL(route.request().url()).pathname;
let body;
if (path === '/api/nodes') body = { nodes: [node], total: 1 };
else if (path === '/api/nodes/' + pubkey) body = { node, recentAdverts: adverts };
else if (path === '/api/nodes/' + pubkey + '/health') body = {};
else return route.continue();
await route.fulfill({ json: body });
});
try {
for (const empty of [false, true]) {
if (empty) adverts = [];
for (const full of [false, true]) {
await fixturePage.goto(`${BASE}/#/nodes${full ? '/' + pubkey : ''}`, { waitUntil: 'domcontentloaded' });
await fixturePage.reload({ waitUntil: 'domcontentloaded' });
if (!full) await fixturePage.locator(`tr[data-key="${pubkey}"]`).click();
// On phones a list click opens the full page; only desktop has a side pane.
const fullView = full || width <= 640;
const root = fullView ? '#node-packets' : '#advertTimeline';
await fixturePage.locator(root).waitFor();
const groups = await fixturePage.locator(root + ' [data-advert-kind]').evaluateAll(els => els.map(el => ({
kind: el.dataset.advertKind,
heading: el.querySelector('h5').textContent.trim(),
rows: Array.from(el.querySelectorAll('a.ch-analyze-link'), a => ({
href: a.getAttribute('href'),
text: a.closest('.node-activity-item, .advert-entry').textContent,
})),
text: el.textContent,
overflow: el.scrollWidth > el.clientWidth + 1,
})));
assert(groups.length === (empty ? 2 : 4), `Expected ${empty ? 2 : 4} advert groups, got ${groups.length}`);
const expected = empty ? [[], []] : [[0, 2], [7, 8], [1, 3], [4, 5, 6, 9, 10]];
const labels = empty ? ['Flood adverts', 'Zero-hop adverts'] : ['Flood adverts', 'Mixed flood / zero-hop adverts', 'Zero-hop adverts', 'Other / unknown adverts'];
assert(groups.reduce((count, group) => count + group.rows.length, 0) === adverts.length, 'Each advert must appear exactly once');
expected.forEach((indices, i) => {
assert(groups[i].heading === `${labels[i]} (${indices.length})`, `Wrong sample count: ${groups[i].heading}`);
assert(JSON.stringify(groups[i].rows.map(row => row.href)) === JSON.stringify(indices.map(j => '#/packets/' + adverts[j].hash)), 'Advert order or analyze links changed');
assert(!groups[i].overflow, `${labels[i]} overflows at ${width}px`);
if (empty) assert(groups[i].text.includes('None in this recent sample'), `${labels[i]} empty-state message missing`);
indices.forEach((j, rowIndex) => {
const advert = adverts[j];
const row = groups[i].rows[rowIndex];
assert(row.text.includes(advert.observer_name) && row.text.includes(`SNR ${advert.snr}dB`) && row.text.includes(`RSSI ${advert.rssi}dBm`), `RF/observer metadata changed for ${row.href}`);
});
});
const heading = fullView ? fixturePage.locator('#node-packets h4') : fixturePage.locator('#advertTimeline').locator('..').locator('h4');
assert(await heading.textContent() === `Recent Adverts (${adverts.length})`, 'Recent Adverts count must reflect sample, not lifetime');
assert((await heading.getAttribute('title')).includes('originated'), 'Existing origin tooltip lost');
const explanation = await heading.getAttribute('title');
assert(explanation.includes('available observations') && explanation.includes('older history may be incomplete'), 'Grouping must explain the available-evidence limit');
}
}
} finally {
await fixtureContext.close();
}
});
}

// --- Group: Home page (tests 1, 6, 7) ---

// Test 1: Home page loads
Expand Down
109 changes: 109 additions & 0 deletions tests/unit/test-frontend-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -6937,6 +6937,115 @@ console.log('\n=== map.js: hash size fallback ===');
});
}

// ===== Recent advert routing (#2073) =====
console.log('\n=== roles.js: recent advert groups (#2073) ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
function classify(packet) {
assert.strictEqual(typeof ctx.classifyRecentAdvert, 'function', 'shared recent-advert classifier must exist');
return ctx.classifyRecentAdvert(packet);
}
test('accumulated mixed evidence wins for the same hash in either arrival order', () => {
for (const canonical of [{ route_type: 1, raw_hex: '110000' }, { route_type: 2, raw_hex: '120000' }]) {
const advert = { ...canonical, hash: 'same-advert', advert_kind: 'mixed', path_json: '[]' };
assert.strictEqual(classify(advert), 'mixed', 'first received frame must not override accumulated evidence');
const groups = ctx.groupRecentAdverts([advert]);
assert.strictEqual(groups.reduce((n, group) => n + group.adverts.length, 0), 1, 'mixed advert must not be counted twice');
const mixed = groups.find(group => group.kind === 'mixed');
assert.ok(mixed, 'mixed group must exist');
assert.strictEqual(mixed.adverts[0], advert, 'row metadata must be retained');
assert.deepStrictEqual(Array.from(groups, group => group.kind), ['flood', 'mixed', 'zero-hop']);
assert.strictEqual(mixed.label, 'Mixed flood / zero-hop adverts');
}
});
test('authoritative advert kind does not depend on canonical route or path', () => {
for (const [advert_kind, expected] of [['flood', 'flood'], ['zero_hop', 'zero-hop'], ['mixed', 'mixed'], ['other', 'other']]) {
assert.strictEqual(classify({ advert_kind }), expected);
assert.strictEqual(classify({ advert_kind, route_type: 1, raw_hex: '110000', path_json: '[]' }), expected);
assert.strictEqual(classify({ advert_kind, route_type: 2, raw_hex: '120000', path_json: '["ab"]' }), expected);
}
});
test('legacy and unrecognized advert evidence stays unknown despite known-looking frames', () => {
for (const advert_kind of [undefined, null, '', 'zero-hop', 'future_kind', 1, false, ['flood']]) {
for (const canonical of [{ route_type: 1, raw_hex: '110000' }, { route_type: 2, raw_hex: '120000' }]) {
assert.strictEqual(classify({ ...canonical, advert_kind, path_json: '[]', observations: [{ raw_hex: '110000' }, { raw_hex: '120000' }] }), 'other');
}
}
});
test('mixed groups preserve per-group order and hide only empty optional groups', () => {
const adverts = ['mixed', 'zero_hop', 'flood', 'mixed', 'other'].map((advert_kind, i) => ({ advert_kind, hash: String(i) }));
const before = JSON.stringify(adverts);
const groups = ctx.groupRecentAdverts(adverts);
assert.deepStrictEqual(JSON.parse(JSON.stringify(groups.map(group => [group.kind, group.adverts.map(advert => advert.hash)]))), [
['flood', ['2']], ['mixed', ['0', '3']], ['zero-hop', ['1']], ['other', ['4']],
]);
assert.strictEqual(groups.reduce((n, group) => n + group.adverts.length, 0), adverts.length);
assert.strictEqual(JSON.stringify(adverts), before);
assert.deepStrictEqual(Array.from(ctx.groupRecentAdverts([]), group => group.kind), ['flood', 'zero-hop']);
});
test('legacy flood routes without accumulated evidence stay unknown', () => {
for (const route of [0, 1, '0', '1']) {
assert.strictEqual(classify({ route_type: route, path_json: '[]' }), 'other');
}
assert.strictEqual(classify({ raw_hex: '110000' }), 'other');
assert.strictEqual(classify({ raw_hex: '10010203040000' }), 'other');
});
test('legacy direct routes cannot prove zero-hop from a selected empty path', () => {
for (const route of [2, 3, '2', '3']) {
assert.strictEqual(classify({ route_type: route, path_json: '[]' }), 'other');
assert.strictEqual(classify({ route_type: route, path_json: [] }), 'other');
assert.strictEqual(classify({ route_type: route }), 'other');
assert.strictEqual(classify({ route_type: route, path_json: '["ab"]' }), 'other');
}
assert.strictEqual(classify({ route_type: null, raw_hex: '120000' }), 'other');
assert.strictEqual(classify({ raw_hex: '13010203040000' }), 'other');
});
test('neither canonical transmission nor selected observation path fills missing evidence', () => {
assert.strictEqual(classify({ route_type: 2, raw_hex: '1201ab00', path_json: '[]' }), 'other');
assert.strictEqual(classify({ route_type: 2, raw_hex: '120000', path_json: '["ab"]' }), 'other');
assert.strictEqual(classify({ route_type: 3, raw_hex: '130002030401ab00', path_json: '[]' }), 'other');
assert.strictEqual(classify({ route_type: 1, raw_hex: '114000' }), 'other');
assert.strictEqual(classify({ route_type: 2, raw_hex: '124000' }), 'other');
});
test('unknown, malformed and contradictory routing is never guessed', () => {
for (const packet of [null, {}, { route_type: null }, { route_type: '' },
{ route_type: false }, { route_type: [] }, { route_type: [2], path_json: '[]' },
{ route_type: {} }, { route_type: 4 }, { route_type: -1 }, { route_type: 2.5 },
{ route_type: 'flood' }, { route_type: 2, path_json: null },
{ route_type: 2, path_json: '' }, { route_type: 2, path_json: 'null' },
{ route_type: 2, path_json: '{}' }, { route_type: 2, path_json: '[' },
{ route_type: 1, raw_hex: '120000' }, { route_type: 2, raw_hex: '110000' },
{ route_type: 2, raw_hex: '12' }, { route_type: 2, raw_hex: '120' },
{ route_type: 2, raw_hex: '1200zz' }, { route_type: 2, raw_hex: '12c000' },
{ route_type: 2, raw_hex: '1202ab' }, { route_type: 3, raw_hex: '130000' }]) {
assert.strictEqual(classify(packet), 'other', JSON.stringify(packet));
}
});
test('grouping preserves every row, per-group order and sample counts without mutation', () => {
assert.strictEqual(typeof ctx.groupRecentAdverts, 'function', 'shared recent-advert grouping must exist');
const packets = [
{ hash: 'a', advert_kind: 'zero_hop', route_type: 2, path_json: '[]' },
{ hash: 'b', advert_kind: 'flood', route_type: 1 },
{ hash: 'c', route_type: null },
{ hash: 'd', advert_kind: 'flood', route_type: 0 },
{ hash: 'e', advert_kind: 'zero_hop', route_type: 3, path_json: '[]' },
{ hash: 'f', route_type: 2, path_json: '["ab"]' },
];
const before = JSON.stringify(packets);
const groups = ctx.groupRecentAdverts(packets);
assert.deepStrictEqual(JSON.parse(JSON.stringify(groups.map(g => [g.kind, g.label, g.adverts.map(p => p.hash)]))), [
['flood', 'Flood adverts', ['b', 'd']],
['zero-hop', 'Zero-hop adverts', ['a', 'e']],
['other', 'Other / unknown adverts', ['c', 'f']],
]);
assert.strictEqual(groups.reduce((n, g) => n + g.adverts.length, 0), packets.length);
assert.strictEqual(groups[0].adverts[0], packets[1]);
assert.strictEqual(JSON.stringify(packets), before);
assert.strictEqual(ctx.groupRecentAdverts([]).reduce((n, g) => n + g.adverts.length, 0), 0);
});
}

// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);
Expand Down
Loading