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
This commit is contained in:
you
2026-03-21 00:18:11 +00:00
parent 607eef2d06
commit ab22b98f48
6 changed files with 80 additions and 37 deletions
+7 -1
View File
@@ -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 };
+14 -13
View File
@@ -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<hash>
@@ -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,
}
};
}
+1 -1
View File
@@ -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 += `<div class="search-result-item" onclick="location.hash='#/packets?id=${p.id}';document.getElementById('searchOverlay').classList.add('hidden')">
html += `<div class="search-result-item" onclick="location.hash='#/packets/${p.packet_hash || p.hash || p.id}';document.getElementById('searchOverlay').classList.add('hidden')">
<span class="search-result-type">Packet</span>${truncate(p.packet_hash || '', 16)}${payloadTypeName(p.payload_type)}</div>`;
}
}
+2 -2
View File
@@ -80,9 +80,9 @@
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774028201"></script>
<script src="app.js?v=1774034748"></script>
<script src="app.js?v=1774052279"></script>
<script src="home.js?v=1774042199"></script>
<script src="packets.js?v=1774051770"></script>
<script src="packets.js?v=1774052279"></script>
<script src="map.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774050030" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774050030" onerror="console.error('Failed to load:', this.src)"></script>
+39 -14
View File
@@ -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 += `<tr class="group-child" data-id="${c.id}" data-action="select" data-value="${c.id}" tabindex="0" role="row">
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select" data-value="${c.id}" tabindex="0" role="row">
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : ''}</td>
<td class="col-time">${timeAgo(c.timestamp)}</td>
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
@@ -674,7 +693,7 @@
const pathStr = renderPath(pathHops);
const detail = getDetailPreview(decoded);
return `<tr data-id="${p.id}" data-action="select" data-value="${p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select" data-value="${p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : ''}</td>
<td class="col-time">${timeAgo(p.timestamp)}</td>
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
@@ -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 @@
<dt>Path</dt><dd>${pathHops.length ? renderPath(pathHops) : ''}</dd>
</dl>
<div class="detail-actions">
<button class="copy-link-btn" data-packet-id="${pkt.id}" title="Copy link to this packet">🔗 Copy Link</button>
<button class="copy-link-btn" data-packet-hash="${pkt.hash || ''}" data-packet-id="${pkt.id}" title="Copy link to this packet">🔗 Copy Link</button>
${pathHops.length ? `<button class="detail-map-link" id="viewRouteBtn">🗺️ View route on map</button>` : ''}
<button class="replay-live-btn" title="Replay this packet on the live map"> Replay</button>
</div>
@@ -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 = `<div style="max-width:800px;margin:0 auto;padding:20px"><div class="text-center text-muted" style="padding:40px">Loading packet #${id}…</div></div>`;
const param = routeParam;
app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:20px"><div class="text-center text-muted" style="padding:40px">Loading packet…</div></div>`;
try {
const data = await api(`/packets/${id}`);
if (!data?.packet) { app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:40px;text-align:center"><h2>Packet not found</h2><p>Packet #${id} doesn't exist.</p><a href="#/packets">← Back to packets</a></div>`; return; }
const data = await api(`/packets/${param}`);
if (!data?.packet) { app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:40px;text-align:center"><h2>Packet not found</h2><p>Packet ${param} doesn't exist.</p><a href="#/packets">← Back to packets</a></div>`; return; }
const hops = [];
try { const ph = JSON.parse(data.packet.path_json || '[]'); hops.push(...ph); } catch {}
const newHops = hops.filter(h => !(h in hopNameCache));
+17 -6
View File
@@ -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;