mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 01:23:54 +00:00
feat: View Path map shows every station that heard the packet, not just the deepest one
GetPacketPath used to resolve only the single farthest-traveled observation and discard the rest. It now returns one branch per distinct station (kept at that station's own deepest observation), each with its own hop count and, where resolvable, relay chain -- so the map visualizes the packet's full flood spread instead of one route. Stations whose path never resolved, or who heard the packet directly (0 hops), still contribute a branch via their own position, rather than being silently dropped. The "View path" link in the ping-bot reply no longer requires hops > 0 to show, since even an all-direct ping still has observer spread worth plotting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
461fbaad1b
commit
2f686d40c6
+107
-57
@@ -1529,10 +1529,10 @@ type PacketPathPoint struct {
|
||||
Lon *float64 `json:"lon"`
|
||||
}
|
||||
|
||||
// PacketPathObserver is the station that produced the deepest observation
|
||||
// of a packet path (see GetPacketPath), positioned from its configured
|
||||
// IATA code the same way the Wardriving tab positions observers -- not a
|
||||
// stored per-observer lat/lon column.
|
||||
// PacketPathObserver is the station that produced a given branch's
|
||||
// observation of a packet (see GetPacketPath), positioned from its
|
||||
// configured IATA code the same way the Wardriving tab positions
|
||||
// observers -- not a stored per-observer lat/lon column.
|
||||
type PacketPathObserver struct {
|
||||
Name string `json:"name"`
|
||||
IATA string `json:"iata,omitempty"`
|
||||
@@ -1540,39 +1540,50 @@ type PacketPathObserver struct {
|
||||
Lon *float64 `json:"lon"`
|
||||
}
|
||||
|
||||
// PacketPathResponse is the geographic relay path for one packet hash,
|
||||
// used to draw it on a map (the ping-bot reply's "View path" link).
|
||||
type PacketPathResponse struct {
|
||||
Hash string `json:"hash"`
|
||||
// PacketPathBranch is one station's route to a packet: how far it
|
||||
// traveled to reach them (hop count taken straight from that
|
||||
// observation's path_json, independent of how much of it resolved) and,
|
||||
// where resolvable, each hop's name/role/lat/lon in path order.
|
||||
type PacketPathBranch struct {
|
||||
Hops int `json:"hops"`
|
||||
Points []PacketPathPoint `json:"points"`
|
||||
Observer *PacketPathObserver `json:"observer,omitempty"`
|
||||
SNR *float64 `json:"snr,omitempty"`
|
||||
}
|
||||
|
||||
// GetPacketPath resolves a packet's DEEPEST observation (the one with the
|
||||
// most hops -- same "farthest leg" reasoning as the ping-bot reply, see
|
||||
// pingBotReply's doc comment) to a geographic point sequence: each
|
||||
// relay's name/role/lat/lon in path order, plus the hearing observer's
|
||||
// position. A packet can have several observations (heard by more than
|
||||
// one station, possibly at different hop depths); this always picks the
|
||||
// one that traveled farthest, since that's the more informative path to
|
||||
// show on a map.
|
||||
// PacketPathResponse is every branch a packet is known to have reached --
|
||||
// one per distinct observer, kept at that observer's own deepest
|
||||
// observation -- used to draw the full flood spread on a map (the
|
||||
// ping-bot reply's "View path" link), not just the single farthest route.
|
||||
type PacketPathResponse struct {
|
||||
Hash string `json:"hash"`
|
||||
Branches []PacketPathBranch `json:"branches"`
|
||||
}
|
||||
|
||||
// GetPacketPath resolves every distinct station that observed a packet to
|
||||
// its own branch: hop count and (where resolvable) relay names/positions
|
||||
// in path order, plus that station's own position. A station can hear a
|
||||
// packet more than once as flood copies arrive via different routes; only
|
||||
// its deepest observation (by raw hop count, same "farthest leg"
|
||||
// reasoning as the ping-bot reply -- see pingBotReply's doc comment) is
|
||||
// kept, so each station contributes exactly one branch. Branches are
|
||||
// returned deepest-first.
|
||||
func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) {
|
||||
if !db.hasResolvedPath {
|
||||
return nil, fmt.Errorf("resolved_path not available on this server")
|
||||
}
|
||||
var querySQL string
|
||||
if db.isV3 {
|
||||
querySQL = `SELECT obs.name, obs.iata, o.resolved_path
|
||||
querySQL = `SELECT obs.rowid, obs.name, obs.iata, o.path_json, o.resolved_path, o.snr
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||||
WHERE t.hash = ? AND o.resolved_path IS NOT NULL AND o.resolved_path != ''`
|
||||
WHERE t.hash = ?`
|
||||
} else {
|
||||
querySQL = `SELECT o.observer_name, NULL, o.resolved_path
|
||||
querySQL = `SELECT o.observer_id, o.observer_name, NULL, o.path_json, o.resolved_path, o.snr
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id
|
||||
WHERE t.hash = ? AND o.resolved_path IS NOT NULL AND o.resolved_path != ''`
|
||||
WHERE t.hash = ?`
|
||||
}
|
||||
rows, err := db.conn.Query(querySQL, strings.ToLower(hash))
|
||||
if err != nil {
|
||||
@@ -1580,37 +1591,68 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var bestPath []*string
|
||||
var bestObserverName, bestObserverIATA sql.NullString
|
||||
type obsBranch struct {
|
||||
hops int
|
||||
resolvedPath []*string
|
||||
observerName string
|
||||
observerIATA sql.NullString
|
||||
snr sql.NullFloat64
|
||||
}
|
||||
best := make(map[string]*obsBranch)
|
||||
|
||||
for rows.Next() {
|
||||
var obsName, obsIATA, rpJSON sql.NullString
|
||||
if err := rows.Scan(&obsName, &obsIATA, &rpJSON); err != nil {
|
||||
var obsKey, obsName, obsIATA, pathJSON, resolvedPathJSON sql.NullString
|
||||
var snr sql.NullFloat64
|
||||
if err := rows.Scan(&obsKey, &obsName, &obsIATA, &pathJSON, &resolvedPathJSON, &snr); err != nil {
|
||||
continue
|
||||
}
|
||||
if !rpJSON.Valid {
|
||||
if !pathJSON.Valid {
|
||||
continue
|
||||
}
|
||||
rp := unmarshalResolvedPath(rpJSON.String)
|
||||
if len(rp) > len(bestPath) {
|
||||
bestPath = rp
|
||||
bestObserverName, bestObserverIATA = obsName, obsIATA
|
||||
var h []string
|
||||
if json.Unmarshal([]byte(pathJSON.String), &h) != nil {
|
||||
continue
|
||||
}
|
||||
hops := len(h)
|
||||
var resolvedPath []*string
|
||||
if resolvedPathJSON.Valid {
|
||||
resolvedPath = unmarshalResolvedPath(resolvedPathJSON.String)
|
||||
}
|
||||
key := obsKey.String
|
||||
if key == "" {
|
||||
key = obsName.String
|
||||
}
|
||||
if key == "" {
|
||||
continue // no way to attribute this observation to a station
|
||||
}
|
||||
if existing, ok := best[key]; !ok || hops > existing.hops {
|
||||
best[key] = &obsBranch{
|
||||
hops: hops, resolvedPath: resolvedPath,
|
||||
observerName: obsName.String, observerIATA: obsIATA, snr: snr,
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("packet path iteration: %w", err)
|
||||
}
|
||||
|
||||
resp := &PacketPathResponse{Hash: hash, Hops: len(bestPath), Points: []PacketPathPoint{}}
|
||||
if len(bestPath) == 0 {
|
||||
resp := &PacketPathResponse{Hash: hash, Branches: []PacketPathBranch{}}
|
||||
if len(best) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
pubkeys := make([]string, 0, len(bestPath))
|
||||
for _, pk := range bestPath {
|
||||
if pk != nil && *pk != "" {
|
||||
pubkeys = append(pubkeys, *pk)
|
||||
pubkeySet := map[string]bool{}
|
||||
for _, b := range best {
|
||||
for _, pk := range b.resolvedPath {
|
||||
if pk != nil && *pk != "" {
|
||||
pubkeySet[*pk] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
pubkeys := make([]string, 0, len(pubkeySet))
|
||||
for pk := range pubkeySet {
|
||||
pubkeys = append(pubkeys, pk)
|
||||
}
|
||||
type nodeInfo struct {
|
||||
name string
|
||||
role string
|
||||
@@ -1652,31 +1694,39 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, pk := range bestPath {
|
||||
if pk == nil || *pk == "" {
|
||||
continue
|
||||
}
|
||||
ni := nodeByPK[*pk]
|
||||
name := ni.name
|
||||
if name == "" {
|
||||
name = *pk
|
||||
}
|
||||
resp.Points = append(resp.Points, PacketPathPoint{
|
||||
PublicKey: *pk, Name: name, Role: ni.role, Lat: ni.lat, Lon: ni.lon,
|
||||
})
|
||||
}
|
||||
|
||||
if bestObserverName.Valid && bestObserverName.String != "" {
|
||||
obs := &PacketPathObserver{Name: bestObserverName.String}
|
||||
if bestObserverIATA.Valid {
|
||||
obs.IATA = strings.ToUpper(strings.TrimSpace(bestObserverIATA.String))
|
||||
if coord, ok := iataCoords[obs.IATA]; ok {
|
||||
lat, lon := coord.Lat, coord.Lon
|
||||
obs.Lat, obs.Lon = &lat, &lon
|
||||
for _, b := range best {
|
||||
branch := PacketPathBranch{Hops: b.hops, Points: []PacketPathPoint{}}
|
||||
for _, pk := range b.resolvedPath {
|
||||
if pk == nil || *pk == "" {
|
||||
continue
|
||||
}
|
||||
ni := nodeByPK[*pk]
|
||||
name := ni.name
|
||||
if name == "" {
|
||||
name = *pk
|
||||
}
|
||||
branch.Points = append(branch.Points, PacketPathPoint{
|
||||
PublicKey: *pk, Name: name, Role: ni.role, Lat: ni.lat, Lon: ni.lon,
|
||||
})
|
||||
}
|
||||
resp.Observer = obs
|
||||
if b.observerName != "" {
|
||||
obs := &PacketPathObserver{Name: b.observerName}
|
||||
if b.observerIATA.Valid {
|
||||
obs.IATA = strings.ToUpper(strings.TrimSpace(b.observerIATA.String))
|
||||
if coord, ok := iataCoords[obs.IATA]; ok {
|
||||
lat, lon := coord.Lat, coord.Lon
|
||||
obs.Lat, obs.Lon = &lat, &lon
|
||||
}
|
||||
}
|
||||
branch.Observer = obs
|
||||
}
|
||||
if b.snr.Valid {
|
||||
v := b.snr.Float64
|
||||
branch.SNR = &v
|
||||
}
|
||||
resp.Branches = append(resp.Branches, branch)
|
||||
}
|
||||
sort.Slice(resp.Branches, func(i, j int) bool { return resp.Branches[i].Hops > resp.Branches[j].Hops })
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
+73
-21
@@ -622,9 +622,10 @@ func TestGetTraces(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestGetPacketPath covers the "View path" map data source: given a
|
||||
// packet hash, resolve its DEEPEST observation's relay path to
|
||||
// name/role/lat/lon per hop, plus the hearing observer's IATA-derived
|
||||
// position. Deliberately independent of seedTestData's fixtures.
|
||||
// packet hash, resolve every distinct station's OWN deepest observation
|
||||
// (not just the single farthest one overall) to a branch of
|
||||
// name/role/lat/lon per hop, plus that station's IATA-derived position.
|
||||
// Deliberately independent of seedTestData's fixtures.
|
||||
func TestGetPacketPath(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
@@ -640,7 +641,7 @@ func TestGetPacketPath(t *testing.T) {
|
||||
// Shallow observation (obs1): 1 hop.
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
|
||||
VALUES (1, 1, 9.0, -88, '["aa"]', '["pkAlpha"]', 1736935200)`)
|
||||
// Deeper observation (obs2): 2 hops -- must win even though it's not first.
|
||||
// Deeper observation (obs2): 2 hops.
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
|
||||
VALUES (1, 2, 4.0, -95, '["aa","bb"]', '["pkAlpha","pkBravo"]', 1736935260)`)
|
||||
|
||||
@@ -648,26 +649,37 @@ func TestGetPacketPath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Hops != 2 {
|
||||
t.Fatalf("Hops = %d, want 2 (the deeper observation)", resp.Hops)
|
||||
if len(resp.Branches) != 2 {
|
||||
t.Fatalf("Branches = %+v, want 2 (one per distinct observer)", resp.Branches)
|
||||
}
|
||||
if len(resp.Points) != 2 {
|
||||
t.Fatalf("Points = %+v, want 2 entries", resp.Points)
|
||||
// Sorted deepest-first: obs2's 2-hop branch, then obs1's 1-hop branch.
|
||||
deep, shallow := resp.Branches[0], resp.Branches[1]
|
||||
if deep.Hops != 2 {
|
||||
t.Fatalf("Branches[0].Hops = %d, want 2 (the deeper branch first)", deep.Hops)
|
||||
}
|
||||
if resp.Points[0].Name != "RepeaterAlpha" || resp.Points[0].Lat == nil || *resp.Points[0].Lat != 56.1 {
|
||||
t.Errorf("Points[0] = %+v, want RepeaterAlpha at lat 56.1", resp.Points[0])
|
||||
if len(deep.Points) != 2 {
|
||||
t.Fatalf("Branches[0].Points = %+v, want 2 entries", deep.Points)
|
||||
}
|
||||
if resp.Points[1].PublicKey != "pkBravo" || resp.Points[1].Name != "pkBravo" || resp.Points[1].Lat != nil {
|
||||
t.Errorf("Points[1] = %+v, want raw pubkey fallback with nil lat (no nodes row)", resp.Points[1])
|
||||
if deep.Points[0].Name != "RepeaterAlpha" || deep.Points[0].Lat == nil || *deep.Points[0].Lat != 56.1 {
|
||||
t.Errorf("Branches[0].Points[0] = %+v, want RepeaterAlpha at lat 56.1", deep.Points[0])
|
||||
}
|
||||
if resp.Observer == nil || resp.Observer.Name != "Observer Two" {
|
||||
t.Fatalf("Observer = %+v, want Observer Two (heard the deeper observation)", resp.Observer)
|
||||
if deep.Points[1].PublicKey != "pkBravo" || deep.Points[1].Name != "pkBravo" || deep.Points[1].Lat != nil {
|
||||
t.Errorf("Branches[0].Points[1] = %+v, want raw pubkey fallback with nil lat (no nodes row)", deep.Points[1])
|
||||
}
|
||||
if resp.Observer.Lat == nil || *resp.Observer.Lat != 37.6213 {
|
||||
t.Errorf("Observer.Lat = %v, want the SFO IATA coordinate (37.6213)", resp.Observer.Lat)
|
||||
if deep.Observer == nil || deep.Observer.Name != "Observer Two" {
|
||||
t.Fatalf("Branches[0].Observer = %+v, want Observer Two", deep.Observer)
|
||||
}
|
||||
if deep.Observer.Lat == nil || *deep.Observer.Lat != 37.6213 {
|
||||
t.Errorf("Branches[0].Observer.Lat = %v, want the SFO IATA coordinate (37.6213)", deep.Observer.Lat)
|
||||
}
|
||||
if shallow.Hops != 1 || shallow.Observer == nil || shallow.Observer.Name != "Observer One" {
|
||||
t.Fatalf("Branches[1] = %+v, want Observer One's 1-hop branch", shallow)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPacketPath_NoResolvedPath covers a station whose observation
|
||||
// never resolved: it still contributes a branch (hop count from
|
||||
// path_json, so reach is never silently dropped), just with no points.
|
||||
func TestGetPacketPath_NoResolvedPath(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
@@ -683,11 +695,51 @@ func TestGetPacketPath_NoResolvedPath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.Points) != 0 {
|
||||
t.Errorf("Points = %+v, want empty when no observation has a resolved_path", resp.Points)
|
||||
if len(resp.Branches) != 1 {
|
||||
t.Fatalf("Branches = %+v, want 1 branch even though its path never resolved", resp.Branches)
|
||||
}
|
||||
if resp.Observer != nil {
|
||||
t.Errorf("Observer = %+v, want nil when there's no resolved path", resp.Observer)
|
||||
b := resp.Branches[0]
|
||||
if b.Hops != 1 {
|
||||
t.Errorf("Branches[0].Hops = %d, want 1 (from path_json, independent of resolution)", b.Hops)
|
||||
}
|
||||
if len(b.Points) != 0 {
|
||||
t.Errorf("Branches[0].Points = %+v, want empty when the path never resolved", b.Points)
|
||||
}
|
||||
if b.Observer == nil || b.Observer.Name != "Observer One" {
|
||||
t.Errorf("Branches[0].Observer = %+v, want Observer One (who heard it is always known)", b.Observer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPacketPath_SameObserverMultipleObservations covers a station
|
||||
// that heard the same packet more than once as later flood copies
|
||||
// arrived via longer routes (the common case -- see the trace dump for
|
||||
// any busy channel): only its single deepest observation should
|
||||
// contribute a branch, not one branch per observation.
|
||||
func TestGetPacketPath_SameObserverMultipleObservations(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
|
||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkAlpha', 'RepeaterAlpha', 'repeater', 56.1, 10.2)`)
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('AA', 'pathtest00000003', '2026-01-15T10:00:00Z', 1, 5,
|
||||
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
|
||||
// Direct copy arrives first (0 hops)...
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 9.0, -88, '[]', 1736935200)`)
|
||||
// ...then a relayed copy arrives later, 1 hop.
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
|
||||
VALUES (1, 1, 6.0, -100, '["aa"]', '["pkAlpha"]', 1736935260)`)
|
||||
|
||||
resp, err := db.GetPacketPath("pathtest00000003")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.Branches) != 1 {
|
||||
t.Fatalf("Branches = %+v, want exactly 1 (same station, keep only its deepest observation)", resp.Branches)
|
||||
}
|
||||
if resp.Branches[0].Hops != 1 || len(resp.Branches[0].Points) != 1 {
|
||||
t.Errorf("Branches[0] = %+v, want the 1-hop relayed observation, not the 0-hop direct one", resp.Branches[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,7 +751,7 @@ func TestGetPacketPath_UnknownHash(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Hops != 0 || len(resp.Points) != 0 {
|
||||
if len(resp.Branches) != 0 {
|
||||
t.Errorf("expected an empty response for an unknown hash, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-5
@@ -141,7 +141,7 @@ func routeDescriptions() map[string]routeMeta {
|
||||
// Misc
|
||||
"GET /api/resolve-hops": {Summary: "Resolve hop path", Description: "Resolves hash prefixes in a hop path to node names. Returns affinity scores and best candidates.", Tag: "nodes", QueryParams: []paramMeta{{Name: "hops", Description: "Comma-separated hop hash prefixes", Type: "string", Required: true}}},
|
||||
"GET /api/traces/{hash}": {Summary: "Get packet traces", Description: "Returns all observer sightings for a packet hash.", Tag: "packets"},
|
||||
"GET /api/packets/{hash}/path": {Summary: "Get a packet's geographic relay path", Description: "Resolves a packet's DEEPEST observation (the one with the most hops -- same reasoning as the ping-bot reply, issue tracker: when the same flood is heard by more than one station, the farthest-along leg is the more informative one to show) to a point sequence: each relay's name/role/lat/lon in path order, plus the hearing observer's position (from its configured IATA code, like the Wardriving tab). Lat/lon are null for any hop that has never advertised a GPS position -- callers should draw a gap, not guess. Backs the Channels tab's ping-bot \"View path\" map link.", Tag: "packets",
|
||||
"GET /api/packets/{hash}/path": {Summary: "Get a packet's full geographic flood spread", Description: "Resolves EVERY distinct station that observed a packet to its own branch: hop count (from that station's deepest observation) plus, where resolvable, each relay's name/role/lat/lon in path order and the station's own position (from its configured IATA code, like the Wardriving tab). A station heard more than once (later flood copies via longer routes) contributes only its deepest observation. Lat/lon are null for any hop that has never advertised a GPS position -- callers should draw a gap, not guess. Backs the Channels tab's ping-bot \"View path\" map link.", Tag: "packets",
|
||||
Response: schemaRef("PacketPathResponse")},
|
||||
"GET /api/iata-coords": {Summary: "Get IATA airport coordinates", Description: "Returns lat/lon for known airport codes (used for observer positioning).", Tag: "config"},
|
||||
"GET /api/audio-lab/buckets": {Summary: "Audio lab frequency buckets", Description: "Returns frequency bucket data for audio analysis.", Tag: "analytics"},
|
||||
@@ -346,7 +346,7 @@ func componentSchemas() map[string]interface{} {
|
||||
},
|
||||
"PacketPathObserver": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "The station that produced the deepest observation of a packet path, positioned from its configured IATA code.",
|
||||
"description": "The station that produced a given branch's observation of a packet path, positioned from its configured IATA code.",
|
||||
"properties": map[string]interface{}{
|
||||
"name": str("Observer display name."),
|
||||
"iata": str("Observer's configured IATA airport code, when set."),
|
||||
@@ -354,13 +354,21 @@ func componentSchemas() map[string]interface{} {
|
||||
"lon": map[string]interface{}{"type": "number", "nullable": true},
|
||||
},
|
||||
},
|
||||
"PacketPathBranch": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "One station's own route to a packet: how far it traveled to reach them (from that observation's raw hop count, independent of how much of it resolved) and, where resolvable, each hop's position in path order.",
|
||||
"properties": map[string]interface{}{
|
||||
"hops": map[string]interface{}{"type": "integer", "description": "Hop count for this station's deepest observation, taken from the raw path length -- present even when none of it resolved."},
|
||||
"points": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathPoint"), "description": "The resolvable portion of the relay path in hop order. Can be shorter than hops, or empty, when some/all hops never resolved."},
|
||||
"observer": schemaRef("PacketPathObserver"),
|
||||
"snr": map[string]interface{}{"type": "number", "nullable": true, "description": "SNR of this station's deepest observation."},
|
||||
},
|
||||
},
|
||||
"PacketPathResponse": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"hash": str("The packet hash this path was resolved for."),
|
||||
"hops": map[string]interface{}{"type": "integer", "description": "Length of the deepest observed relay path."},
|
||||
"points": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathPoint"), "description": "The relay path in hop order."},
|
||||
"observer": schemaRef("PacketPathObserver"),
|
||||
"branches": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathBranch"), "description": "One branch per distinct station that observed the packet, each kept at that station's own deepest observation, sorted deepest-first -- shows the full flood spread, not just the single farthest route."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3156,7 +3156,7 @@ func (s *Server) handleTraces(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handlePacketPath(w http.ResponseWriter, r *http.Request) {
|
||||
hash := mux.Vars(r)["hash"]
|
||||
if s.db == nil {
|
||||
writeJSON(w, PacketPathResponse{Hash: hash, Points: []PacketPathPoint{}})
|
||||
writeJSON(w, PacketPathResponse{Hash: hash, Branches: []PacketPathBranch{}})
|
||||
return
|
||||
}
|
||||
resp, err := s.db.GetPacketPath(hash)
|
||||
|
||||
+5
-4
@@ -2334,10 +2334,11 @@
|
||||
// "Not sent to the mesh" caveat is load-bearing, not decoration --
|
||||
// without it this could be misread as a real bot reply the sender's
|
||||
// own radio received.
|
||||
// "View path" only makes sense when there's an actual multi-hop
|
||||
// route to draw (hops > 0) and we have a packet hash to look it up
|
||||
// by -- a direct (0-hop) reply has no relay path to show on a map.
|
||||
const viewPathHtml = (msg.botReply && msg.botReply.hops > 0 && msg.packetHash)
|
||||
// "View path" needs a packet hash to look up. Shown even for a
|
||||
// 0-hop reply -- the map now plots every station that heard the
|
||||
// packet (not just the deepest relay chain), so a direct-only ping
|
||||
// still has a spread worth visualizing (see GetPacketPath).
|
||||
const viewPathHtml = (msg.botReply && msg.packetHash)
|
||||
? ` · <button type="button" class="ch-analyze-link" data-view-path="${escapeHtml(msg.packetHash)}" style="background:none;border:none;padding:0;cursor:pointer;font:inherit">View path →</button>`
|
||||
: '';
|
||||
const botReplyHtml = msg.botReply ? `<div class="ch-msg ch-message ch-bot-message">
|
||||
|
||||
+77
-40
@@ -1,16 +1,18 @@
|
||||
/* window.PacketPathMap.open(hash) — on-demand modal showing a packet's
|
||||
resolved relay path (see GET /api/packets/{hash}/path, cmd/server/db.go
|
||||
GetPacketPath) as a sequential Leaflet map: each hop plotted in path
|
||||
order and connected by a line, ending at the observer that produced
|
||||
the deepest observation. Reuses node-reach-map.js's Leaflet setup
|
||||
conventions (tile helper, circleMarker points, theme-aware colors) but
|
||||
draws an ORDERED CHAIN instead of a star, since a relay path is a
|
||||
sequence, not a hub-and-spoke.
|
||||
/* window.PacketPathMap.open(hash) — on-demand modal showing every branch
|
||||
of a packet's flood spread (see GET /api/packets/{hash}/path,
|
||||
cmd/server/db.go GetPacketPath) as a Leaflet map: one branch per
|
||||
distinct station that observed the packet, each drawn as its own
|
||||
ordered relay chain (or, when no hop resolved, just that station's own
|
||||
position) ending at that station. The deepest branch is drawn on top
|
||||
in the accent color; every other branch is drawn muted underneath it,
|
||||
so the map reads as "how far AND how wide did this packet spread"
|
||||
rather than a single route. Reuses node-reach-map.js's Leaflet setup
|
||||
conventions (tile helper, circleMarker points, theme-aware colors).
|
||||
|
||||
Entry point today: the ping-bot reply's "View path" link
|
||||
(public/channels.js botReplyHtml) -- kept general (keyed by packet
|
||||
hash, not ping-specific) since any packet with a resolved path could
|
||||
use the same view later. */
|
||||
hash, not ping-specific) since any packet with observations could use
|
||||
the same view later. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -35,6 +37,25 @@
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
}
|
||||
|
||||
// Turns one branch into a plottable chain: resolved hops with a known
|
||||
// position, then the observer's own position when known. A branch with
|
||||
// no locatable hops still contributes a single-point chain -- just the
|
||||
// observer -- so a station we can't trace a route through is still
|
||||
// visible on the map rather than silently dropped.
|
||||
function chainForBranch(b) {
|
||||
var located = (b.points || []).filter(function (p) { return p.lat != null && p.lon != null; });
|
||||
var chain = located.map(function (p, hi) {
|
||||
return { lat: p.lat, lon: p.lon, name: p.name, label: 'hop ' + (hi + 1) + ' of ' + b.hops };
|
||||
});
|
||||
if (b.observer && b.observer.lat != null && b.observer.lon != null) {
|
||||
chain.push({
|
||||
lat: b.observer.lat, lon: b.observer.lon, name: b.observer.name,
|
||||
label: b.hops + ' hop' + (b.hops === 1 ? '' : 's'), isObserver: true,
|
||||
});
|
||||
}
|
||||
return { chain: chain, missing: (b.points || []).length - located.length };
|
||||
}
|
||||
|
||||
async function open(hash) {
|
||||
close(); // in case one's already open
|
||||
|
||||
@@ -46,7 +67,7 @@
|
||||
'<button type="button" id="packetPathClose" aria-label="Close" ' +
|
||||
'style="position:absolute;top:8px;right:8px;background:none;border:none;cursor:pointer;font-size:22px;line-height:1;color:var(--text-muted)">×</button>' +
|
||||
'<h3 style="margin:0 0 4px;padding-right:24px">Relay Path</h3>' +
|
||||
'<p class="text-muted" style="margin:0 0 10px;font-size:12px">How far this packet traveled before reaching the farthest-along observer. Hops without a known GPS position are omitted from the line.</p>' +
|
||||
'<p class="text-muted" style="margin:0 0 10px;font-size:12px">How far and how wide this packet spread. The highlighted route is the farthest-traveled branch; every other station that heard it is shown too.</p>' +
|
||||
'<div id="packetPathMapContainer" style="height:360px;border-radius:8px;overflow:hidden;background:var(--surface-1)"></div>' +
|
||||
'<div id="packetPathStatus" style="margin-top:8px;font-size:12px;color:var(--text-muted)">Loading…</div>' +
|
||||
'</div>';
|
||||
@@ -66,16 +87,22 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var allHops = data.points || [];
|
||||
var located = allHops.filter(function (p) { return p.lat != null && p.lon != null; });
|
||||
var missing = allHops.length - located.length;
|
||||
var hasObserver = !!(data.observer && data.observer.lat != null && data.observer.lon != null);
|
||||
var branches = data.branches || [];
|
||||
var plotted = branches.map(function (b, i) {
|
||||
var built = chainForBranch(b);
|
||||
return { branch: b, chain: built.chain, missing: built.missing, primary: i === 0 };
|
||||
}).filter(function (p) { return p.chain.length > 0; });
|
||||
|
||||
if (located.length === 0 && !hasObserver) {
|
||||
if (plotted.length === 0) {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = data.hops > 0
|
||||
? 'None of the ' + data.hops + ' hop' + (data.hops === 1 ? '' : 's') + ' in this path have a known position yet.'
|
||||
: 'This packet has no resolved relay path yet.';
|
||||
if (branches.length === 0) {
|
||||
statusEl.textContent = 'This packet has no observations yet.';
|
||||
} else {
|
||||
var deepestUnplottable = branches[0].hops;
|
||||
statusEl.textContent = 'None of the ' + branches.length + ' station' + (branches.length === 1 ? '' : 's') +
|
||||
' that heard this packet have a known position yet (farthest reached ' +
|
||||
deepestUnplottable + ' hop' + (deepestUnplottable === 1 ? '' : 's') + ').';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -85,14 +112,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var chain = located.map(function (p, i) {
|
||||
return { lat: p.lat, lon: p.lon, name: p.name, label: 'hop ' + (i + 1) + ' of ' + data.hops };
|
||||
});
|
||||
if (hasObserver) {
|
||||
chain.push({ lat: data.observer.lat, lon: data.observer.lon, name: data.observer.name, label: 'observer', isObserver: true });
|
||||
}
|
||||
|
||||
var center = chain[Math.floor(chain.length / 2)];
|
||||
var primaryChain = plotted[0].chain;
|
||||
var center = primaryChain[Math.floor(primaryChain.length / 2)];
|
||||
var map = L.map('packetPathMapContainer', { zoomControl: true, attributionControl: false })
|
||||
.setView([center.lat, center.lon], 10);
|
||||
if (typeof window._applyTilesToNodeMap === 'function') {
|
||||
@@ -104,26 +125,42 @@
|
||||
var outline = cssVar('--surface-0');
|
||||
var accent = cssVar('--accent');
|
||||
var observerColor = cssVar('--status-yellow');
|
||||
var muted = cssVar('--text-muted');
|
||||
|
||||
var bounds = [];
|
||||
var line = [];
|
||||
chain.forEach(function (p) {
|
||||
bounds.push([p.lat, p.lon]);
|
||||
line.push([p.lat, p.lon]);
|
||||
var color = p.isObserver ? observerColor : accent;
|
||||
L.circleMarker([p.lat, p.lon], { radius: p.isObserver ? 7 : 6, color: outline, weight: 2, fillColor: color, fillOpacity: 1 })
|
||||
.addTo(map)
|
||||
.bindTooltip(escapeHtml(p.name) + ' (' + p.label + ')');
|
||||
var missingTotal = 0;
|
||||
// Draw secondary branches first so the primary (deepest) one ends up on top.
|
||||
var ordered = plotted.slice().sort(function (a, b) { return (a.primary ? 1 : 0) - (b.primary ? 1 : 0); });
|
||||
ordered.forEach(function (p) {
|
||||
missingTotal += p.missing;
|
||||
var lineColor = p.primary ? accent : muted;
|
||||
var line = [];
|
||||
p.chain.forEach(function (pt) {
|
||||
bounds.push([pt.lat, pt.lon]);
|
||||
line.push([pt.lat, pt.lon]);
|
||||
var color = pt.isObserver ? observerColor : lineColor;
|
||||
var radius = p.primary ? (pt.isObserver ? 7 : 6) : (pt.isObserver ? 5 : 4);
|
||||
L.circleMarker([pt.lat, pt.lon], {
|
||||
radius: radius, color: outline, weight: p.primary ? 2 : 1,
|
||||
fillColor: color, fillOpacity: p.primary ? 1 : 0.8,
|
||||
})
|
||||
.addTo(map)
|
||||
.bindTooltip(escapeHtml(pt.name) + ' (' + pt.label + ')');
|
||||
});
|
||||
if (line.length > 1) {
|
||||
L.polyline(line, { color: lineColor, weight: p.primary ? 2.5 : 1.5, opacity: p.primary ? 0.85 : 0.5 }).addTo(map);
|
||||
}
|
||||
});
|
||||
if (line.length > 1) {
|
||||
L.polyline(line, { color: accent, weight: 2.5, opacity: 0.85 }).addTo(map);
|
||||
}
|
||||
try { map.fitBounds(bounds, { padding: [30, 30] }); } catch (e) { /* single point */ }
|
||||
setTimeout(function () { map.invalidateSize(); }, 120);
|
||||
activeMap = map;
|
||||
|
||||
var statusParts = [data.hops + ' hop' + (data.hops === 1 ? '' : 's') + ' total'];
|
||||
if (missing > 0) statusParts.push(missing + ' without a known position (not shown)');
|
||||
var deepestHops = branches[0].hops;
|
||||
var statusParts = [
|
||||
plotted.length + ' of ' + branches.length + ' station' + (branches.length === 1 ? '' : 's') + ' shown',
|
||||
'deepest reached ' + deepestHops + ' hop' + (deepestHops === 1 ? '' : 's'),
|
||||
];
|
||||
if (missingTotal > 0) statusParts.push(missingTotal + ' hop' + (missingTotal === 1 ? '' : 's') + ' without a known position (not shown)');
|
||||
if (statusEl) statusEl.textContent = statusParts.join(' · ');
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ test('a message with botReply renders a distinct bot bubble with the reply text'
|
||||
assert.ok(html.includes('SNR 8.2dB'), 'should include the SNR from the reply text');
|
||||
});
|
||||
|
||||
test('"View path" link appears when hops > 0 and a packetHash is available', () => {
|
||||
test('"View path" link appears when a packetHash is available', () => {
|
||||
const { ctx, chMessagesEl } = makeSandbox();
|
||||
ctx.window._channelsSetStateForTest({ messages: [
|
||||
{
|
||||
@@ -176,7 +176,7 @@ test('"View path" link appears when hops > 0 and a packetHash is available', ()
|
||||
assert.ok(html.includes('data-view-path="abc123"'), 'should carry the packet hash for the click handler to look up');
|
||||
});
|
||||
|
||||
test('"View path" link is absent for a direct (0-hop) reply -- nothing to draw', () => {
|
||||
test('"View path" link still appears for a direct (0-hop) reply -- the map plots every station that heard it, not just relay hops', () => {
|
||||
const { ctx, chMessagesEl } = makeSandbox();
|
||||
ctx.window._channelsSetStateForTest({ messages: [
|
||||
{
|
||||
@@ -185,7 +185,7 @@ test('"View path" link is absent for a direct (0-hop) reply -- nothing to draw',
|
||||
},
|
||||
] });
|
||||
ctx.window._channelsRenderMessagesForTest();
|
||||
assert.ok(!chMessagesEl.innerHTML.includes('View path'), 'a direct reply has no relay path to visualize');
|
||||
assert.ok(chMessagesEl.innerHTML.includes('View path'), 'a direct reply still has observer positions worth visualizing');
|
||||
});
|
||||
|
||||
test('"View path" link is absent when there is no packetHash to look it up by', () => {
|
||||
|
||||
+64
-7
@@ -37,7 +37,15 @@ test('fetches via the shared api() helper, not a raw fetch (picks up auth/base-U
|
||||
});
|
||||
|
||||
test('escapes node/observer names before interpolating into tooltip HTML (operator-controlled data)', () => {
|
||||
assert.ok(/escapeHtml\(p\.name\)/.test(src), 'point tooltips must escape the name');
|
||||
assert.ok(/escapeHtml\(pt\.name\)/.test(src), 'point tooltips must escape the name');
|
||||
});
|
||||
|
||||
test('draws every branch, not just the deepest one', () => {
|
||||
assert.ok(/branches\.map/.test(src), 'should iterate all branches from the response');
|
||||
});
|
||||
|
||||
test('draws the deepest branch on top of the others (primary drawn last)', () => {
|
||||
assert.ok(/a\.primary \? 1 : 0/.test(src) || /primary.*sort/.test(src), 'should reorder so the primary branch paints last');
|
||||
});
|
||||
|
||||
test('handles Escape key and click-outside to close, matching other CoreScope modals', () => {
|
||||
@@ -140,21 +148,24 @@ function makeSandbox(apiImpl) {
|
||||
|
||||
await (async () => {
|
||||
try {
|
||||
const ctx = makeSandbox(() => Promise.resolve({ hash: 'deadbeef', hops: 0, points: [] }));
|
||||
const ctx = makeSandbox(() => Promise.resolve({ hash: 'deadbeef', branches: [] }));
|
||||
await ctx.window.PacketPathMap.open('deadbeef');
|
||||
const status = ctx.document.getElementById('packetPathStatus');
|
||||
assert.ok(status.textContent.includes('no resolved relay path'), 'should explain there is nothing to show yet, got: ' + status.textContent);
|
||||
assert.ok(status.textContent.includes('no observations'), 'should explain there is nothing to show yet, got: ' + status.textContent);
|
||||
passed++;
|
||||
console.log(' ✅ an empty path (hops=0, no points) shows a clear "nothing to show" status');
|
||||
} catch (e) { failed++; console.log(' ❌ an empty path (hops=0, no points) shows a clear "nothing to show" status: ' + e.message); }
|
||||
console.log(' ✅ no branches at all shows a clear "nothing to show" status');
|
||||
} catch (e) { failed++; console.log(' ❌ no branches at all shows a clear "nothing to show" status: ' + e.message); }
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
try {
|
||||
const ctx = makeSandbox(() => Promise.resolve({ hash: 'deadbeef', hops: 3, points: [{ publicKey: 'pk1', name: 'RepeaterA', lat: null, lon: null }] }));
|
||||
const ctx = makeSandbox(() => Promise.resolve({
|
||||
hash: 'deadbeef',
|
||||
branches: [{ hops: 3, points: [{ publicKey: 'pk1', name: 'RepeaterA', lat: null, lon: null }], observer: null }],
|
||||
}));
|
||||
await ctx.window.PacketPathMap.open('deadbeef');
|
||||
const status = ctx.document.getElementById('packetPathStatus');
|
||||
assert.ok(status.textContent.includes('3 hop'), 'should mention the hop count even when no hop has a known position, got: ' + status.textContent);
|
||||
assert.ok(status.textContent.includes('3 hop'), 'should mention the hop count even when no branch has a known position, got: ' + status.textContent);
|
||||
passed++;
|
||||
console.log(' ✅ hops with no known position at all still report the hop count, not a silent blank');
|
||||
} catch (e) { failed++; console.log(' ❌ hops with no known position at all still report the hop count, not a silent blank: ' + e.message); }
|
||||
@@ -172,6 +183,52 @@ function makeSandbox(apiImpl) {
|
||||
} catch (e) { failed++; console.log(' ❌ close() removes the modal overlay from the DOM: ' + e.message); }
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
try {
|
||||
// Two branches: a 2-hop chain (deepest, drawn primary) and a
|
||||
// 0-hop direct observer with no resolvable relay names at all.
|
||||
// Both should still get plotted -- this is the whole point of the
|
||||
// "show every branch" rework (a station that heard the packet
|
||||
// directly is real reach data even without a relay chain).
|
||||
const ctx = makeSandbox(() => Promise.resolve({
|
||||
hash: 'deadbeef',
|
||||
branches: [
|
||||
{
|
||||
hops: 2,
|
||||
points: [
|
||||
{ publicKey: 'pk1', name: 'RepeaterA', lat: 56.0, lon: 10.0 },
|
||||
{ publicKey: 'pk2', name: 'RepeaterB', lat: 56.1, lon: 10.1 },
|
||||
],
|
||||
observer: { name: 'FarObserver', lat: 56.2, lon: 10.2 },
|
||||
},
|
||||
{ hops: 0, points: [], observer: { name: 'NearObserver', lat: 55.9, lon: 9.9 } },
|
||||
],
|
||||
}));
|
||||
|
||||
let markerCount = 0, polylineCount = 0;
|
||||
ctx.L = {
|
||||
map: () => ({
|
||||
setView() { return this; },
|
||||
fitBounds() {},
|
||||
invalidateSize() {},
|
||||
remove() {},
|
||||
}),
|
||||
tileLayer: () => ({ addTo() { return this; } }),
|
||||
circleMarker: () => { markerCount++; return { addTo() { return this; }, bindTooltip() { return this; } }; },
|
||||
polyline: () => { polylineCount++; return { addTo() { return this; } }; },
|
||||
};
|
||||
|
||||
await ctx.window.PacketPathMap.open('deadbeef');
|
||||
const status = ctx.document.getElementById('packetPathStatus');
|
||||
assert.strictEqual(markerCount, 4, 'expected 4 markers: 2 hops + observer for branch 1, 1 observer-only point for branch 2, got ' + markerCount);
|
||||
assert.strictEqual(polylineCount, 1, 'expected exactly 1 polyline (only the 3-point branch has >1 point to connect), got ' + polylineCount);
|
||||
assert.ok(status.textContent.includes('2 of 2 stations shown'), 'status should report both branches plotted, got: ' + status.textContent);
|
||||
assert.ok(status.textContent.includes('deepest reached 2 hop'), 'status should report the deepest branch hop count, got: ' + status.textContent);
|
||||
passed++;
|
||||
console.log(' ✅ multiple branches (including a 0-hop direct observer) are all plotted, not just the deepest');
|
||||
} catch (e) { failed++; console.log(' ❌ multiple branches (including a 0-hop direct observer) are all plotted, not just the deepest: ' + e.message); }
|
||||
})();
|
||||
|
||||
console.log('\n════════════════════════════════════════');
|
||||
console.log(` packet-path-map.js: ${passed} passed, ${failed} failed`);
|
||||
console.log('════════════════════════════════════════');
|
||||
|
||||
Reference in New Issue
Block a user