From ab22b98f48142162285a767a42caede1d7bc533b Mon Sep 17 00:00:00 2001 From: you Date: Sat, 21 Mar 2026 00:18:11 +0000 Subject: [PATCH] fix: switch all user-facing URLs to hash-based for stability across restarts After dedup migration, packet IDs from the legacy 'packets' table differ from transmission IDs in the 'transmissions' table. URLs using numeric IDs became invalid after restart when _loadNormalized() assigned different IDs. Changes: - All packet URLs now use 16-char hex hashes instead of numeric IDs (#/packets/HASH instead of #/packet/ID) - selectPacket() accepts hash parameter, uses hash-based URLs - Copy Link generates hash-based URLs - Search results link to hash-based URLs - /api/packets/:id endpoint accepts both numeric IDs and 16-char hashes - insert() now calls insertTransmission() to get stable transmission IDs - Added db.getTransmission() for direct transmission table lookup - Removed redundant byTransmission map (identical to byHash) - All byTransmission references replaced with byHash --- db.js | 8 ++++++- packet-store.js | 27 ++++++++++++------------ public/app.js | 2 +- public/index.html | 4 ++-- public/packets.js | 53 ++++++++++++++++++++++++++++++++++------------- server.js | 23 ++++++++++++++------ 6 files changed, 80 insertions(+), 37 deletions(-) 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;