diff --git a/db.js b/db.js index 3691a688..c304804a 100644 --- a/db.js +++ b/db.js @@ -327,6 +327,12 @@ function getPackets({ limit = 50, offset = 0, type, route, hash, since } = {}) { return { rows, total }; } +function getTransmission(id) { + try { + return db.prepare('SELECT * FROM transmissions WHERE id = ?').get(id) || null; + } catch { return null; } +} + function getPacket(id) { const packet = stmts.getPacket.get(id); if (!packet) return null; @@ -652,4 +658,4 @@ function getNodeAnalytics(pubkey, days) { }; } -module.exports = { db, insertPacket, insertTransmission, insertPath, upsertNode, upsertObserver, updateObserverStatus, getPackets, getPacket, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth, getNodeAnalytics }; +module.exports = { db, insertPacket, insertTransmission, insertPath, upsertNode, upsertObserver, updateObserverStatus, getPackets, getPacket, getTransmission, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth, getNodeAnalytics }; diff --git a/packet-store.js b/packet-store.js index c909ee9a..bb8e0462 100644 --- a/packet-store.js +++ b/packet-store.js @@ -27,7 +27,6 @@ class PacketStore { this.byHash = new Map(); // hash → transmission object (1:1) this.byObserver = new Map(); // observer_id → [observation objects] this.byNode = new Map(); // pubkey → [transmission objects] (deduped) - this.byTransmission = new Map(); // hash → transmission object (same refs as byHash) // Track which hashes are indexed per node pubkey (avoid dupes in byNode) this._nodeHashIndex = new Map(); // pubkey → Set @@ -77,9 +76,9 @@ class PacketStore { `).all(); for (const row of rows) { - if (this.packets.length >= this.maxPackets && !this.byTransmission.has(row.hash)) break; + if (this.packets.length >= this.maxPackets && !this.byHash.has(row.hash)) break; - let tx = this.byTransmission.get(row.hash); + let tx = this.byHash.get(row.hash); if (!tx) { tx = { id: row.transmission_id, @@ -100,7 +99,7 @@ class PacketStore { path_json: null, direction: null, }; - this.byTransmission.set(row.hash, tx); + this.byHash.set(row.hash, tx); this.byHash.set(row.hash, tx); this.packets.push(tx); this.byTxId.set(tx.id, tx); @@ -167,7 +166,7 @@ class PacketStore { /** Index a legacy packet row (old flat structure) — builds transmission + observation */ _indexLegacy(pkt) { - let tx = this.byTransmission.get(pkt.hash); + let tx = this.byHash.get(pkt.hash); if (!tx) { tx = { id: pkt.id, @@ -187,7 +186,7 @@ class PacketStore { path_json: pkt.path_json, direction: pkt.direction, }; - this.byTransmission.set(pkt.hash, tx); + this.byHash.set(pkt.hash, tx); this.byHash.set(pkt.hash, tx); this.packets.push(tx); this.byTxId.set(tx.id, tx); @@ -252,7 +251,7 @@ class PacketStore { while (this.packets.length > this.maxPackets) { const old = this.packets.pop(); this.byHash.delete(old.hash); - this.byTransmission.delete(old.hash); + this.byHash.delete(old.hash); this.byTxId.delete(old.id); // Remove observations from byId and byObserver for (const obs of old.observations) { @@ -270,13 +269,16 @@ class PacketStore { /** Insert a new packet (to both memory and SQLite) */ insert(packetData) { const id = this.dbModule.insertPacket(packetData); + // Also write to normalized tables and get the transmission ID + const txResult = this.dbModule.insertTransmission ? this.dbModule.insertTransmission(packetData) : null; + const transmissionId = txResult ? txResult.transmissionId : null; const row = this.dbModule.getPacket(id); if (row && !this.sqliteOnly) { // Update or create transmission in memory - let tx = this.byTransmission.get(row.hash); + let tx = this.byHash.get(row.hash); if (!tx) { tx = { - id: row.id, + id: transmissionId || row.id, raw_hex: row.raw_hex, hash: row.hash, first_seen: row.timestamp, @@ -293,7 +295,7 @@ class PacketStore { path_json: row.path_json, direction: row.direction, }; - this.byTransmission.set(row.hash, tx); + this.byHash.set(row.hash, tx); this.byHash.set(row.hash, tx); this.packets.unshift(tx); // newest first this.byTxId.set(tx.id, tx); @@ -465,7 +467,7 @@ class PacketStore { for (const o of obs) { if (!seen.has(o.hash)) { seen.add(o.hash); - const tx = this.byTransmission.get(o.hash); + const tx = this.byHash.get(o.hash); if (tx) result.push(tx); } } @@ -531,7 +533,7 @@ class PacketStore { /** Get all siblings of a packet (same hash) — returns observations array */ getSiblings(hash) { if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets WHERE hash = ? ORDER BY timestamp DESC').all(hash); - const tx = this.byTransmission.get(hash); + const tx = this.byHash.get(hash); return tx ? tx.observations : []; } @@ -560,7 +562,6 @@ class PacketStore { byHash: this.byHash.size, byObserver: this.byObserver.size, byNode: this.byNode.size, - byTransmission: this.byTransmission.size, } }; } diff --git a/public/app.js b/public/app.js index 5bac0294..30e41cfd 100644 --- a/public/app.js +++ b/public/app.js @@ -455,7 +455,7 @@ window.addEventListener('DOMContentLoaded', () => { const pktList = packets.packets || packets; if (Array.isArray(pktList)) { for (const p of pktList.slice(0, 5)) { - html += `
+ html += `
Packet${truncate(p.packet_hash || '', 16)} — ${payloadTypeName(p.payload_type)}
`; } } diff --git a/public/index.html b/public/index.html index edd1573e..4a6565b8 100644 --- a/public/index.html +++ b/public/index.html @@ -80,9 +80,9 @@ - + - + diff --git a/public/packets.js b/public/packets.js index cbc634ac..376696f8 100644 --- a/public/packets.js +++ b/public/packets.js @@ -124,6 +124,7 @@ } let directPacketId = null; + let directPacketHash = null; let initGeneration = 0; async function init(app, routeParam) { @@ -134,6 +135,7 @@ directPacketId = routeParam.slice(3); } else if (routeParam.length <= 16) { filters.hash = routeParam; + directPacketHash = routeParam; } else { filters.node = routeParam; } @@ -149,6 +151,18 @@ await loadObservers(); loadPackets(); + // Auto-select packet detail when arriving via hash URL + if (directPacketHash) { + const h = directPacketHash; + directPacketHash = null; + try { + const data = await api(`/packets/${h}`); + if (gen === initGeneration && data?.packet) { + selectPacket(data.packet.id, h); + } + } catch {} + } + // Event delegation for data-action buttons app.addEventListener('click', function (e) { var btn = e.target.closest('[data-action]'); @@ -279,6 +293,7 @@ totalCount = 0; observers = []; directPacketId = null; + directPacketHash = null; groupByHash = true; filters = {}; regionMap = {}; @@ -553,7 +568,11 @@ if (e.type === 'keydown') e.preventDefault(); const action = row.dataset.action; const value = row.dataset.value; - if (action === 'select') selectPacket(Number(value)); + if (action === 'select') { + const hash = row.dataset.hash; + if (hash) selectPacket(null, hash); + else selectPacket(Number(value)); + } else if (action === 'select-hash') pktSelectHash(value); else if (action === 'toggle-select') { pktToggleGroup(value); pktSelectHash(value); } }; @@ -644,7 +663,7 @@ let childPath = []; try { childPath = JSON.parse(c.path_json || '[]'); } catch {} const childPathStr = renderPath(childPath); - html += ` + html += ` ${childRegion ? `${childRegion}` : '—'} ${timeAgo(c.timestamp)} ${truncate(c.hash || '', 8)} @@ -674,7 +693,7 @@ const pathStr = renderPath(pathHops); const detail = getDetailPreview(decoded); - return ` + return ` ${region ? `${region}` : '—'} ${timeAgo(p.timestamp)} ${truncate(p.hash || String(p.id), 8)} @@ -715,9 +734,13 @@ return ''; } - async function selectPacket(id) { + async function selectPacket(id, hash) { selectedId = id; - history.replaceState(null, '', `#/packet/${id}`); + if (hash) { + history.replaceState(null, '', `#/packets/${hash}`); + } else { + history.replaceState(null, '', `#/packets/${id}`); + } renderTableRows(); const isMobileNow = window.innerWidth <= 640; let panel; @@ -748,7 +771,8 @@ } try { - const data = await api(`/packets/${id}`); + const endpoint = hash ? `/packets/${hash}` : `/packets/${id}`; + const data = await api(endpoint); // Resolve path hops for detail view const pkt = data.packet; try { @@ -813,7 +837,7 @@
Path
${pathHops.length ? renderPath(pathHops) : '—'}
- + ${pathHops.length ? `` : ''}
@@ -828,7 +852,8 @@ const copyLinkBtn = panel.querySelector('.copy-link-btn'); if (copyLinkBtn) { copyLinkBtn.addEventListener('click', () => { - const url = `${location.origin}/#/packet/${copyLinkBtn.dataset.packetId}`; + const pktHash = copyLinkBtn.dataset.packetHash; + const url = pktHash ? `${location.origin}/#/packets/${pktHash}` : `${location.origin}/#/packets/${copyLinkBtn.dataset.packetId}`; navigator.clipboard.writeText(url).then(() => { copyLinkBtn.textContent = '✅ Copied!'; setTimeout(() => { copyLinkBtn.textContent = '🔗 Copy Link'; }, 1500); @@ -1135,20 +1160,20 @@ // When grouped, find first packet with this hash try { const data = await api(`/packets?hash=${hash}&limit=1`); - if (data.packets?.[0]) selectPacket(data.packets[0].id); + if (data.packets?.[0]) selectPacket(data.packets[0].id, hash); } catch {} } registerPage('packets', { init, destroy }); - // Standalone packet detail page: #/packet/123 + // Standalone packet detail page: #/packet/123 or #/packet/HASH registerPage('packet-detail', { init: async (app, routeParam) => { - const id = Number(routeParam); - app.innerHTML = `
Loading packet #${id}…
`; + const param = routeParam; + app.innerHTML = `
Loading packet…
`; try { - const data = await api(`/packets/${id}`); - if (!data?.packet) { app.innerHTML = `

Packet not found

Packet #${id} doesn't exist.

← Back to packets
`; return; } + const data = await api(`/packets/${param}`); + if (!data?.packet) { app.innerHTML = `

Packet not found

Packet ${param} doesn't exist.

← Back to packets
`; return; } const hops = []; try { const ph = JSON.parse(data.packet.path_json || '[]'); hops.push(...ph); } catch {} const newHops = hops.filter(h => !(h in hopNameCache)); diff --git a/server.js b/server.js index 0160907f..903a5bf3 100644 --- a/server.js +++ b/server.js @@ -562,7 +562,7 @@ for (const source of mqttSources) { cache.debouncedInvalidateAll(); const fullPacket = pktStore.getById(packetId); - const tx = pktStore.byTransmission.get(pktData.hash); + const tx = pktStore.byHash.get(pktData.hash); const observation_count = tx ? tx.observation_count : 1; const broadcastData = { id: packetId, raw: msg.raw, decoded, snr: msg.SNR, rssi: msg.RSSI, hash: msg.hash, observer: observerId, packet: fullPacket, observation_count }; broadcast({ type: 'packet', data: broadcastData }); @@ -778,10 +778,21 @@ app.get('/api/packets/timestamps', (req, res) => { }); app.get('/api/packets/:id', (req, res) => { - const id = Number(req.params.id); - // Try observation ID first, then transmission ID, then legacy packets table - // Try transmission ID first (what the UI sends), then observation ID, then legacy - const packet = pktStore.getByTxId(id) || pktStore.getById(id) || db.getPacket(id); + const param = req.params.id; + const isHash = /^[0-9a-f]{16}$/i.test(param); + let packet; + if (isHash) { + // Hash-based lookup + const tx = pktStore.byHash.get(param); + packet = tx || null; + } + if (!packet) { + const id = Number(param); + if (!isNaN(id)) { + // Try transmission ID first (what the UI sends), then observation ID, then legacy + packet = pktStore.getByTxId(id) || pktStore.getById(id) || db.getPacket(id); + } + } if (!packet) return res.status(404).json({ error: 'Not found' }); // Use the sibling with the longest path (most hops) for display @@ -802,7 +813,7 @@ app.get('/api/packets/:id', (req, res) => { const breakdown = buildBreakdown(packet.raw_hex, decoded); // Include sibling observations for this transmission - const transmission = packet.hash ? pktStore.byTransmission.get(packet.hash) : null; + const transmission = packet.hash ? pktStore.byHash.get(packet.hash) : null; const siblingObservations = transmission ? transmission.observations : []; const observation_count = transmission ? transmission.observation_count : 1;