mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-08 00:11:38 +00:00
fix(#849): Packet Detail dialog — show exact clicked observation, not cross-observer aggregate (#851)
## Problem The Packet Detail dialog summary (Observer, Path, Hops, SNR/RSSI, Timestamp) used the **aggregated cross-observer view** (`_parsedPath` / `getParsedPath(pkt)`), which contradicted the byte breakdown after #844. A packet observed with 2 hops by one observer would show "Path: 7 hops" in the summary because it merged all observers' paths. ## Fix The dialog is now **per-observation**: - `renderDetail` resolves a `currentObservation` from `selectedObservationId` (set when clicking an observation child row) or defaults to `observations[0]` - All summary fields read from the current observation: Observer, SNR/RSSI, Timestamp, Path, Direction - Hop count badge comes from `path_len & 0x3F` of the observation's `raw_hex` (firmware truth, same source as byte breakdown). Cross-checked against `path_json` length — logs a console warning on mismatch - **Observations table** rendered inside the detail panel when multiple observations exist. Clicking a row updates `currentObservation` and re-renders the summary in-place (no dialog close/reopen) - `.observation-current` CSS class highlights the selected observation row ### Cross-observer aggregate (Option B) A read-only "Cross-observer aggregate" section below the observations table shows the longest observed path across all observers. This is **not** the default view — it's always visible as secondary context. ## Tests 8 new tests in `test-frontend-helpers.js`: - Hop count extraction from raw_hex (normal, direct, transport route types) - Inconsistency detection between path_json and raw_hex - Per-observation field override of aggregated packet fields - First observation used when no specific observation selected - Observation row click selects that observation - Null/missing raw_hex handling All 572 tests pass (564 frontend + 62 filter + 29 aging). ## Acceptance - Summary shows per-observation path/hops/SNR/RSSI/timestamp - Switching observations in the detail updates everything - Cross-observer aggregate available as secondary section - Byte breakdown untouched (owned by #846) ## Related - Closes #849 - Related: #844 (#846) — byte breakdown fix (separate PR, different code region) --------- Co-authored-by: you <you@example.com>
This commit is contained in:
+99
-14
@@ -1804,12 +1804,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function renderDetail(panel, data) {
|
||||
async function renderDetail(panel, data, chosenObsId) {
|
||||
const pkt = data.packet;
|
||||
const breakdown = data.breakdown || {};
|
||||
const ranges = breakdown.ranges || [];
|
||||
const decoded = getParsedDecoded(pkt) || {};
|
||||
const pathHops = getParsedPath(pkt) || [];
|
||||
const observations = data.observations || [];
|
||||
|
||||
// Per-observation rendering (issue #849):
|
||||
// When opened from a packet row (no specific observer), default to first observation.
|
||||
// When opened from an observation child row, use that observation.
|
||||
// Clicking a different observation row in the detail re-renders with that observation.
|
||||
let currentObs = null;
|
||||
const targetObsId = chosenObsId || selectedObservationId;
|
||||
if (targetObsId && observations.length) {
|
||||
currentObs = observations.find(o => String(o.id) === String(targetObsId));
|
||||
}
|
||||
if (!currentObs && observations.length) {
|
||||
currentObs = observations[0]; // fall back to first observation
|
||||
}
|
||||
|
||||
// If we have a current observation, build pkt fields from it so summary is per-observation
|
||||
const effectivePkt = currentObs ? clearParsedCache({...pkt, ...currentObs, _isObservation: true}) : pkt;
|
||||
const decoded = getParsedDecoded(effectivePkt) || {};
|
||||
const pathHops = getParsedPath(effectivePkt) || [];
|
||||
|
||||
// Cross-check: hop count from raw_hex path_len byte vs path_json length
|
||||
const obsRawHex = effectivePkt.raw_hex || pkt.raw_hex || '';
|
||||
let rawHopCount = null;
|
||||
if (obsRawHex.length >= 4) {
|
||||
// path_len byte position depends on route type
|
||||
let plOff = 1;
|
||||
if (pkt.route_type === 0 || pkt.route_type === 3) plOff = 5;
|
||||
const plByte = parseInt(obsRawHex.slice(plOff * 2, plOff * 2 + 2), 16);
|
||||
if (!isNaN(plByte)) rawHopCount = plByte & 0x3F;
|
||||
}
|
||||
if (rawHopCount != null && pathHops.length !== rawHopCount) {
|
||||
console.warn(`[CoreScope] Hop count inconsistency for packet ${pkt.hash}: path_json has ${pathHops.length} hops but raw_hex path_len has ${rawHopCount}. Trusting raw_hex.`);
|
||||
}
|
||||
|
||||
// Resolve sender GPS — from packet directly, or from known node in DB
|
||||
let senderLat = decoded.lat != null ? decoded.lat : (decoded.latitude || null);
|
||||
@@ -1856,12 +1887,12 @@
|
||||
const rawPathByte = pkt.raw_hex ? parseInt(pkt.raw_hex.slice(2, 4), 16) : NaN;
|
||||
const hashSize = (isNaN(rawPathByte) || (rawPathByte & 0x3F) === 0) ? null : ((rawPathByte >> 6) + 1);
|
||||
|
||||
const size = pkt.raw_hex ? Math.floor(pkt.raw_hex.length / 2) : 0;
|
||||
const size = effectivePkt.raw_hex ? Math.floor(effectivePkt.raw_hex.length / 2) : (pkt.raw_hex ? Math.floor(pkt.raw_hex.length / 2) : 0);
|
||||
const typeName = payloadTypeName(pkt.payload_type);
|
||||
|
||||
const snr = pkt.snr ?? decoded.SNR ?? decoded.snr ?? null;
|
||||
const rssi = pkt.rssi ?? decoded.RSSI ?? decoded.rssi ?? null;
|
||||
const hasRawHex = !!pkt.raw_hex;
|
||||
const snr = effectivePkt.snr ?? decoded.SNR ?? decoded.snr ?? null;
|
||||
const rssi = effectivePkt.rssi ?? decoded.RSSI ?? decoded.rssi ?? null;
|
||||
const hasRawHex = !!(effectivePkt.raw_hex || pkt.raw_hex);
|
||||
|
||||
// Build message preview
|
||||
let messageHtml = '';
|
||||
@@ -1882,7 +1913,6 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const observations = data.observations || [];
|
||||
const obsCount = data.observation_count || observations.length || 1;
|
||||
const uniqueObservers = new Set(observations.map(o => o.observer_id)).size;
|
||||
|
||||
@@ -1945,21 +1975,28 @@
|
||||
? `<div class="anomaly-banner" style="background:var(--warning, #f0ad4e); color:#000; padding:8px 12px; border-radius:4px; margin-bottom:8px; font-weight:600;">⚠️ Anomaly: ${escapeHtml(decoded.anomaly)}</div>`
|
||||
: '';
|
||||
|
||||
// Hop count display: trust raw_hex (firmware truth) over path_json
|
||||
const displayHopCount = rawHopCount != null ? rawHopCount : pathHops.length;
|
||||
const obsIndicator = currentObs && observations.length > 1
|
||||
? `<span style="font-size:0.8em;color:var(--muted);margin-left:6px">(observation ${observations.indexOf(currentObs) + 1} of ${observations.length})</span>`
|
||||
: '';
|
||||
|
||||
panel.innerHTML = `
|
||||
${anomalyBanner}
|
||||
<div class="detail-title">${hasRawHex ? `Packet Byte Breakdown (${size} bytes)` : typeName + ' Packet'}</div>
|
||||
<div class="detail-hash">${pkt.hash || 'Packet #' + pkt.id}</div>
|
||||
<div class="detail-hash">${pkt.hash || 'Packet #' + pkt.id}${obsIndicator}</div>
|
||||
${messageHtml}
|
||||
<dl class="detail-meta">
|
||||
<dt>Observer</dt><dd>${obsName(pkt.observer_id)}</dd>
|
||||
<dt>Observer</dt><dd>${obsName(effectivePkt.observer_id)}</dd>
|
||||
<dt>Location</dt><dd>${locationHtml}</dd>
|
||||
<dt>SNR / RSSI</dt><dd>${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}</dd>
|
||||
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
|
||||
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
|
||||
${hashSize ? `<dt>Hash Size</dt><dd>${hashSize} byte${hashSize !== 1 ? 's' : ''}</dd>` : ''}
|
||||
<dt>Timestamp</dt><dd>${renderTimestampCell(pkt.timestamp)}</dd>
|
||||
<dt>Timestamp</dt><dd>${renderTimestampCell(effectivePkt.timestamp)}</dd>
|
||||
<dt>Propagation</dt><dd>${propagationHtml}</dd>
|
||||
<dt>Path</dt><dd>${pathHops.length ? renderPath(pathHops, pkt.observer_id) : '—'}</dd>
|
||||
<dt>Path</dt><dd>${displayHopCount > 0 ? `<span class="badge badge-info">${displayHopCount} hop${displayHopCount !== 1 ? 's' : ''}</span> ` + renderPath(pathHops, effectivePkt.observer_id) : '— (direct)'}</dd>
|
||||
${effectivePkt.direction ? `<dt>Direction</dt><dd>${escapeHtml(effectivePkt.direction)}</dd>` : ''}
|
||||
</dl>
|
||||
<div class="detail-actions">
|
||||
<button class="copy-link-btn" data-packet-hash="${pkt.hash || ''}" data-packet-id="${pkt.id}" title="Copy link to this packet">🔗 Copy Link</button>
|
||||
@@ -1969,11 +2006,59 @@
|
||||
</div>
|
||||
|
||||
${hasRawHex ? `<div class="hex-legend">${buildHexLegend(ranges)}</div>
|
||||
<div class="hex-dump">${createColoredHexDump(pkt.raw_hex, ranges)}</div>` : ''}
|
||||
<div class="hex-dump">${createColoredHexDump(effectivePkt.raw_hex || pkt.raw_hex, ranges)}</div>` : ''}
|
||||
|
||||
${hasRawHex ? buildFieldTable(pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)}
|
||||
${hasRawHex ? buildFieldTable(effectivePkt.raw_hex ? effectivePkt : pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)}
|
||||
|
||||
${observations.length > 1 ? `
|
||||
<div class="detail-observations" style="margin-top:16px">
|
||||
<div style="font-weight:600;margin-bottom:6px">Observations (${observations.length})</div>
|
||||
<table class="detail-obs-table" style="width:100%;border-collapse:collapse;font-size:0.9em">
|
||||
<thead><tr style="border-bottom:1px solid var(--border)">
|
||||
<th style="padding:4px 6px;text-align:left">Observer</th>
|
||||
<th style="padding:4px 6px;text-align:left">Hops</th>
|
||||
<th style="padding:4px 6px;text-align:left">SNR</th>
|
||||
<th style="padding:4px 6px;text-align:left">RSSI</th>
|
||||
<th style="padding:4px 6px;text-align:left">Time</th>
|
||||
</tr></thead>
|
||||
<tbody>${observations.map(o => {
|
||||
const oPath = getParsedPath(o);
|
||||
const isCurrent = currentObs && String(o.id) === String(currentObs.id);
|
||||
return `<tr class="detail-obs-row${isCurrent ? ' observation-current' : ''}" data-obs-id="${o.id}" style="cursor:pointer;${isCurrent ? 'background:var(--accent-bg, rgba(0,122,255,0.1))' : ''}" title="Click to view this observation">
|
||||
<td style="padding:4px 6px">${obsName(o.observer_id)}</td>
|
||||
<td style="padding:4px 6px">${oPath.length}</td>
|
||||
<td style="padding:4px 6px">${o.snr != null ? o.snr + ' dB' : '—'}</td>
|
||||
<td style="padding:4px 6px">${o.rssi != null ? o.rssi + ' dBm' : '—'}</td>
|
||||
<td style="padding:4px 6px">${renderTimestampCell(o.timestamp)}</td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>
|
||||
</div>` : ''}
|
||||
|
||||
${observations.length > 1 ? (() => {
|
||||
// Cross-observer aggregate (Option B): show longest observed path across all observers
|
||||
const aggregatePath = getParsedPath(pkt) || [];
|
||||
return `<div class="detail-aggregate" style="margin-top:12px;padding:10px;background:var(--card-bg);border-radius:6px;border:1px solid var(--border);font-size:0.9em">
|
||||
<div style="font-weight:600;margin-bottom:4px;color:var(--muted)">Cross-observer aggregate</div>
|
||||
<div>Longest observed path: ${aggregatePath.length ? `${aggregatePath.length} hops — ${renderPath(aggregatePath, pkt.observer_id)}` : '— (direct)'}</div>
|
||||
<div style="font-size:0.8em;color:var(--muted);margin-top:2px">Longest path seen across all ${uniqueObservers} observer${uniqueObservers !== 1 ? 's' : ''}</div>
|
||||
</div>`;
|
||||
})() : ''}
|
||||
`;
|
||||
|
||||
// Wire up observation row click handlers — re-render detail with clicked observation
|
||||
panel.querySelectorAll('.detail-obs-row').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
const obsId = row.dataset.obsId;
|
||||
selectedObservationId = obsId;
|
||||
// Update URL hash to reflect selected observation (deep linking)
|
||||
const pktHash = pkt.hash || pkt.id;
|
||||
const obsParam = obsId ? `?obs=${obsId}` : '';
|
||||
history.replaceState(null, '', `#/packets/${pktHash}${obsParam}`);
|
||||
renderDetail(panel, data, obsId);
|
||||
});
|
||||
});
|
||||
|
||||
// Wire up copy link button
|
||||
const copyLinkBtn = panel.querySelector('.copy-link-btn');
|
||||
if (copyLinkBtn) {
|
||||
|
||||
@@ -345,6 +345,9 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
}
|
||||
.detail-meta dt { color: var(--text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
|
||||
.detail-meta dd { font-weight: 500; margin-bottom: 4px; }
|
||||
.observation-current { background: var(--accent-bg, rgba(0,122,255,0.1)); font-weight: 600; }
|
||||
.detail-obs-row:hover { background: var(--hover-bg, rgba(255,255,255,0.05)); }
|
||||
.detail-obs-table th { font-size: 0.8em; text-transform: uppercase; color: var(--text-muted); }
|
||||
|
||||
/* === Hex Dump === */
|
||||
.hex-dump {
|
||||
|
||||
@@ -5887,6 +5887,87 @@ console.log('\n=== analytics.js: renderCollisionsFromServer collision table ==='
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Issue #849: Per-observation packet detail tests =====
|
||||
{
|
||||
console.log('\n=== Issue #849: Per-observation packet detail ===');
|
||||
|
||||
// Test helper: extract hop count from raw_hex path_len byte
|
||||
function extractRawHopCount(rawHex, routeType) {
|
||||
if (!rawHex || rawHex.length < 4) return null;
|
||||
let plOff = 1;
|
||||
if (routeType === 0 || routeType === 3) plOff = 5;
|
||||
const plByte = parseInt(rawHex.slice(plOff * 2, plOff * 2 + 2), 16);
|
||||
if (isNaN(plByte)) return null;
|
||||
return plByte & 0x3F;
|
||||
}
|
||||
|
||||
test('#849: hop count from raw_hex path_len byte (2 hops)', () => {
|
||||
// path_len byte = 0x82: hash_size=2+1=3, hash_count=2
|
||||
const rawHex = '0482aabbccddee'; // header + path_len(0x82) + path data
|
||||
assert.strictEqual(extractRawHopCount(rawHex, 1), 2);
|
||||
});
|
||||
|
||||
test('#849: hop count from raw_hex path_len byte (0 hops = direct)', () => {
|
||||
const rawHex = '0400'; // header + path_len=0x00
|
||||
assert.strictEqual(extractRawHopCount(rawHex, 1), 0);
|
||||
});
|
||||
|
||||
test('#849: hop count from raw_hex for transport route (offset 5)', () => {
|
||||
// Transport routes have 4 bytes of transport codes before path_len
|
||||
const rawHex = '00112233440541B127D7'; // header + 4 transport bytes + path_len(0x05)=5 hops
|
||||
assert.strictEqual(extractRawHopCount(rawHex, 0), 5);
|
||||
});
|
||||
|
||||
test('#849: hop count warns on inconsistency (path_json vs raw_hex)', () => {
|
||||
// path_json has 3 hops, but raw_hex says 2
|
||||
const pathJson = ['41B1', '27D7', '5EB0'];
|
||||
const rawHopCount = 2;
|
||||
assert.notStrictEqual(pathJson.length, rawHopCount, 'should detect inconsistency');
|
||||
// In production code, rawHopCount is trusted
|
||||
assert.strictEqual(rawHopCount, 2);
|
||||
});
|
||||
|
||||
test('#849: per-observation fields override aggregated packet fields', () => {
|
||||
const pkt = { id: 1, hash: 'abc', observer_id: 'obs-agg', snr: 10, rssi: -90, path_json: '["A","B","C"]', timestamp: '2026-01-01T00:00:00Z' };
|
||||
const obs = { id: 2, observer_id: 'obs-1', snr: 5, rssi: -85, path_json: '["A"]', timestamp: '2026-01-01T00:01:00Z' };
|
||||
// Simulate what renderDetail does: spread obs over pkt
|
||||
const effective = {...pkt, ...obs, _isObservation: true};
|
||||
delete effective._parsedPath; // clear cache
|
||||
assert.strictEqual(effective.observer_id, 'obs-1');
|
||||
assert.strictEqual(effective.snr, 5);
|
||||
assert.strictEqual(effective.rssi, -85);
|
||||
assert.strictEqual(effective.timestamp, '2026-01-01T00:01:00Z');
|
||||
});
|
||||
|
||||
test('#849: first observation used when no specific observation selected', () => {
|
||||
const observations = [
|
||||
{ id: 10, observer_id: 'obs-A', path_json: '["X"]' },
|
||||
{ id: 20, observer_id: 'obs-B', path_json: '["X","Y","Z"]' }
|
||||
];
|
||||
// No targetObsId → use observations[0]
|
||||
const currentObs = observations[0];
|
||||
assert.strictEqual(currentObs.id, 10);
|
||||
assert.strictEqual(currentObs.observer_id, 'obs-A');
|
||||
});
|
||||
|
||||
test('#849: clicking observation row selects that observation', () => {
|
||||
const observations = [
|
||||
{ id: 10, observer_id: 'obs-A', path_json: '["X"]' },
|
||||
{ id: 20, observer_id: 'obs-B', path_json: '["X","Y","Z"]' }
|
||||
];
|
||||
const targetObsId = '20';
|
||||
const currentObs = observations.find(o => String(o.id) === String(targetObsId));
|
||||
assert.ok(currentObs);
|
||||
assert.strictEqual(currentObs.observer_id, 'obs-B');
|
||||
});
|
||||
|
||||
test('#849: null/missing raw_hex returns null hop count', () => {
|
||||
assert.strictEqual(extractRawHopCount(null, 1), null);
|
||||
assert.strictEqual(extractRawHopCount('', 1), null);
|
||||
assert.strictEqual(extractRawHopCount('04', 1), null); // too short
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
Promise.allSettled(pendingTests).then(() => {
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
|
||||
Reference in New Issue
Block a user