diff --git a/public/index.html b/public/index.html
index b2db634b..dffaa2e8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -22,7 +22,7 @@
-
+
-
+
diff --git a/public/packets.js b/public/packets.js
index 32ac60f4..a061821b 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -129,11 +129,29 @@
const name = entry ? (typeof entry === 'string' ? entry : entry.name) : null;
const pubkey = entry?.pubkey || h;
const ambiguous = entry?.ambiguous || false;
+ const conflicts = entry?.conflicts || [];
+ const regionalCount = conflicts.filter(c => c.regional).length;
+ const totalGlobal = entry?.totalGlobal || conflicts.length;
+ const globalFallback = entry?.globalFallback || false;
+ const unreliable = entry?.unreliable || false;
const display = name ? escapeHtml(name) : h;
- const title = ambiguous
- ? `${h} — ⚠ ${entry.candidates.length} matches: ${entry.candidates.map(c => c.name).join(', ')}`
- : h;
- return `${display}${ambiguous ? '⚠' : ''}`;
+ const hopBytes = Math.ceil(h.length / 2);
+
+ let title = h;
+ if (ambiguous && conflicts.length > 0) {
+ const regionLabel = regionalCount > 0 ? `${regionalCount} regional` : `${totalGlobal} global (no regional match)`;
+ title = `${h} (${hopBytes}B) — ⚠ ${regionLabel}: ${conflicts.map(c => c.name + (c.regional ? '' : ' ⚑')).join(', ')}`;
+ }
+ if (unreliable) title += ' — ✗ unreliable (too far from neighbors)';
+ if (globalFallback) title += ' — ⚑ no regional candidates, using global fallback';
+
+ const warnBadge = ambiguous
+ ? `${conflicts.length > 1 ? '⚠' + conflicts.length : '⚠'}`
+ : '';
+ const unreliableCls = unreliable ? ' hop-unreliable' : '';
+ const fallbackCls = globalFallback ? ' hop-global-fallback' : '';
+
+ return `${display}${warnBadge}`;
}
function renderPath(hops) {
@@ -1305,8 +1323,12 @@
const hopEntry = hopNameCache[pathHops[i]];
const hopName = hopEntry ? (typeof hopEntry === 'string' ? hopEntry : hopEntry.name) : null;
const hopPubkey = hopEntry?.pubkey || pathHops[i];
+ const conflicts = hopEntry?.conflicts || [];
+ const conflictInfo = conflicts.length > 1
+ ? ` ⚠${conflicts.length}`
+ : '';
const nameHtml = hopName
- ? `${escapeHtml(hopName)}${hopEntry?.ambiguous ? ' ⚠' : ''}`
+ ? `${escapeHtml(hopName)}${conflictInfo}`
: '';
const label = hopName ? `Hop ${i} — ${nameHtml}` : `Hop ${i}`;
rows += fieldRow(off + i * hashSize, label, pathHops[i], '');
diff --git a/public/style.css b/public/style.css
index 051a7518..c6b0cd78 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1224,7 +1224,9 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
/* Ambiguous hop indicator */
.hop-ambiguous { border-bottom: 1px dashed #f59e0b; }
-.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; }
+.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; color: #f59e0b; }
+.hop-unreliable { opacity: 0.5; text-decoration: line-through; }
+.hop-global-fallback { border-bottom: 1px dashed #ef4444; }
/* Self-loop subpath rows */
.subpath-selfloop { opacity: 0.6; }
diff --git a/server.js b/server.js
index e4f6577c..3d8d8fc5 100644
--- a/server.js
+++ b/server.js
@@ -2003,25 +2003,47 @@ app.get('/api/analytics/hash-sizes', (req, res) => {
app.get('/api/resolve-hops', (req, res) => {
const hops = (req.query.hops || '').split(',').filter(Boolean);
const observerId = req.query.observer || null;
- // Origin anchor: sender's lat/lon for forward-pass disambiguation.
- // Without this, the first ambiguous hop falls through to the backward pass
- // which anchors from the observer — wrong when sender and observer are far apart.
const originLat = req.query.originLat ? parseFloat(req.query.originLat) : null;
const originLon = req.query.originLon ? parseFloat(req.query.originLon) : null;
if (!hops.length) return res.json({ resolved: {} });
const allNodes = getCachedNodes(false);
+ const allObservers = db.getObservers();
+
+ // Build observer IATA lookup and regional observer sets
+ const observerIataMap = {}; // observer_id → iata
+ const observersByIata = {}; // iata → Set
+ for (const obs of allObservers) {
+ if (obs.iata) {
+ observerIataMap[obs.id] = obs.iata;
+ if (!observersByIata[obs.iata]) observersByIata[obs.iata] = new Set();
+ observersByIata[obs.iata].add(obs.id);
+ }
+ }
+
+ // Determine this packet's region from its observer
+ const packetIata = observerId ? observerIataMap[observerId] : null;
+ const regionalObserverIds = packetIata ? observersByIata[packetIata] : null;
+
+ // Helper: check if a node has been seen by any observer in the given region
+ const nodeSeenInRegion = (pubkey) => {
+ if (!regionalObserverIds) return false;
+ const nodeObservers = pktStore._advertByObserver.get(pubkey);
+ if (!nodeObservers) return false;
+ for (const obsId of nodeObservers) {
+ if (regionalObserverIds.has(obsId)) return true;
+ }
+ return false;
+ };
// Build observer geographic position
let observerLat = null, observerLon = null;
if (observerId) {
- // Try exact name match first
const obsNode = allNodes.find(n => n.name === observerId);
if (obsNode && obsNode.lat && obsNode.lon && !(obsNode.lat === 0 && obsNode.lon === 0)) {
observerLat = obsNode.lat;
observerLon = obsNode.lon;
} else {
- // Fall back to averaging nearby nodes from adverts this observer received
const obsNodes = db.db.prepare(`
SELECT n.lat, n.lon FROM packets_v p
JOIN nodes n ON n.public_key = json_extract(p.decoded_json, '$.pubKey')
@@ -2040,25 +2062,44 @@ app.get('/api/resolve-hops', (req, res) => {
}
const resolved = {};
- // First pass: find all candidates for each hop
+ // First pass: find all candidates for each hop, split into regional and global
for (const hop of hops) {
const hopLower = hop.toLowerCase();
- const candidates = allNodes.filter(n => n.public_key.toLowerCase().startsWith(hopLower));
- if (candidates.length === 0) {
- resolved[hop] = { name: null, candidates: [] };
- } else if (candidates.length === 1) {
- resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, candidates: [{ name: candidates[0].name, pubkey: candidates[0].public_key }] };
+ const hopByteLen = Math.ceil(hop.length / 2); // 2 hex chars = 1 byte
+ const allCandidates = allNodes.filter(n => n.public_key.toLowerCase().startsWith(hopLower));
+
+ if (allCandidates.length === 0) {
+ resolved[hop] = { name: null, candidates: [], conflicts: [] };
+ } else if (allCandidates.length === 1) {
+ const c = allCandidates[0];
+ resolved[hop] = { name: c.name, pubkey: c.public_key, candidates: [{ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon }], conflicts: [] };
} else {
- resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, ambiguous: true, candidates: candidates.map(c => ({ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon })) };
+ // Multiple candidates — apply regional filtering
+ const regional = allCandidates.filter(c => nodeSeenInRegion(c.public_key));
+ const candidates = regional.length > 0 ? regional : allCandidates;
+ const globalFallback = regional.length === 0 && allCandidates.length > 0;
+
+ const conflicts = candidates.map(c => ({
+ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon,
+ regional: nodeSeenInRegion(c.public_key)
+ }));
+
+ if (candidates.length === 1) {
+ resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key,
+ candidates: conflicts, conflicts, globalFallback };
+ } else {
+ resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key,
+ ambiguous: true, candidates: conflicts, conflicts, globalFallback,
+ hopBytes: hopByteLen, totalGlobal: allCandidates.length, totalRegional: regional.length };
+ }
}
}
- // Sequential disambiguation: each hop must be near the previous one
- // Walk the path forward, resolving ambiguous hops by distance to last known position
- // Start from first unambiguous hop (or observer position as anchor for last hop)
-
- // Build initial resolved positions map
- const hopPositions = {}; // hop -> {lat, lon}
+ const dist = (lat1, lon1, lat2, lon2) => Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
+
+ // Forward pass: resolve each ambiguous hop using previous hop's position
+ const hopPositions = {};
+ // Seed unambiguous positions
for (const hop of hops) {
const r = resolved[hop];
if (r && !r.ambiguous && r.pubkey) {
@@ -2069,9 +2110,6 @@ app.get('/api/resolve-hops', (req, res) => {
}
}
- const dist = (lat1, lon1, lat2, lon2) => Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
-
- // Forward pass: resolve each ambiguous hop using previous hop's position
let lastPos = (originLat != null && originLon != null) ? { lat: originLat, lon: originLon } : null;
for (let hi = 0; hi < hops.length; hi++) {
const hop = hops[hi];
@@ -2084,7 +2122,6 @@ app.get('/api/resolve-hops', (req, res) => {
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
if (!withLoc.length) continue;
- // Use previous hop position, or observer position for last hop, or skip
let anchor = lastPos;
if (!anchor && hi === hops.length - 1 && observerLat != null) {
anchor = { lat: observerLat, lon: observerLon };
@@ -2117,7 +2154,7 @@ app.get('/api/resolve-hops', (req, res) => {
nextPos = hopPositions[hop];
}
- // Sanity check: drop hops impossibly far from both neighbors (>200km ≈ 1.8°)
+ // Sanity check: drop hops impossibly far from both neighbors
const MAX_HOP_DIST = MAX_HOP_DIST_SERVER;
for (let i = 0; i < hops.length; i++) {
const pos = hopPositions[hops[i]];
@@ -2130,14 +2167,13 @@ app.get('/api/resolve-hops', (req, res) => {
const tooFarPrev = prev && dPrev > MAX_HOP_DIST;
const tooFarNext = next && dNext > MAX_HOP_DIST;
if ((tooFarPrev && tooFarNext) || (tooFarPrev && !next) || (tooFarNext && !prev)) {
- // Mark as unreliable — likely prefix collision with distant node
const r = resolved[hops[i]];
if (r) { r.unreliable = true; }
delete hopPositions[hops[i]];
}
}
- res.json({ resolved });
+ res.json({ resolved, region: packetIata || null });
});
// channelHashNames removed — we only use decoded channel names now