diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 229aa62b7..f6f3d2e07 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -441,6 +441,7 @@ jobs:
BASE_URL=http://localhost:13581 node test-slideover-1056-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1692-packets-init-parallel-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-147-packets-url-obs-e2e.js 2>&1 | tee -a e2e-output.txt
+ BASE_URL=http://localhost:13581 node test-issue-165-grouped-hop-warn-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-packet-detail-sender-hash-size-obs-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-96-hide-control-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-180-packets-url-modal-e2e.js 2>&1 | tee -a e2e-output.txt
diff --git a/cmd/server/db.go b/cmd/server/db.go
index 5c8a88737..a5aa80027 100644
--- a/cmd/server/db.go
+++ b/cmd/server/db.go
@@ -792,6 +792,12 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
if db.hasScopeName() {
groupedScopeCol = ", t.scope_name"
}
+ // #165 — the displayed observation's resolved_path. Always one column
+ // (NULL without the column), so the Scan below does not depend on it.
+ groupedRPCol := "NULL"
+ if db.hasResolvedPath() {
+ groupedRPCol = "o.resolved_path"
+ }
var querySQL string
if db.isV3() {
querySQL = fmt.Sprintf(`SELECT t.hash, t.first_seen, t.raw_hex, t.decoded_json, t.payload_type, t.route_type,
@@ -799,7 +805,7 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
COALESCE((SELECT COUNT(DISTINCT oi.observer_idx) FROM observations oi WHERE oi.transmission_id = t.id), 0) AS observer_count,
COALESCE((SELECT MAX(strftime('%%Y-%%m-%%dT%%H:%%M:%%fZ', oi.timestamp, 'unixepoch')) FROM observations oi WHERE oi.transmission_id = t.id), t.first_seen) AS latest,
obs.id AS observer_id, obs.name AS observer_name, COALESCE(obs.iata, '') AS observer_iata,
- o.snr, o.rssi, o.path_json,
+ o.snr, o.rssi, o.path_json, `+groupedRPCol+`,
COALESCE((SELECT GROUP_CONCAT(DISTINCT obi.iata) FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.iata IS NOT NULL AND obi.iata != ''), '') AS distinct_iatas`+groupedScopeCol+`
FROM transmissions t
LEFT JOIN observations o ON o.id = (
@@ -814,7 +820,7 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
COALESCE((SELECT COUNT(DISTINCT oi.observer_id) FROM observations oi WHERE oi.transmission_id = t.id), 0) AS observer_count,
COALESCE((SELECT MAX(oi.timestamp) FROM observations oi WHERE oi.transmission_id = t.id), t.first_seen) AS latest,
o.observer_id, o.observer_name, COALESCE(obs2.iata, '') AS observer_iata,
- o.snr, o.rssi, o.path_json,
+ o.snr, o.rssi, o.path_json, `+groupedRPCol+`,
COALESCE((SELECT GROUP_CONCAT(DISTINCT obi.iata) FROM observations oi JOIN observers obi ON obi.id = oi.observer_id WHERE oi.transmission_id = t.id AND obi.iata IS NOT NULL AND obi.iata != ''), '') AS distinct_iatas`+groupedScopeCol+`
FROM transmissions t
LEFT JOIN observations o ON o.id = (
@@ -837,7 +843,7 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
packets := make([]map[string]interface{}, 0)
for rows.Next() {
- var hash, firstSeen, rawHex, decodedJSON, latest, observerID, observerName, observerIATA, pathJSON, distinctIatasCSV sql.NullString
+ var hash, firstSeen, rawHex, decodedJSON, latest, observerID, observerName, observerIATA, pathJSON, resolvedPath, distinctIatasCSV sql.NullString
var payloadType, routeType sql.NullInt64
var count, observerCount int
var snr, rssi sql.NullFloat64
@@ -845,7 +851,7 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
scanArgs := []interface{}{&hash, &firstSeen, &rawHex, &decodedJSON, &payloadType, &routeType,
&count, &observerCount, &latest,
- &observerID, &observerName, &observerIATA, &snr, &rssi, &pathJSON, &distinctIatasCSV}
+ &observerID, &observerName, &observerIATA, &snr, &rssi, &pathJSON, &resolvedPath, &distinctIatasCSV}
if db.hasScopeName() {
scanArgs = append(scanArgs, &scopeName)
}
@@ -853,7 +859,7 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
continue
}
- packets = append(packets, map[string]interface{}{
+ row := map[string]interface{}{
"hash": nullStr(hash),
"first_seen": nullStr(firstSeen),
"count": count,
@@ -872,7 +878,15 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
"snr": nullFloat(snr),
"rssi": nullFloat(rssi),
"scope_name": nullStr(scopeName),
- })
+ }
+ // #165 — the displayed observation's resolved_path, as the in-memory
+ // store sends it (groupedPageWithRP).
+ if resolvedPath.Valid {
+ if rp := resolvedPathRaw(resolvedPath.String); rp != nil {
+ row["resolved_path"] = rp
+ }
+ }
+ packets = append(packets, row)
}
return &PacketResult{Packets: packets, Total: total}, nil
diff --git a/cmd/server/grouped_resolved_path_165_bench_test.go b/cmd/server/grouped_resolved_path_165_bench_test.go
new file mode 100644
index 000000000..efafae177
--- /dev/null
+++ b/cmd/server/grouped_resolved_path_165_bench_test.go
@@ -0,0 +1,74 @@
+package main
+
+import (
+ "bytes"
+ "compress/gzip"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "testing"
+ "time"
+)
+
+// BenchmarkGroupedPackets165 measures the default packets-page request
+// (/api/packets?groupByHash=true, desktop limit 50000) on 30K transmissions
+// with two observations each and a 3-hop resolved_path, the shape #165
+// asked to be checked before the grouped rows carry resolved_path. It
+// reports the JSON size of the page as json-bytes, and gzipped (the server
+// compresses responses, compress.go) as gzip-bytes.
+//
+// go test -run '^$' -bench GroupedPackets165 -benchtime 20x .
+func BenchmarkGroupedPackets165(b *testing.B) {
+ const nTx = 30000
+ db := setupTestDB(b)
+ defer db.Close()
+ now := time.Now().UTC()
+ ts := now.Format(time.RFC3339)
+ epoch := now.Add(-time.Minute).Unix()
+ tx, err := db.conn.Begin()
+ if err != nil {
+ b.Fatal(err)
+ }
+ for i := 1; i <= 2; i++ {
+ tx.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) VALUES (?, ?, 'SJC', ?, ?, 1)`,
+ fmt.Sprintf("OBS%02d", i), fmt.Sprintf("O%d", i), ts, ts)
+ }
+ // Pubkeys look random, as real ones do, so gzip-bytes is not flattered.
+ pk := func(i, h int) string {
+ sum := sha256.Sum256([]byte(fmt.Sprint(i, ":", h)))
+ return fmt.Sprintf("%02x", (i+h)%256) + hex.EncodeToString(sum[:])[2:]
+ }
+ for i := 1; i <= nTx; i++ {
+ tx.Exec(`INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, decoded_json) VALUES (?, ?, ?, ?, 1, 4, '{}')`,
+ i, "11223344556677889900aabbccddeeff", fmt.Sprintf("%016x", i), ts)
+ path := fmt.Sprintf(`["%02x","%02x","%02x"]`, (i+0)%256, (i+1)%256, (i+2)%256)
+ rp := fmt.Sprintf(`["%s","%s",null]`, pk(i, 0), pk(i, 1))
+ tx.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path) VALUES (?, 1, 5, -90, ?, ?, ?)`, i, path, epoch-int64(i), rp)
+ tx.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path) VALUES (?, 2, 5, -90, ?, ?, ?)`,
+ i, fmt.Sprintf(`["%02x"]`, (i+2)%256), epoch-int64(i), fmt.Sprintf(`["%s"]`, pk(i, 2)))
+ }
+ if err := tx.Commit(); err != nil {
+ b.Fatal(err)
+ }
+ store := NewPacketStore(db, nil)
+ if err := store.Load(); err != nil {
+ b.Fatal(err)
+ }
+ q := PacketQuery{Limit: 50000}
+ r := store.QueryGroupedPackets(q)
+ if r.Total != nTx {
+ b.Fatalf("total = %d, want %d", r.Total, nTx)
+ }
+ body, _ := json.Marshal(r)
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ store.QueryGroupedPackets(q)
+ }
+ var gz bytes.Buffer
+ zw := gzip.NewWriter(&gz)
+ zw.Write(body)
+ zw.Close()
+ b.ReportMetric(float64(len(body)), "json-bytes")
+ b.ReportMetric(float64(gz.Len()), "gzip-bytes")
+}
diff --git a/cmd/server/grouped_resolved_path_165_test.go b/cmd/server/grouped_resolved_path_165_test.go
new file mode 100644
index 000000000..7df873da5
--- /dev/null
+++ b/cmd/server/grouped_resolved_path_165_test.go
@@ -0,0 +1,102 @@
+package main
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+)
+
+// #165 — the packets page's default (grouped) view resolves hop names from
+// the grouped row. Without the server's resolved_path on that row, the client
+// heuristic guessed every hop and the list flagged hops the server had a
+// definite answer for (23 of 60 rows on the CI fixture). The grouped row must
+// carry the resolved_path of the observation it displays: the same observer
+// and path_json as the row's observer_id/path_json, since the client caches
+// it under hop:observer.
+
+const (
+ rp165A = "aa11111111111111111111111111111111111111111111111111111111111111"
+ rp165B = "bb22222222222222222222222222222222222222222222222222222222222222"
+ rp165X = "aa99999999999999999999999999999999999999999999999999999999999999"
+)
+
+// seedGroupedRP165 inserts two transmissions:
+// - "165a…": observer A hears path [aa,bb] with resolved_path [A, null]
+// (the header: longest path); observer B hears [aa] resolved to X.
+// - "165b…": one observation with no resolved_path at all.
+func seedGroupedRP165(t *testing.T, db *DB) {
+ t.Helper()
+ now := time.Now().UTC()
+ ts := now.Format(time.RFC3339)
+ epoch := now.Add(-time.Minute).Unix()
+ mustExec := func(q string, args ...interface{}) {
+ t.Helper()
+ if _, err := db.conn.Exec(q, args...); err != nil {
+ t.Fatalf("seed: %v\n%s", err, q)
+ }
+ }
+ mustExec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) VALUES ('OBSA', 'A', 'SJC', ?, ?, 1)`, ts, ts)
+ mustExec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) VALUES ('OBSB', 'B', 'SFO', ?, ?, 1)`, ts, ts)
+ mustExec(`INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, decoded_json) VALUES (1, 'AABB', '165a000000000001', ?, 1, 4, '{}')`, ts)
+ mustExec(`INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, decoded_json) VALUES (2, 'CCDD', '165b000000000002', ?, 1, 4, '{}')`, ts)
+ mustExec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path) VALUES (1, 2, 5, -90, '["aa"]', ?, ?)`, epoch, `["`+rp165X+`"]`)
+ mustExec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path) VALUES (1, 1, 6, -88, '["aa","bb"]', ?, ?)`, epoch, `["`+rp165A+`",null]`)
+ mustExec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (2, 1, 7, -80, '["cc"]', ?)`, epoch)
+}
+
+func groupedRowsByHash165(t *testing.T, r *PacketResult) map[string]map[string]interface{} {
+ t.Helper()
+ out := map[string]map[string]interface{}{}
+ for _, p := range r.Packets {
+ h, _ := p["hash"].(string)
+ out[h] = p
+ }
+ if len(out) != 2 {
+ t.Fatalf("expected 2 grouped rows, got %d: %#v", len(out), r.Packets)
+ }
+ return out
+}
+
+func assertGroupedRP165(t *testing.T, rows map[string]map[string]interface{}) {
+ t.Helper()
+ a := rows["165a000000000001"]
+ if got := strings.ToUpper(a["observer_id"].(string)); got != "OBSA" {
+ t.Fatalf("header observer = %q, want OBSA (longest path)", got)
+ }
+ rp, ok := a["resolved_path"]
+ if !ok {
+ t.Fatalf("grouped row carries no resolved_path; the client then guesses hops the server resolved (#165): %#v", a)
+ }
+ b, _ := json.Marshal(rp)
+ if want := `["` + rp165A + `",null]`; string(b) != want {
+ t.Fatalf("grouped resolved_path = %s, want the header observation's %s", b, want)
+ }
+ if _, ok := rows["165b000000000002"]["resolved_path"]; ok {
+ t.Fatalf("a row whose observation has no resolved_path must omit the key, got %#v", rows["165b000000000002"]["resolved_path"])
+ }
+}
+
+func TestGroupedPacketsCarryHeaderResolvedPath165(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+ seedGroupedRP165(t, db)
+ store := NewPacketStore(db, nil)
+ if err := store.Load(); err != nil {
+ t.Fatal(err)
+ }
+ assertGroupedRP165(t, groupedRowsByHash165(t, store.QueryGroupedPackets(PacketQuery{Limit: 50})))
+ // Second call is served from the grouped sort cache; same contract.
+ assertGroupedRP165(t, groupedRowsByHash165(t, store.QueryGroupedPackets(PacketQuery{Limit: 50})))
+}
+
+func TestGroupedPacketsSQLCarryHeaderResolvedPath165(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+ seedGroupedRP165(t, db)
+ r, err := db.QueryGroupedPackets(PacketQuery{Limit: 50})
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertGroupedRP165(t, groupedRowsByHash165(t, r))
+}
diff --git a/cmd/server/resolved_index.go b/cmd/server/resolved_index.go
index 8dc76e97c..8fd195b7b 100644
--- a/cmd/server/resolved_index.go
+++ b/cmd/server/resolved_index.go
@@ -11,8 +11,10 @@ package main
import (
"database/sql"
+ "encoding/json"
"hash/fnv"
"log"
+ "strconv"
"strings"
"time"
)
@@ -198,6 +200,64 @@ func (s *PacketStore) fetchResolvedPathsForTx(txID int) map[int][]*string {
return result
}
+// fetchResolvedPathsForObsIDs reads resolved_path for many observations in
+// one query (#165, grouped packet pages of up to 50K rows): the ids go in as
+// one JSON array parameter, so there is no bound-variable limit and one
+// statement to prepare. Values come back as the stored JSON, unparsed
+// (resolvedPathRaw), because the caller only re-encodes them. Id 0 is
+// skipped. It bypasses the LRU on purpose: a page is far larger than
+// lruMaxSize and would evict every entry other endpoints rely on.
+func (s *PacketStore) fetchResolvedPathsForObsIDs(ids []int) map[int]json.RawMessage {
+ out := make(map[int]json.RawMessage)
+ if s.db == nil || s.db.conn == nil || !s.db.hasResolvedPath() || len(ids) == 0 {
+ return out
+ }
+ var b strings.Builder
+ b.Grow(len(ids) * 8)
+ b.WriteByte('[')
+ n := 0
+ for _, id := range ids {
+ if id == 0 {
+ continue
+ }
+ if n > 0 {
+ b.WriteByte(',')
+ }
+ b.WriteString(strconv.Itoa(id))
+ n++
+ }
+ b.WriteByte(']')
+ if n == 0 {
+ return out
+ }
+ rows, err := s.db.conn.Query(`SELECT o.id, o.resolved_path FROM observations o
+ WHERE o.id IN (SELECT value FROM json_each(?)) AND o.resolved_path IS NOT NULL`, b.String())
+ if err != nil {
+ return out
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var id int
+ var rpJSON sql.NullString
+ if rows.Scan(&id, &rpJSON) == nil && rpJSON.Valid {
+ if rp := resolvedPathRaw(rpJSON.String); rp != nil {
+ out[id] = rp
+ }
+ }
+ }
+ return out
+}
+
+// resolvedPathRaw returns a stored resolved_path as JSON to embed in a
+// response, or nil when it is empty or not a JSON array (an invalid
+// RawMessage would fail the whole response's encoding).
+func resolvedPathRaw(s string) json.RawMessage {
+ if len(s) < 2 || s[0] != '[' || !json.Valid([]byte(s)) {
+ return nil
+ }
+ return json.RawMessage(s)
+}
+
// fetchResolvedPathForObs fetches resolved_path for a single observation,
// using the LRU cache.
func (s *PacketStore) fetchResolvedPathForObs(obsID int) []*string {
diff --git a/cmd/server/store.go b/cmd/server/store.go
index 7ad0237ad..2b1cdadad 100644
--- a/cmd/server/store.go
+++ b/cmd/server/store.go
@@ -2360,7 +2360,7 @@ func (s *PacketStore) QueryGroupedPackets(q PacketQuery) *PacketResult {
cachedTxs := s.groupedCacheTxs
cachedTotal := s.groupedCacheTotal
s.groupedCacheMu.Unlock()
- return groupedTxsToPage(cachedTxs, cachedTotal, q.Offset, q.Limit)
+ return s.groupedPageWithRP(cachedTxs, cachedTotal, q.Offset, q.Limit)
}
s.groupedCacheMu.Unlock()
@@ -2386,7 +2386,48 @@ func (s *PacketStore) QueryGroupedPackets(q PacketQuery) *PacketResult {
s.groupedCacheExp = time.Now().Add(3 * time.Second)
s.groupedCacheMu.Unlock()
- return groupedTxsToPage(txs, total, q.Offset, q.Limit)
+ return s.groupedPageWithRP(txs, total, q.Offset, q.Limit)
+}
+
+// groupedPageWithRP is groupedTxsToPage plus each row's resolved_path (#165).
+// The packets page resolves hop names from the grouped row, keyed by hop and
+// row observer; without the server's answer it guessed every hop and flagged
+// hops the server had resolved. The path is the one of the observation the
+// row displays (headerObservationID), not the tx's longest-resolved one, so
+// it matches the row's observer_id and path_json. Observation ids are read
+// under s.mu; the SQL runs after it is released, as one batched read per
+// page (fetchResolvedPathsForObsIDs) rather than one per row.
+func (s *PacketStore) groupedPageWithRP(txs []*StoreTx, total, offset, limit int) *PacketResult {
+ res := groupedTxsToPage(txs, total, offset, limit)
+ if len(res.Packets) == 0 {
+ return res
+ }
+ page := txs[offset : offset+len(res.Packets)]
+ ids := make([]int, len(page))
+ s.mu.RLock()
+ for i, tx := range page {
+ ids[i] = headerObservationID(tx)
+ }
+ s.mu.RUnlock()
+ rps := s.fetchResolvedPathsForObsIDs(ids)
+ for i, id := range ids {
+ if rp := rps[id]; rp != nil {
+ res.Packets[i]["resolved_path"] = rp
+ }
+ }
+ return res
+}
+
+// headerObservationID returns the id of the observation pickBestObservation
+// copied onto tx (same observer and path), or 0 when none matches.
+// Caller holds s.mu.
+func headerObservationID(tx *StoreTx) int {
+ for _, o := range tx.Observations {
+ if o != nil && o.ObserverID == tx.ObserverID && o.PathJSON == tx.PathJSON {
+ return o.ID
+ }
+ }
+ return 0
}
// pagePacketResult returns a window of a PacketResult without re-allocating the slice.
@@ -2441,7 +2482,7 @@ func groupedTxsToPage(txs []*StoreTx, total, offset, limit int) *PacketResult {
"rssi": floatPtrOrNil(tx.RSSI),
"scope_name": strOrNil(tx.ScopeName),
}
- // resolved_path omitted for grouped view (cold path, not worth SQL round-trip)
+ // resolved_path is added by groupedPageWithRP (#165).
packets[i] = m
}
diff --git a/public/hop-display.js b/public/hop-display.js
index 1f7751943..02fe7e52f 100644
--- a/public/hop-display.js
+++ b/public/hop-display.js
@@ -57,6 +57,19 @@ window.HopDisplay = (function() {
popover.style.left = Math.max(8, Math.min(rect.left + window.scrollX - 60, window.innerWidth - 280)) + 'px';
}
+ /**
+ * The number on a hop's conflict badge; the badge is drawn when it is > 1.
+ * Only regional conflicts count, or every conflict when the resolver fell
+ * back to the global list. #165 — the packets list's path summary counts
+ * hops with this, so the list flags exactly the hops the detail badges.
+ */
+ function conflictBadgeCount(entry) {
+ if (!entry || typeof entry !== 'object') return 0;
+ const conflicts = entry.conflicts || [];
+ const regional = conflicts.filter(c => c.regional).length;
+ return regional > 0 ? regional : (entry.globalFallback ? conflicts.length : 0);
+ }
+
/**
* Render a hop prefix as HTML with conflict info.
*/
@@ -77,9 +90,7 @@ window.HopDisplay = (function() {
let title = h;
if (unreliable) title += ' — unreliable';
- // Badge — only count regional conflicts
- const regionalConflicts = conflicts.filter(c => c.regional);
- const badgeCount = regionalConflicts.length > 0 ? regionalConflicts.length : (globalFallback ? conflicts.length : 0);
+ const badgeCount = conflictBadgeCount(entry);
const conflictData = escapeHtml(JSON.stringify({ h, conflicts, globalFallback }));
const conflictBadge = badgeCount > 1
? ` `
@@ -87,7 +98,9 @@ window.HopDisplay = (function() {
const unreliableBadge = unreliable
? ' '
: '';
- const warnBadge = conflictBadge + unreliableBadge;
+ // #165 (port of upstream 2099) — opts.badge === false renders the name without its badge, for the
+ // packets list, which summarises per path instead of per hop.
+ const warnBadge = opts.badge === false ? '' : (conflictBadge + unreliableBadge);
const cls = [
'hop',
@@ -95,12 +108,22 @@ window.HopDisplay = (function() {
ambiguous ? 'hop-ambiguous' : '',
unreliable ? 'hop-unreliable' : '',
globalFallback ? 'hop-global-fallback' : '',
+ // #165 — callers mark the pill itself (e.g. nodes.js 'hop-current').
+ // Patching the first class="" of the returned HTML would hit the
+ // .hop-group wrapper whenever the hop carries a badge.
+ opts.className || '',
].filter(Boolean).join(' ');
- if (opts.link !== false) {
- return `${display}${warnBadge}`;
- }
- return `${display}${warnBadge}`;
+ const pill = opts.link !== false
+ ? `${display}`
+ : `${display}`;
+
+ // #165 (port of upstream 2099) — a badge is a sibling of its pill, so in "A [8] → B [6]" nothing
+ // said which name the 8 belonged to. Wrapping the pair keeps them together
+ // and puts the separator clearly outside. Only when there is a badge:
+ // wrapping every hop would change the layout of every path for nothing.
+ if (!warnBadge) return pill;
+ return `${pill}${warnBadge}`;
}
/**
@@ -146,5 +169,5 @@ window.HopDisplay = (function() {
'
' + items + '
';
}
- return { renderHop, renderPath, _showFromBtn, PATH_SYMBOLS_LEGEND, renderPathSymbolsLegend };
+ return { renderHop, renderPath, conflictBadgeCount, _showFromBtn, PATH_SYMBOLS_LEGEND, renderPathSymbolsLegend };
})();
diff --git a/public/nodes.js b/public/nodes.js
index ef8baf3e4..d1cece996 100644
--- a/public/nodes.js
+++ b/public/nodes.js
@@ -1207,8 +1207,7 @@
const isThis = h.pubkey && h.pubkey.toLowerCase() === currentPubkey;
if (window.HopDisplay) {
const entry = { name: h.name, pubkey: h.pubkey, ambiguous: h.ambiguous, conflicts: h.conflicts, totalGlobal: h.totalGlobal, totalRegional: h.totalRegional, globalFallback: h.globalFallback, unreliable: h.unreliable };
- const html = HopDisplay.renderHop(h.prefix, entry);
- return isThis ? html.replace('class="', 'class="hop-current ') : html;
+ return HopDisplay.renderHop(h.prefix, entry, isThis ? { className: 'hop-current' } : undefined);
}
const name = escapeHtml(h.name || h.prefix);
const link = h.pubkey ? `${name}` : `${name}`;
diff --git a/public/packets.js b/public/packets.js
index 7d72ea4d5..d1f137ec5 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -829,7 +829,23 @@
if (isMobile && savedTimeWindowMin > 180) savedTimeWindowMin = 15;
let totalCount = 0;
let expandedHashes = new Set();
- let hopNameCache = {};
+ // #165 — resolved hop names, keyed by hop and by hop:observer. The
+ // per-observer keys grow with every observer that hears a prefix, so the
+ // cache is bounded: past HOP_CACHE_MAX the oldest entries are evicted
+ // (Map keeps insertion order), and destroy() clears it.
+ const HOP_CACHE_MAX = 50000;
+ let hopNameCache = new Map();
+ // #165 — bumped by destroy(). A hop job that started before it (the
+ // initial load yields between observer groups) stops instead of writing
+ // into the next page's cache.
+ let hopCacheGen = 0;
+ function hopCacheHas(k) { return hopNameCache.has(k); }
+ function hopCacheGet(k) { return hopNameCache.get(k); }
+ function hopCacheSet(k, v) {
+ hopNameCache.delete(k); // a rewrite counts as new, for eviction order
+ hopNameCache.set(k, v);
+ if (hopNameCache.size > HOP_CACHE_MAX) hopNameCache.delete(hopNameCache.keys().next().value);
+ }
let _tableSortInstance = null;
let _packetSortColumn = null;
let _packetSortDirection = 'desc';
@@ -1107,10 +1123,16 @@
api('/observers', { ttl: 60000 }),
api('/iata-coords', { ttl: 300000 }).catch(() => ({ coords: {} })),
]);
+ const obsList = obsData.observers || obsData || [];
HopResolver.init(nodeData.nodes || [], {
- observers: obsData.observers || obsData || [],
+ observers: obsList,
iataCoords: coordData.coords || {},
});
+ // #165 (upstream 2099) — seed the lookup resolveHops() needs for the anchor, in case a
+ // render gets here before loadObservers() has run.
+ if (!observerMap || !observerMap.size) {
+ observerMap = new Map(obsList.map(o => [o.id, o]));
+ }
} catch (e) {
// Non-fatal: hops will render as unresolved hex prefixes until a later
// call succeeds. Log so a paginated /api/nodes failure isn't silent.
@@ -1120,14 +1142,78 @@
}
// Resolve hop hex prefixes to node names (cached, client-side)
- async function resolveHops(hops) {
- const unknown = hops.filter(h => !(h in hopNameCache));
- if (unknown.length) {
- await ensureHopResolver();
- const resolved = HopResolver.resolve(unknown);
- Object.assign(hopNameCache, resolved || {});
- // Cache misses as null so we don't re-query
- unknown.forEach(h => { if (!(h in hopNameCache)) hopNameCache[h] = null; });
+ // #165 (upstream 2099) — the cache key carries the observer, because an ambiguous hop
+ // resolves differently depending on who heard it. renderHop() has always
+ // looked for this key; nothing ever wrote it.
+ // #165 (upstream 2099) — the observer's own position, used as the anchor at the receiving
+ // end of the path. The IATA route is dead weight: measured on the live
+ // deployment, none of the 42 observers has its code in /api/iata-coords, so
+ // nodeInRegion() always returns null. lat/lon is reported directly and works.
+ // #165 — /api/observers sends lat/lon as null when no node location is
+ // known, and Number(null) is 0. Only a pair of real numbers is an anchor;
+ // (0, 0) means "no fix" here, as on the server and in HopResolver.
+ function coordOrNull(v) {
+ if (v === null || v === undefined || v === '') return null;
+ const n = Number(v);
+ return Number.isFinite(n) ? n : null;
+ }
+
+ function observerPosition(observerId) {
+ const o = observerId && observerMap ? observerMap.get(observerId) : null;
+ const lat = o ? coordOrNull(o.lat) : null;
+ const lon = o ? coordOrNull(o.lon) : null;
+ if (lat === null || lon === null || (lat === 0 && lon === 0)) return [null, null];
+ return [lat, lon];
+ }
+
+ function hopCacheKey(h, observerId) {
+ return observerId ? h + ':' + observerId : h;
+ }
+
+ // #165 (upstream 2099) — resolve WITH the observer. Every 1-byte prefix on the network is
+ // shared by several nodes, and the observer is what lets HopResolver filter
+ // candidates by region and report the rest as conflicts. Called with the hops
+ // alone, it returns the first candidate with no signal that it guessed, which
+ // is how a repeater 126 km outside the observer's region ended up displayed
+ // as a certainty.
+ async function resolveHops(hops, observerId) {
+ const unknown = hops.filter(h => !hopCacheHas(hopCacheKey(h, observerId)));
+ if (!unknown.length) return;
+ const gen = hopCacheGen;
+ await ensureHopResolver();
+ if (gen !== hopCacheGen) return;
+ const [obsLat, obsLon] = observerPosition(observerId);
+ const resolved = HopResolver.resolve(unknown, null, null, obsLat, obsLon, observerId) || {};
+ for (const h of unknown) {
+ const entry = resolved[h] || null;
+ hopCacheSet(hopCacheKey(h, observerId), entry);
+ // Bare key as a fallback for any render that has no observer in hand.
+ if (!hopCacheHas(h)) hopCacheSet(h, entry);
+ }
+ }
+
+ // Resolve every hop of every packet, grouped by the observer that heard it,
+ // so each group gets its own regional filtering.
+ async function resolveHopsForPackets(packets) {
+ const groups = new Map();
+ for (const p of packets || []) {
+ const obs = (p && p.observer_id) ? String(p.observer_id) : '';
+ let set = groups.get(obs);
+ if (!set) { set = new Set(); groups.set(obs, set); }
+ try { getParsedPath(p).forEach(h => set.add(h)); } catch {}
+ }
+ // ensureHopResolver() is idempotent and awaited once inside resolveHops;
+ // the resolve itself is local computation, so the loop costs no requests.
+ // #165 — but it is CPU: ~42 groups on a 30K load took one ~220 ms block.
+ // Yield to the event loop between groups so input and paint get through.
+ const gen = hopCacheGen;
+ let first = true;
+ for (const [obs, set] of groups) {
+ if (!set.size) continue;
+ if (!first) await new Promise(r => setTimeout(r, 0));
+ if (gen !== hopCacheGen) return;
+ first = false;
+ await resolveHops([...set], obs || undefined);
}
}
@@ -1144,24 +1230,92 @@
if (rp) { needsInit = true; break; }
}
if (!needsInit) return;
+ const gen = hopCacheGen;
await ensureHopResolver();
- for (const p of packets) {
- const rp = getResolvedPath(p);
- if (!rp) continue;
- const hops = getParsedPath(p);
- const resolved = HopResolver.resolveFromServer(hops, rp);
- Object.assign(hopNameCache, resolved);
+ // #165 — grouped rows carry resolved_path too since PR #185 F1, so this
+ // walks the whole page (up to 50K rows). Yield every 2000 rows so it is
+ // not one long main-thread block, and stop if destroy() ran meanwhile.
+ for (let i = 0; i < packets.length; i++) {
+ if (i && i % 2000 === 0) await new Promise(r => setTimeout(r, 0));
+ if (gen !== hopCacheGen) return;
+ cacheServerResolvedPath(packets[i]);
+ }
+ }
+
+ // Does k already hold exactly this server answer? A client pick of the same
+ // node that was flagged (ambiguous, unreliable, global fallback) does not
+ // count: the server's answer must replace it, or the list keeps warning.
+ function holdsServerAnswer(k, pubkey) {
+ const e = hopCacheGet(k);
+ return !!e && e.pubkey === pubkey && !e.ambiguous && !e.unreliable && !e.globalFallback;
+ }
+
+ // #165 — store the server's resolved_path entries under the bare key AND
+ // under hop:observer. renderHop() and resolveHops() both look at the
+ // per-observer key first, so a server answer stored only under the bare key
+ // was re-resolved by the client heuristic and replaced. Writing it where
+ // the readers look makes the server win without resolveHops() needing to
+ // know which packet a prefix came from. Hops the server left null are not
+ // written, so the client still resolves them and can show the ambiguity.
+ // Caller must have initialised HopResolver.
+ // PR #185 F1 — grouped rows carry resolved_path too, and most rows repeat
+ // an answer that is already cached (same prefix, observer and node). Those
+ // are skipped (holdsServerAnswer), so a 30K page does not rewrite the cache
+ // twice per hop per row.
+ function cacheServerResolvedPath(p) {
+ const rp = getResolvedPath(p);
+ if (!rp) return;
+ const hops = getParsedPath(p);
+ const obs = p.observer_id ? String(p.observer_id) : '';
+ let news = false;
+ for (let i = 0; i < rp.length && !news; i++) {
+ if (rp[i] && (!holdsServerAnswer(hops[i], rp[i]) || (obs && !holdsServerAnswer(hopCacheKey(hops[i], obs), rp[i])))) news = true;
+ }
+ if (!news) return;
+ const resolved = HopResolver.resolveFromServer(hops, rp);
+ for (const h of Object.keys(resolved)) {
+ const v = resolved[h];
+ if (!holdsServerAnswer(h, v.pubkey)) hopCacheSet(h, v);
+ if (obs && !holdsServerAnswer(hopCacheKey(h, obs), v.pubkey)) hopCacheSet(hopCacheKey(h, obs), v);
}
}
- function renderHop(h, observerId) {
+ // Incremental path (WS/poll): pre-populate from server-side resolved_path,
+ // then fall back to the client resolver for the remaining hops.
+ // #165 — "already resolved" means resolved for THIS packet's observer. A
+ // bare-key check let a second observer reuse the first one's pick.
+ function resolveIncomingHops(pkts) {
+ let missing = false;
+ for (const p of pkts) {
+ if (window.HopResolver && HopResolver.ready()) cacheServerResolvedPath(p);
+ const obs = p.observer_id ? String(p.observer_id) : undefined;
+ try { if (getParsedPath(p).some(h => !hopCacheHas(hopCacheKey(h, obs)))) missing = true; } catch {}
+ }
+ return missing ? resolveHopsForPackets(pkts) : Promise.resolve();
+ }
+
+ function renderHop(h, observerId, opts) {
// Use per-packet cache key if observer context available (ambiguous hops differ by region)
- const cacheKey = observerId ? h + ':' + observerId : h;
- const entry = hopNameCache[cacheKey] || hopNameCache[h];
- return HopDisplay.renderHop(h, entry, { hexMode: showHexHashes });
+ const cacheKey = hopCacheKey(h, observerId);
+ const entry = hopCacheGet(cacheKey) || hopCacheGet(h);
+ return HopDisplay.renderHop(h, entry, Object.assign({ hexMode: showHexHashes }, opts || {}));
+ }
+
+ // #165 (PR #185 review F1, F6) — is this hop uncertain for this observer?
+ // Only the observer's own entry counts: the bare key renderHop() falls
+ // back to for the name is another observer's answer, or the server's for
+ // another observation. And "uncertain" is what the detail pane badges
+ // (HopDisplay.conflictBadgeCount), so list and detail never disagree.
+ function hopUncertainFor(h, observerId) {
+ const entry = observerId ? hopCacheGet(hopCacheKey(h, observerId)) : hopCacheGet(h);
+ return HopDisplay.conflictBadgeCount(entry) > 1;
}
- function renderPath(hops, observerId) {
+ // #165 (upstream 2099) — opts.summary renders the list form: names without a badge on every
+ // hop, and one indicator for the whole path. A row with five 1-byte hops was
+ // five warning triangles, which is noise in a table; the detail pane keeps
+ // the per-hop badges, because that is where the question gets answered.
+ function renderPath(hops, observerId, opts) {
if (!hops || !hops.length) return '—';
// #1633 — render-time filter (default OFF). Applies at every consumer
// because every site funnels through this function (group header, child
@@ -1170,7 +1324,23 @@
? window.MC_filterPathHops(hops)
: hops;
if (!filtered.length) return '— (1-byte filtered)';
- return filtered.map(h => renderHop(h, observerId)).join('→');
+ const arrow = '→';
+ if (!(opts && opts.summary)) return filtered.map(h => renderHop(h, observerId)).join(arrow);
+
+ let uncertain = 0;
+ const body = filtered.map(h => {
+ const flag = hopUncertainFor(h, observerId);
+ if (flag) uncertain++;
+ return renderHop(h, observerId, flag ? { badge: false, className: 'hop-uncertain' } : { badge: false });
+ }).join(arrow);
+ if (!uncertain) return body;
+ const label = uncertain + ' of ' + filtered.length + ' hops have more than one candidate';
+ // #165 — first, not last: the list's path cell clips overflow, and the
+ // long paths that overflow are the ones the warning matters most for.
+ const warn = '' +
+ uncertain + '';
+ return warn + body;
}
let directPacketId = null;
@@ -1394,9 +1564,7 @@
panel.appendChild(content);
const pkt = data.packet;
try {
- const hops = getParsedPath(pkt);
- const newHops = hops.filter(h => !(h in hopNameCache));
- if (newHops.length) await resolveHops(newHops);
+ await resolveHopsForPackets([pkt]);
} catch {}
await renderDetail(content, data);
initPanelResize();
@@ -1441,18 +1609,7 @@
if (!filtered.length) return;
// Resolve any new hops, then update and re-render
- // Pre-populate from server-side resolved_path, then fall back for remaining
- const newHops = new Set();
- for (const p of filtered) {
- const rp = getResolvedPath(p);
- const hops = getParsedPath(p);
- if (rp && rp.length === hops.length && window.HopResolver && HopResolver.ready()) {
- const resolved = HopResolver.resolveFromServer(hops, rp);
- Object.assign(hopNameCache, resolved);
- }
- try { hops.forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
- }
- (newHops.size ? resolveHops([...newHops]) : Promise.resolve()).then(() => {
+ resolveIncomingHops(filtered).then(() => {
if (groupByHash) {
// Update existing groups or create new ones
for (const p of filtered) {
@@ -1546,7 +1703,8 @@
filtersBuilt = false;
delete filters.node;
expandedHashes = new Set();
- hopNameCache = {};
+ hopNameCache = new Map();
+ hopCacheGen++;
totalCount = 0;
observers = [];
observerMap = new Map();
@@ -1656,11 +1814,7 @@
const hopJob = (async () => {
try {
await cacheResolvedPaths(packets);
- const allHops = new Set();
- for (const p of packets) {
- try { getParsedPath(p).forEach(h => allHops.add(h)); } catch {}
- }
- if (allHops.size) await resolveHops([...allHops]);
+ await resolveHopsForPackets(packets);
// Re-render rows so resolved hop names replace hex prefixes.
if (filtersBuilt) renderTableRows();
} catch (e) {
@@ -2205,11 +2359,7 @@
if (p._children) sortGroupChildren(p);
}
// Resolve any new hops from updated header paths
- const newHops = new Set();
- for (const p of packets) {
- try { getParsedPath(p).forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
- }
- if (newHops.size) await resolveHops([...newHops]);
+ await resolveHopsForPackets(packets);
renderTableRows();
});
@@ -2488,7 +2638,7 @@
const groupRegion = headerObserverId ? (observerMap.get(headerObserverId)?.iata || '') : '';
let groupPath = [];
try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {}
- const groupPathStr = renderPath(groupPath, headerObserverId);
+ const groupPathStr = renderPath(groupPath, headerObserverId, { summary: true });
const groupTypeName = payloadTypeName(p.payload_type);
const groupTypeClass = payloadTypeColor(p.payload_type);
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
@@ -2548,7 +2698,7 @@
: (childPath.length > 0 ? childPath[0].length / 2 : 0));
const _cHashSizeTitle = _cIsTrace ? ' title="TRACE path bytes are SNR readings, not hash prefixes — see sidebar decoder for actual hop count"' : '';
const childRegion = c.observer_id ? (observerMap.get(c.observer_id)?.iata || '') : '';
- const childPathStr = renderPath(childPath, c.observer_id);
+ const childPathStr = renderPath(childPath, c.observer_id, { summary: true });
const _childHashStripe = _hashStripeStyle(c.hash || p.hash);
html += `
${childRegion ? `${childRegion}` : '—'}
@@ -2583,7 +2733,7 @@
const _flatIsTrace = p.payload_type === 9;
const hashBytes = _flatIsTrace ? '—' : (((parseInt(p.raw_hex?.slice(_flatPlOff * 2, _flatPlOff * 2 + 2), 16) || 0) >> 6) + 1);
const _flatHashSizeTitle = _flatIsTrace ? ' title="TRACE path bytes are SNR readings, not hash prefixes — see sidebar decoder for actual hop count"' : '';
- const pathStr = renderPath(pathHops, p.observer_id);
+ const pathStr = renderPath(pathHops, p.observer_id, { summary: true });
const detail = getDetailPreview(decoded);
const _flatHashStripe = _hashStripeStyle(p.hash);
const _flatStyle = _flatHashStripe + _chanStyle;
@@ -2800,7 +2950,8 @@
// is past the host's right edge.
for (var j = 0; j < children.length; j++) {
var ch = children[j];
- if (ch.classList.contains('arrow')) continue;
+ // #165 — the path's ambiguity summary is not a hop.
+ if (ch.classList.contains('arrow') || ch.classList.contains('hop-path-warn')) continue;
var r = ch.getBoundingClientRect();
if (r.left >= hostRight || r.right > hostRight + 0.5) hidden++;
}
@@ -2860,6 +3011,21 @@
}
}
+ // Popover body for a .path-hops host: clone all children except the pill
+ // and (#165, upstream 2557894d) the path's ambiguity summary, preserving
+ // rendered chips/arrows. The title counts the hops listed (PR #185 F5).
+ function _pathPopoverHtml(host) {
+ var body = '', hops = 0;
+ var kids = Array.prototype.slice.call(host.children);
+ for (var i = 0; i < kids.length; i++) {
+ var cl = kids[i].classList;
+ if (cl.contains('path-overflow-pill') || cl.contains('hop-path-warn')) continue;
+ if (!cl.contains('arrow')) hops++;
+ body += kids[i].outerHTML;
+ }
+ return '
';
+ }
+
// Delegated click for path overflow pills — show popover of full path.
function _wirePathOverflowPopover() {
if (window.__pathOverflowWired) return;
@@ -2883,15 +3049,7 @@
dismiss();
var pop = document.createElement('div');
pop.className = 'path-popover';
- // Clone all children except the pill, preserving rendered chips/arrows.
- var inner = '
Full path (' + (host.children.length) + ' items)
';
- var kids = Array.prototype.slice.call(host.children);
- for (var i = 0; i < kids.length; i++) {
- if (kids[i].classList.contains('path-overflow-pill')) continue;
- inner += kids[i].outerHTML;
- }
- inner += '
';
- pop.innerHTML = inner;
+ pop.innerHTML = _pathPopoverHtml(host);
document.body.appendChild(pop);
var r = pill.getBoundingClientRect();
// #1128 (Bug 2): position below by default, but flip ABOVE when there
@@ -3414,9 +3572,7 @@
// Resolve path hops for detail view
const pkt = data.packet;
try {
- const hops = getParsedPath(pkt);
- const newHops = hops.filter(h => !(h in hopNameCache));
- if (newHops.length) await resolveHops(newHops);
+ await resolveHopsForPackets([pkt]);
} catch {}
panel.innerHTML = isMobileNow ? '' : (useSlideOver ? '' : ('' + PANEL_CLOSE_HTML));
const content = document.createElement('div');
@@ -3551,12 +3707,16 @@
resolved = HopResolver.resolveFromServer(pathHops, serverResolved);
} else {
await ensureHopResolver();
- resolved = HopResolver.resolve(pathHops);
+ // #165 (upstream 2099) — with the observer: the cache write below stores this under
+ // the per-observer key, so it has to have been resolved for that
+ // observer or the key promises something the value is not.
+ const [dLat, dLon] = observerPosition(pkt.observer_id);
+ resolved = HopResolver.resolve(pathHops, null, null, dLat, dLon, pkt.observer_id);
}
if (resolved) {
for (const [k, v] of Object.entries(resolved)) {
- hopNameCache[k] = v;
- if (pkt.observer_id) hopNameCache[k + ':' + pkt.observer_id] = v;
+ hopCacheSet(k, v);
+ if (pkt.observer_id) hopCacheSet(hopCacheKey(k, String(pkt.observer_id)), v);
}
}
} catch {}
@@ -3958,7 +4118,7 @@
for (let i = 0; i < pathHops.length; i++) {
const hopOff = off + i * hashSize;
const hex = String(pathHops[i] || '').toUpperCase();
- const hopHtml = HopDisplay.renderHop(hex, hopNameCache[hex]);
+ const hopHtml = HopDisplay.renderHop(hex, hopCacheGet(hex));
const label = `Hop ${i} — ${hopHtml}`;
rows += fieldRow(hopOff, label, hex, '');
}
@@ -4321,12 +4481,7 @@
}
// Resolve hops from children: prefer server-side resolved_path
await cacheResolvedPaths(group?._children || []);
- const childHops = new Set();
- for (const c of (group?._children || [])) {
- try { getParsedPath(c).forEach(h => childHops.add(h)); } catch {}
- }
- const newHops = [...childHops].filter(h => !(h in hopNameCache));
- if (newHops.length) await resolveHops(newHops);
+ await resolveHopsForPackets(group?._children || []);
expandedHashes.add(hash);
renderTableRows();
// Also open detail panel — no extra fetch needed
@@ -4392,6 +4547,17 @@
fieldRow,
renderTimestampCell,
renderPath,
+ observerPosition,
+ resolveHops,
+ resolveHopsForPackets,
+ resolveIncomingHops,
+ cacheResolvedPaths,
+ _finalizePathOverflow,
+ _pathPopoverHtml,
+ HOP_CACHE_MAX,
+ _hopCacheGet: hopCacheGet,
+ _hopCacheSize: function() { return hopNameCache.size; },
+ _destroy: destroy,
_getRowCount,
_cumulativeRowOffsets,
_invalidateRowCounts,
@@ -4425,10 +4591,7 @@
await loadObservers();
const data = await api(`/packets/${param}`);
if (!data?.packet) { app.innerHTML = `