mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-29 00:19:23 +00:00
feat(nodes): per-node Reach page + GET /api/nodes/{pubkey}/reach (directional link quality) (#1625)
## What
Adds a per-node **Reach** view that answers "how well does this specific
node hear, and get heard by, its neighbours?" — both as a standalone
page (`#/nodes/{pubkey}/reach`) and as a section on the node detail
page.
New endpoint: **`GET /api/nodes/{pubkey}/reach`**.
## What it measures
For the target node it derives, from raw `path_json` adjacency (a path
travels origin→observer, so in `[A,B]` B received A directly):
- **Directional link counts** per neighbour: `we_hear` (how often we
received them) vs `they_hear` (how often they received us).
- **Bidirectional / bottleneck**: a link is two-way stable when both
directions > 0; the weaker direction is the bottleneck and rates real
two-way reliability.
- **Importance**: neighbour degree + rank, relay-observation volume,
bidirectional-link count, direct-observer count.
- **Direct observers**: who received the node at 0 hops, with SNR.
Reliability rule: a neighbour is only attributed when its pubkey
**prefix is unique** at the path's byte length (collisions are skipped,
never misattributed).
## UI
- Standalone Reach page + node-detail section.
- Reusable bidirectional link map (OSM) with links coloured by
bottleneck.
- Incoming/outgoing toggles to isolate each direction.
## Naming note (deliberate, no collision)
This is distinct from the existing **per-observer reachability** in
topology analytics (`ReachNode` / `ObserverReach` / `perObserverReach`).
This PR adds its own `NodeReach*` response structs in a new
`node_reach.go` and a new `/api/nodes/{pubkey}/reach` route — there are
no symbol or route collisions (verified: `go build ./...` clean). Happy
to rename to disambiguate further (e.g. "Link Quality") if you'd prefer
to reserve "Reach" for the per-observer feature.
## Testing
- `cmd/server`: endpoint shape/404/limit-clamp + unit tests for token
derivation and directional attribution, plus a scan benchmark — all
pass.
- Frontend: helper tests + Reach-page E2E (`test-node-reach-e2e.js`),
standalone route + incoming/outgoing toggles.
- `go build ./...` and `eslint public/*.js` (no-undef) clean.
## Docs
Design spec, implementation plan, and the `GET
/api/nodes/{pubkey}/reach` API contract are included under `docs/`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
efd6464204
commit
47f85f6c4c
@@ -0,0 +1,432 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// advertPayloadType mirrors MeshCore ADVERT (0x04). Local const so this file
|
||||
// stays independent of decoder internals.
|
||||
const advertPayloadType = 4
|
||||
|
||||
// pathRow is one observation fed to attributeDirections. path tokens are
|
||||
// uppercase hex hop prefixes (as stored in observations.path_json).
|
||||
type pathRow struct {
|
||||
observerPK string // lowercase pubkey of the observer (may be "")
|
||||
fromPubkey string // lowercase originator pubkey (may be "")
|
||||
payloadType int
|
||||
path []string
|
||||
snr *float64
|
||||
}
|
||||
|
||||
type obsAgg struct {
|
||||
count int
|
||||
snrSum float64
|
||||
snrN int
|
||||
}
|
||||
|
||||
type dirCounts struct {
|
||||
we map[string]int
|
||||
they map[string]int
|
||||
obs map[string]*obsAgg
|
||||
relay int
|
||||
}
|
||||
|
||||
// attributeDirections walks each path and attributes directional evidence for
|
||||
// the target node (identified by any token in ourTokens). resolve maps a hop
|
||||
// token → a unique relay pubkey ("" when ambiguous/unknown → skipped). ourPK is
|
||||
// the target's own pubkey (lowercase) so self-edges are ignored.
|
||||
func attributeDirections(rows []pathRow, ourTokens map[string]bool, ourPK string, resolve func(string) string) dirCounts {
|
||||
d := dirCounts{we: map[string]int{}, they: map[string]int{}, obs: map[string]*obsAgg{}}
|
||||
for _, r := range rows {
|
||||
n := len(r.path)
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
hit := false
|
||||
for i, tok := range r.path {
|
||||
if !ourTokens[tok] {
|
||||
continue
|
||||
}
|
||||
hit = true
|
||||
// predecessor → we heard it
|
||||
if i > 0 {
|
||||
if pk := resolve(r.path[i-1]); pk != "" && pk != ourPK {
|
||||
d.we[pk]++
|
||||
}
|
||||
} else if r.payloadType == advertPayloadType && r.fromPubkey != "" && r.fromPubkey != ourPK {
|
||||
d.we[r.fromPubkey]++
|
||||
}
|
||||
// successor → it heard us; or if we're the last hop, the observer did
|
||||
if i < n-1 {
|
||||
if pk := resolve(r.path[i+1]); pk != "" && pk != ourPK {
|
||||
d.they[pk]++
|
||||
}
|
||||
} else if r.observerPK != "" && r.observerPK != ourPK {
|
||||
d.they[r.observerPK]++
|
||||
a := d.obs[r.observerPK]
|
||||
if a == nil {
|
||||
a = &obsAgg{}
|
||||
d.obs[r.observerPK] = a
|
||||
}
|
||||
a.count++
|
||||
if r.snr != nil {
|
||||
a.snrSum += *r.snr
|
||||
a.snrN++
|
||||
}
|
||||
}
|
||||
}
|
||||
if hit {
|
||||
d.relay++
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// reliableTokens returns the uppercase hex prefixes (1, 2, 3 byte) of pubkey
|
||||
// that are UNIQUE among relay-capable nodes in pm. 1-byte prefixes almost always
|
||||
// collide and are excluded; only unique prefixes can identify a node in a path.
|
||||
func reliableTokens(pubkey string, pm *prefixMap) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
lpk := strings.ToLower(pubkey)
|
||||
for _, l := range []int{2, 4, 6} { // hex chars = 1,2,3 bytes
|
||||
if len(lpk) < l {
|
||||
continue
|
||||
}
|
||||
p := lpk[:l]
|
||||
if pm != nil && len(pm.m[p]) == 1 {
|
||||
out[strings.ToUpper(p)] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uniqueResolve returns the single relay pubkey (lowercase) for a hop token, or
|
||||
// "" when the token resolves to zero or multiple candidates (conservative).
|
||||
func uniqueResolve(pm *prefixMap, token string) string {
|
||||
if pm == nil {
|
||||
return ""
|
||||
}
|
||||
cands := pm.m[strings.ToLower(token)]
|
||||
if len(cands) == 1 {
|
||||
return strings.ToLower(cands[0].PublicKey)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type NodeReachInfo struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lon *float64 `json:"lon"`
|
||||
FirstSeen string `json:"first_seen"`
|
||||
}
|
||||
type NodeReachWindow struct {
|
||||
Days int `json:"days"`
|
||||
Since string `json:"since"`
|
||||
}
|
||||
type NodeReachImportance struct {
|
||||
NeighborDegree int `json:"neighbor_degree"`
|
||||
DegreeRank int `json:"degree_rank"`
|
||||
NodesWithEdges int `json:"nodes_with_edges"`
|
||||
RelayObservations int `json:"relay_observations"`
|
||||
BidirectionalLinks int `json:"bidirectional_links"`
|
||||
DirectObservers int `json:"direct_observers"`
|
||||
}
|
||||
type NodeReachObserver struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
AvgSNR *float64 `json:"avg_snr"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lon *float64 `json:"lon"`
|
||||
DistanceKm *float64 `json:"distance_km"`
|
||||
}
|
||||
type NodeReachLink struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lon *float64 `json:"lon"`
|
||||
WeHear int `json:"we_hear"`
|
||||
TheyHear int `json:"they_hear"`
|
||||
Bottleneck int `json:"bottleneck"`
|
||||
Bidir bool `json:"bidir"`
|
||||
DistanceKm *float64 `json:"distance_km"`
|
||||
}
|
||||
type NodeReachResponse struct {
|
||||
Node NodeReachInfo `json:"node"`
|
||||
Window NodeReachWindow `json:"window"`
|
||||
ReliableTokens []string `json:"reliable_tokens"`
|
||||
Importance NodeReachImportance `json:"importance"`
|
||||
DirectObservers []NodeReachObserver `json:"direct_observers"`
|
||||
Links []NodeReachLink `json:"links"`
|
||||
}
|
||||
|
||||
func fptr(v float64) *float64 { return &v }
|
||||
|
||||
// gpsPtrs returns (lat,lon) pointers, nil when the node has no GPS (0,0).
|
||||
func gpsPtrs(info nodeInfo, ok bool) (*float64, *float64) {
|
||||
if !ok || !info.HasGPS {
|
||||
return nil, nil
|
||||
}
|
||||
return fptr(info.Lat), fptr(info.Lon)
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// clampDays bounds the lookback window to [1,30]; default callers pass 7.
|
||||
func clampDays(d int) int {
|
||||
if d < 1 {
|
||||
return 1
|
||||
}
|
||||
if d > 30 {
|
||||
return 30
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// --- bounded TTL cache (perf is gated by the time window; this just avoids
|
||||
// recompute under dashboard polling). Keyed "pubkey|days". ---
|
||||
const (
|
||||
reachCacheTTL = 5 * time.Minute
|
||||
reachCacheMax = 256
|
||||
)
|
||||
|
||||
type reachCacheEntry struct {
|
||||
at time.Time
|
||||
raw []byte
|
||||
}
|
||||
|
||||
var (
|
||||
reachCacheMu sync.Mutex
|
||||
reachCache = map[string]reachCacheEntry{}
|
||||
)
|
||||
|
||||
func reachCacheGet(key string) ([]byte, bool) {
|
||||
reachCacheMu.Lock()
|
||||
defer reachCacheMu.Unlock()
|
||||
e, ok := reachCache[key]
|
||||
if !ok || time.Since(e.at) > reachCacheTTL {
|
||||
return nil, false
|
||||
}
|
||||
return e.raw, true
|
||||
}
|
||||
|
||||
func reachCachePut(key string, raw []byte) {
|
||||
reachCacheMu.Lock()
|
||||
defer reachCacheMu.Unlock()
|
||||
if len(reachCache) >= reachCacheMax {
|
||||
reachCache = map[string]reachCacheEntry{} // crude bounded reset
|
||||
}
|
||||
reachCache[key] = reachCacheEntry{at: time.Now(), raw: raw}
|
||||
}
|
||||
|
||||
func (s *Server) handleNodeReach(w http.ResponseWriter, r *http.Request) {
|
||||
pubkey := strings.ToLower(mux.Vars(r)["pubkey"])
|
||||
if s.cfg != nil && s.cfg.IsBlacklisted(pubkey) {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
days := 7
|
||||
if v := r.URL.Query().Get("days"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
days = n
|
||||
}
|
||||
}
|
||||
days = clampDays(days)
|
||||
|
||||
cacheKey := pubkey + "|" + strconv.Itoa(days)
|
||||
if raw, ok := reachCacheGet(cacheKey); ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(raw)
|
||||
return
|
||||
}
|
||||
|
||||
resp, ok := s.computeNodeReach(pubkey, days)
|
||||
if !ok {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(resp)
|
||||
reachCachePut(cacheKey, raw)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(raw)
|
||||
}
|
||||
|
||||
// computeNodeReach does the read-only scan + assembly. ok=false → 404.
|
||||
func (s *Server) computeNodeReach(pubkey string, days int) (NodeReachResponse, bool) {
|
||||
if s.store == nil || s.db == nil || s.db.conn == nil {
|
||||
return NodeReachResponse{}, false
|
||||
}
|
||||
nodeMap := s.buildNodeInfoMap()
|
||||
self, found := nodeMap[pubkey]
|
||||
if !found {
|
||||
return NodeReachResponse{}, false
|
||||
}
|
||||
_, pm := s.store.getCachedNodesAndPM()
|
||||
tokens := reliableTokens(pubkey, pm)
|
||||
|
||||
since := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour)
|
||||
sinceEpoch := since.Unix()
|
||||
|
||||
var d dirCounts
|
||||
if len(tokens) > 0 {
|
||||
rows := s.scanReachRows(tokens, sinceEpoch)
|
||||
d = attributeDirections(rows, tokens, pubkey, func(tok string) string {
|
||||
return uniqueResolve(pm, tok)
|
||||
})
|
||||
} else {
|
||||
d = dirCounts{we: map[string]int{}, they: map[string]int{}, obs: map[string]*obsAgg{}}
|
||||
}
|
||||
|
||||
// importance: neighbor_edges degree + rank (all-time)
|
||||
var degree, rank, nodesWithEdges int
|
||||
s.db.conn.QueryRow(`
|
||||
WITH dd AS (SELECT node_a pk, count c FROM neighbor_edges
|
||||
UNION ALL SELECT node_b, count FROM neighbor_edges),
|
||||
aa AS (SELECT pk, COUNT(*) neigh FROM dd GROUP BY pk)
|
||||
SELECT (SELECT COUNT(*) FROM aa),
|
||||
COALESCE((SELECT neigh FROM aa WHERE pk=?),0),
|
||||
(SELECT 1+COUNT(*) FROM aa WHERE neigh > COALESCE((SELECT neigh FROM aa WHERE pk=?),0))
|
||||
`, pubkey, pubkey).Scan(&nodesWithEdges, °ree, &rank)
|
||||
|
||||
// node first_seen (nodeInfo only carries last_seen; the contract wants first_seen)
|
||||
var firstSeen sql.NullString
|
||||
s.db.conn.QueryRow(`SELECT first_seen FROM nodes WHERE LOWER(public_key)=?`, pubkey).Scan(&firstSeen)
|
||||
|
||||
// assemble links
|
||||
links := []NodeReachLink{}
|
||||
bidir := 0
|
||||
seen := map[string]bool{}
|
||||
for pk := range d.we {
|
||||
seen[pk] = true
|
||||
}
|
||||
for pk := range d.they {
|
||||
seen[pk] = true
|
||||
}
|
||||
for pk := range seen {
|
||||
we, they := d.we[pk], d.they[pk]
|
||||
info := nodeMap[pk]
|
||||
lat, lon := gpsPtrs(info, true)
|
||||
var dist *float64
|
||||
if self.HasGPS && info.HasGPS {
|
||||
dist = fptr(haversineKm(self.Lat, self.Lon, info.Lat, info.Lon))
|
||||
}
|
||||
b := we > 0 && they > 0
|
||||
if b {
|
||||
bidir++
|
||||
}
|
||||
links = append(links, NodeReachLink{
|
||||
Pubkey: pk, Name: info.Name, Role: info.Role, Lat: lat, Lon: lon,
|
||||
WeHear: we, TheyHear: they, Bottleneck: minInt(we, they), Bidir: b, DistanceKm: dist,
|
||||
})
|
||||
}
|
||||
sort.Slice(links, func(i, j int) bool {
|
||||
if links[i].Bidir != links[j].Bidir {
|
||||
return links[i].Bidir
|
||||
}
|
||||
if links[i].Bottleneck != links[j].Bottleneck {
|
||||
return links[i].Bottleneck > links[j].Bottleneck
|
||||
}
|
||||
return links[i].WeHear+links[i].TheyHear > links[j].WeHear+links[j].TheyHear
|
||||
})
|
||||
|
||||
// direct observers
|
||||
directObs := []NodeReachObserver{}
|
||||
for pk, a := range d.obs {
|
||||
info := nodeMap[pk]
|
||||
lat, lon := gpsPtrs(info, true)
|
||||
var avg, dist *float64
|
||||
if a.snrN > 0 {
|
||||
avg = fptr(a.snrSum / float64(a.snrN))
|
||||
}
|
||||
if self.HasGPS && info.HasGPS {
|
||||
dist = fptr(haversineKm(self.Lat, self.Lon, info.Lat, info.Lon))
|
||||
}
|
||||
directObs = append(directObs, NodeReachObserver{
|
||||
Pubkey: pk, Name: info.Name, Count: a.count, AvgSNR: avg, Lat: lat, Lon: lon, DistanceKm: dist,
|
||||
})
|
||||
}
|
||||
sort.Slice(directObs, func(i, j int) bool { return directObs[i].Count > directObs[j].Count })
|
||||
|
||||
toks := make([]string, 0, len(tokens))
|
||||
for t := range tokens {
|
||||
toks = append(toks, t)
|
||||
}
|
||||
sort.Strings(toks)
|
||||
|
||||
selfLat, selfLon := gpsPtrs(self, true)
|
||||
return NodeReachResponse{
|
||||
Node: NodeReachInfo{Pubkey: pubkey, Name: self.Name, Role: self.Role,
|
||||
Lat: selfLat, Lon: selfLon, FirstSeen: firstSeen.String},
|
||||
Window: NodeReachWindow{Days: days, Since: since.Format(time.RFC3339)},
|
||||
ReliableTokens: toks,
|
||||
Importance: NodeReachImportance{
|
||||
NeighborDegree: degree, DegreeRank: rank, NodesWithEdges: nodesWithEdges,
|
||||
RelayObservations: d.relay, BidirectionalLinks: bidir, DirectObservers: len(directObs),
|
||||
},
|
||||
DirectObservers: directObs,
|
||||
Links: links,
|
||||
}, true
|
||||
}
|
||||
|
||||
// scanReachRows reads windowed observations whose path contains any reliable
|
||||
// token, with the originator + observer + snr needed for attribution.
|
||||
func (s *Server) scanReachRows(tokens map[string]bool, sinceEpoch int64) []pathRow {
|
||||
likes := make([]string, 0, len(tokens))
|
||||
args := []interface{}{sinceEpoch}
|
||||
for tok := range tokens {
|
||||
likes = append(likes, "o.path_json LIKE ?")
|
||||
args = append(args, "%\""+tok+"\"%")
|
||||
}
|
||||
q := `SELECT COALESCE(obs.id,''), COALESCE(t.from_pubkey,''), COALESCE(t.payload_type,0), o.path_json, 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 o.timestamp >= ? AND (` + strings.Join(likes, " OR ") + `)`
|
||||
rows, err := s.db.conn.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []pathRow
|
||||
for rows.Next() {
|
||||
var oid, fpk, pj string
|
||||
var pt int
|
||||
var snr sql.NullFloat64
|
||||
if err := rows.Scan(&oid, &fpk, &pt, &pj, &snr); err != nil {
|
||||
continue
|
||||
}
|
||||
var raw []string
|
||||
if json.Unmarshal([]byte(pj), &raw) != nil || len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
path := make([]string, len(raw))
|
||||
for i, h := range raw {
|
||||
path[i] = strings.ToUpper(h)
|
||||
}
|
||||
pr := pathRow{observerPK: strings.ToLower(oid), fromPubkey: strings.ToLower(fpk),
|
||||
payloadType: pt, path: path}
|
||||
if snr.Valid {
|
||||
v := snr.Float64
|
||||
pr.snr = &v
|
||||
}
|
||||
out = append(out, pr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// benchReachDB builds an in-memory DB with nObs observations whose path
|
||||
// contains the "01FA" token, for benchmarking scanReachRows.
|
||||
func benchReachDB(b *testing.B, nObs int) *DB {
|
||||
b.Helper()
|
||||
conn, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
schema := []string{
|
||||
`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT, first_seen TEXT, payload_type INTEGER, from_pubkey TEXT)`,
|
||||
`CREATE TABLE observers (id TEXT PRIMARY KEY, name TEXT)`,
|
||||
`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_idx INTEGER, snr REAL, path_json TEXT, timestamp INTEGER)`,
|
||||
`CREATE INDEX idx_obs_ts ON observations(timestamp)`,
|
||||
}
|
||||
for _, s := range schema {
|
||||
if _, err := conn.Exec(s); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
tx, _ := conn.Begin()
|
||||
tx.Exec(`INSERT INTO observers (id, name) VALUES ('OBS', 'o')`)
|
||||
for i := 0; i < nObs; i++ {
|
||||
tx.Exec(`INSERT INTO transmissions (id, hash, first_seen, payload_type, from_pubkey) VALUES (?,?,?,5,'')`,
|
||||
i, fmt.Sprintf("h%d", i), "2026-06-07T00:00:00Z")
|
||||
tx.Exec(`INSERT INTO observations (id, transmission_id, observer_idx, snr, path_json, timestamp) VALUES (?,?,1,-7.0,?,?)`,
|
||||
i, i, `["AA","01FA","BB"]`, 1000)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
return &DB{conn: conn}
|
||||
}
|
||||
|
||||
func BenchmarkNodeReachScan(b *testing.B) {
|
||||
db := benchReachDB(b, 5000)
|
||||
srv := &Server{db: db}
|
||||
tokens := map[string]bool{"01FA": true}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
rows := srv.scanReachRows(tokens, 0)
|
||||
if len(rows) == 0 {
|
||||
b.Fatal("expected rows")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func serveReach(srv *Server, path string) *httptest.ResponseRecorder {
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/api/nodes/{pubkey}/reach", srv.handleNodeReach).Methods("GET")
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestClampDays(t *testing.T) {
|
||||
cases := []struct{ in, want int }{{0, 1}, {-5, 1}, {1, 1}, {7, 7}, {30, 30}, {31, 30}, {999, 30}}
|
||||
for _, c := range cases {
|
||||
if got := clampDays(c.in); got != c.want {
|
||||
t.Errorf("clampDays(%d)=%d want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeReach_UnknownNode(t *testing.T) {
|
||||
srv := makeTestServer(makeTestGraph()) // no store/db wired → 404
|
||||
rr := serveReach(srv, "/api/nodes/deadbeef/reach")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeReach_ShapeAndClamp(t *testing.T) {
|
||||
db := setupTestDBv2(t)
|
||||
const pk = "01fa326b475800a31105abcb9e4cac000b3e5d9e2b5ba0739981ce8d5f3a6754"
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES ('`+pk+`', 'BE-Test', 'repeater', 50.9, 5.4, '2026-06-07T00:00:00Z', '2026-06-01T00:00:00Z', 3)`)
|
||||
|
||||
cfg := &Config{}
|
||||
srv := &Server{store: newTestStoreWithDB(t, db, cfg), db: db, cfg: cfg, perfStats: NewPerfStats()}
|
||||
|
||||
rr := serveReach(srv, "/api/nodes/"+pk+"/reach?days=999")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d want 200 (body=%s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp NodeReachResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("bad json: %v", err)
|
||||
}
|
||||
if resp.Window.Days != 30 {
|
||||
t.Fatalf("days not clamped to 30: %d", resp.Window.Days)
|
||||
}
|
||||
if resp.Links == nil || resp.DirectObservers == nil || resp.ReliableTokens == nil {
|
||||
t.Fatalf("array fields must be non-nil (never null)")
|
||||
}
|
||||
if !contains(resp.ReliableTokens, "01FA") {
|
||||
t.Fatalf("expected 01FA reliable token, got %v", resp.ReliableTokens)
|
||||
}
|
||||
if resp.Node.FirstSeen != "2026-06-01T00:00:00Z" {
|
||||
t.Fatalf("first_seen not sourced from nodes table: %q", resp.Node.FirstSeen)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// resolver that only resolves the exact tokens it's told are unique.
|
||||
func testResolver(unique map[string]string) func(string) string {
|
||||
return func(tok string) string {
|
||||
if pk, ok := unique[tok]; ok {
|
||||
return pk
|
||||
}
|
||||
return "" // ambiguous / unknown → skip
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttributeDirections_PredecessorAndSuccessor(t *testing.T) {
|
||||
// path A(aa) -> N(01fa) -> B(bb): we hear A, B hears us.
|
||||
unique := map[string]string{"AA": "aa00", "BB": "bb00"}
|
||||
rows := []pathRow{{
|
||||
observerPK: "obs1", payloadType: 5,
|
||||
path: []string{"AA", "01FA", "BB"},
|
||||
}}
|
||||
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b", testResolver(unique))
|
||||
if d.we["aa00"] != 1 {
|
||||
t.Fatalf("we_hear[aa00]=%d want 1", d.we["aa00"])
|
||||
}
|
||||
if d.they["bb00"] != 1 {
|
||||
t.Fatalf("they_hear[bb00]=%d want 1", d.they["bb00"])
|
||||
}
|
||||
if d.relay != 1 {
|
||||
t.Fatalf("relay=%d want 1", d.relay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttributeDirections_LastHopObserverAndAdvertFirstHop(t *testing.T) {
|
||||
snr := 4.0
|
||||
rows := []pathRow{
|
||||
// N is last hop → observer heard us directly (+snr).
|
||||
{observerPK: "obsx", payloadType: 5, path: []string{"AA", "01FA"}, snr: &snr},
|
||||
// N is first hop of an ADVERT (type 4) → we heard the originator.
|
||||
{observerPK: "obsy", payloadType: 4, fromPubkey: "origin1", path: []string{"01FA", "CC"}},
|
||||
}
|
||||
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b",
|
||||
testResolver(map[string]string{"CC": "cc00"}))
|
||||
if d.obs["obsx"] == nil || d.obs["obsx"].count != 1 {
|
||||
t.Fatalf("observer obsx not counted")
|
||||
}
|
||||
if d.obs["obsx"].snrN != 1 || d.obs["obsx"].snrSum != 4.0 {
|
||||
t.Fatalf("observer snr not aggregated")
|
||||
}
|
||||
if d.they["obsx"] != 1 {
|
||||
t.Fatalf("they_hear[obsx]=%d want 1", d.they["obsx"])
|
||||
}
|
||||
if d.we["origin1"] != 1 {
|
||||
t.Fatalf("we_hear[origin1]=%d want 1 (advert first-hop)", d.we["origin1"])
|
||||
}
|
||||
if d.they["cc00"] != 1 {
|
||||
t.Fatalf("they_hear[cc00]=%d want 1 (successor)", d.they["cc00"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttributeDirections_AmbiguousSkippedAndSelfIgnored(t *testing.T) {
|
||||
// No observer, so the last-hop observer branch can't fire — this isolates
|
||||
// the resolve logic. ZZ is unresolved (ambiguous → skipped); the trailing
|
||||
// 01FA resolves to self (ourPK) and must be ignored as a successor.
|
||||
rows := []pathRow{{observerPK: "", payloadType: 5, path: []string{"ZZ", "01FA", "01FA"}}}
|
||||
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b",
|
||||
testResolver(map[string]string{"01FA": "01fa326b"}))
|
||||
if len(d.we) != 0 || len(d.they) != 0 {
|
||||
t.Fatalf("ambiguous/self should yield no edges, got we=%v they=%v", d.we, d.they)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttributeDirections_LastHopWithObserverCountsObserver(t *testing.T) {
|
||||
// Guards the case the previous test deliberately excludes: when our token is
|
||||
// the last hop AND an observer is present, that observer heard us directly.
|
||||
rows := []pathRow{{observerPK: "obs1", payloadType: 5, path: []string{"ZZ", "01FA"}}}
|
||||
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b",
|
||||
testResolver(map[string]string{}))
|
||||
if d.they["obs1"] != 1 || d.obs["obs1"] == nil || d.obs["obs1"].count != 1 {
|
||||
t.Fatalf("last-hop observer should be counted, got they=%v", d.they)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReliableTokens(t *testing.T) {
|
||||
// pm where "01fa" is unique but "01" is shared (collision).
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "01fa326b0000", Role: "repeater"},
|
||||
{PublicKey: "0188aaaa0000", Role: "repeater"},
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
toks := reliableTokens("01fa326b0000", pm)
|
||||
if !toks["01FA"] {
|
||||
t.Fatalf("expected 01FA reliable, got %v", toks)
|
||||
}
|
||||
if toks["01"] {
|
||||
t.Fatalf("1-byte 01 must be excluded (collision), got %v", toks)
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/nodes/{pubkey}/clock-skew", s.handleNodeClockSkew).Methods("GET")
|
||||
r.HandleFunc("/api/observers/clock-skew", s.handleObserverClockSkew).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/neighbors", s.handleNodeNeighbors).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/reach", s.handleNodeReach).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}", s.handleNodeDetail).Methods("GET")
|
||||
r.HandleFunc("/api/nodes", s.handleNodes).Methods("GET")
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
- [GET /api/nodes/:pubkey/health](#get-apinodespubkeyhealth)
|
||||
- [GET /api/nodes/:pubkey/paths](#get-apinodespubkeypaths)
|
||||
- [GET /api/nodes/:pubkey/analytics](#get-apinodespubkeyanalytics)
|
||||
- [GET /api/nodes/:pubkey/reach](#get-apinodespubkeyreach)
|
||||
- [GET /api/packets](#get-apipackets)
|
||||
- [GET /api/packets/timestamps](#get-apipacketstimestamps)
|
||||
- [GET /api/packets/:id](#get-apipacketsid)
|
||||
@@ -672,6 +673,63 @@ Per-node analytics over a time range.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/nodes/:pubkey/reach
|
||||
|
||||
Per-node RF reach report (two-way link quality). Computes **directional** link counts from raw
|
||||
path adjacency (a flood path is recorded origin→observer, so in `[A,B]` B received
|
||||
A directly). A link is **bidirectional** when both directions have observations;
|
||||
the **bottleneck** (weaker direction) rates two-way stability. Read-only; bounded
|
||||
to a recent window. Identifies nodes only by **unique 2–3 byte** path prefixes
|
||||
(1-byte prefixes collide and are excluded).
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|--------|--------|---------|--------------------------------------|
|
||||
| `days` | number | `7` | Lookback window, clamped 1–30 |
|
||||
|
||||
### Response `200`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"node": { "pubkey": string, "name": string, "role": string,
|
||||
"lat": number | null, "lon": number | null, "first_seen": string (ISO) },
|
||||
"window": { "days": number, "since": string (ISO) },
|
||||
"reliable_tokens": [string], // uppercase hex prefixes unique to this node ([] if unidentifiable)
|
||||
"importance": {
|
||||
"neighbor_degree": number, // all-time, from neighbor_edges
|
||||
"degree_rank": number, // 1-based rank among nodes with edges
|
||||
"nodes_with_edges": number,
|
||||
"relay_observations": number, // windowed obs with this node anywhere in path
|
||||
"bidirectional_links":number,
|
||||
"direct_observers": number
|
||||
},
|
||||
"direct_observers": [
|
||||
{ "pubkey": string, "name": string, "count": number,
|
||||
"avg_snr": number | null, "lat": number | null, "lon": number | null,
|
||||
"distance_km": number | null }
|
||||
],
|
||||
"links": [
|
||||
{ "pubkey": string, "name": string, "role": string,
|
||||
"lat": number | null, "lon": number | null,
|
||||
"we_hear": number, "they_hear": number,
|
||||
"bottleneck": number, "bidir": boolean,
|
||||
"distance_km": number | null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`reliable_tokens: []` means the node has no unique 1–3 byte prefix and cannot be
|
||||
reliably identified in paths; `links`/`direct_observers` will be empty.
|
||||
|
||||
### Response `404`
|
||||
|
||||
```json
|
||||
{ "error": "Not found" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/packets
|
||||
|
||||
Paginated packet (transmission) list with filtering.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,272 @@
|
||||
# Node Quality Report — Design Spec
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Status:** Draft for review
|
||||
**Author:** efite (+ Claude)
|
||||
**Upstream goal:** Build in the fork, fully AGENTS.md-compliant, so it can later be
|
||||
PR'd to `Kpa-clawbot/CoreScope` (upstream) without rework. Keep it decoupled from
|
||||
the fork-private matomo/locode/security customizations.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Give any node a **quality report**: a map of the node and the neighbours it has a
|
||||
**stable two-way (bidirectional) RF link** with, plus a table of those links rated
|
||||
by quality, plus a few importance stats. Reachable at a **direct, shareable URL**
|
||||
and **printable to an offline PDF** (browser Save-as-PDF).
|
||||
|
||||
### Success criteria
|
||||
- A user opens `…/#/nodes/<pubkey>?section=quality`, sees a map with the node +
|
||||
its bidirectional neighbours (links coloured by quality) and a table below.
|
||||
- `Ctrl-P` / Save-as-PDF produces a clean one/two-page offline report.
|
||||
- The directional/quality numbers match the validated `analysis-output/` toolkit.
|
||||
- No write to the DB; bounded, cached query; tests + api-spec updated.
|
||||
|
||||
### Non-goals (YAGNI)
|
||||
- No server-side PDF rendering engine (Go PDF / headless browser). Browser print
|
||||
covers offline. A `/quality.pdf` endpoint is a possible *later* follow-up.
|
||||
- No change to the global `neighbor_edges` builder or DB schema (that's the heavier
|
||||
"approach C" — explicitly deferred).
|
||||
- No new npm deps, no build step, no framework.
|
||||
|
||||
---
|
||||
|
||||
## 2. Approach
|
||||
|
||||
**Approach B — on-demand per-node endpoint + frontend report section.**
|
||||
|
||||
The existing neighbour feature (`/api/neighbors/graph`, `/api/nodes/:pubkey/neighbors`,
|
||||
the Leaflet map, spider-fan viz) is built on `neighbor_edges`, which is
|
||||
**canonical-merged (node_a ≤ node_b)** and therefore directionless — the
|
||||
`GraphEdge.Bidirectional` field is currently hardcoded `true` (placeholder). This
|
||||
feature computes **real direction** on demand from raw `observations.path_json`,
|
||||
for a single node, bounded to a recent window. It does not touch the global builder,
|
||||
so it is read-only and self-contained (maximally upstreamable).
|
||||
|
||||
Rejected alternatives:
|
||||
- **A (standalone CLI/script):** the validated `analysis-output/` toolkit. Great for
|
||||
ad-hoc use but no in-app URL, not what the user wants long-term.
|
||||
- **C (directional `neighbor_edges`):** teach `cmd/ingestor/neighbor_builder.go` to
|
||||
track direction + add columns. More valuable app-wide (fills the placeholder
|
||||
everywhere) but touches the `dbschema` source-of-truth invariant (#1321), the
|
||||
ingestor, and migrations — much larger review surface. Deferred as a future
|
||||
follow-up that upstream can own.
|
||||
|
||||
---
|
||||
|
||||
## 3. Directionality method (the core algorithm)
|
||||
|
||||
A flood path is recorded origin → observer; each repeater appends its own hash
|
||||
hop. So in a path `[A, B]`, **B received A directly** (B is one RF hop downstream
|
||||
of A). For the target node N (matched by its reliable token):
|
||||
|
||||
| Position of N in path | Meaning | Counts toward |
|
||||
|---|---|---|
|
||||
| token **before** N (`[X, N]`) | we received X directly | `we_hear[X] += 1` |
|
||||
| token **after** N (`[N, X]`) | X received us directly | `they_hear[X] += 1` |
|
||||
| N is **last** hop | the **observer** heard us directly | `they_hear[observer] += 1` (+SNR) |
|
||||
| N is **first** hop of an ADVERT | we were first relay of the originator's advert ⇒ we heard it | `we_hear[from_pubkey] += 1` |
|
||||
|
||||
- A **link is bidirectional/stable** when both `we_hear>0` and `they_hear>0`.
|
||||
- **bottleneck = min(we_hear, they_hear)** — the weaker direction rates real
|
||||
two-way reliability and drives link colour/width.
|
||||
|
||||
### Reliability rule
|
||||
A node (target or neighbour) is only identified from a path hop when its pubkey
|
||||
**prefix is unique** at that hop's byte length, across all known pubkeys
|
||||
(nodes + inactive_nodes + observers). 1-byte prefixes almost always collide and
|
||||
are auto-excluded; only unique 2–3 byte hops are trusted. The target's reliable
|
||||
tokens are derived automatically; ambiguous neighbour hops are skipped (never
|
||||
misattributed — conservative undercount).
|
||||
|
||||
This is the exact, validated logic of `analysis-output/analyze_node.py`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend
|
||||
|
||||
### 4.1 New endpoint
|
||||
`GET /api/nodes/:pubkey/quality?days=7`
|
||||
|
||||
- `days` — lookback window, default 7, clamped 1–30 (perf bound; "recent quality"
|
||||
is also more meaningful than all-time).
|
||||
- Read-only (`mode=ro`), per #1283. No writes.
|
||||
|
||||
### 4.2 Response (named structs in `cmd/server/node_quality.go`)
|
||||
```go
|
||||
type NodeQualityResponse struct {
|
||||
Node QualityNode `json:"node"`
|
||||
Window QualityWindow `json:"window"`
|
||||
ReliableTokens []string `json:"reliable_tokens"`
|
||||
Importance QualityImportance `json:"importance"`
|
||||
DirectObservers []QualityObserver `json:"direct_observers"`
|
||||
Links []QualityLink `json:"links"`
|
||||
}
|
||||
type QualityNode struct { Pubkey, Name, Role string; Lat, Lon *float64; FirstSeen string }
|
||||
type QualityWindow struct { Days int `json:"days"`; Since string `json:"since"` }
|
||||
type QualityImportance struct {
|
||||
NeighborDegree, DegreeRank, NodesWithEdges int
|
||||
RelayObservations, BidirectionalLinks, DirectObservers int
|
||||
}
|
||||
type QualityLink struct {
|
||||
Pubkey, Name, Role string
|
||||
Lat, Lon *float64
|
||||
WeHear int `json:"we_hear"`
|
||||
TheyHear int `json:"they_hear"`
|
||||
Bottleneck int `json:"bottleneck"`
|
||||
Bidir bool `json:"bidir"`
|
||||
DistanceKm *float64 `json:"distance_km,omitempty"`
|
||||
}
|
||||
type QualityObserver struct { Pubkey, Name string; Count int; AvgSNR, Lat, Lon, DistanceKm *float64 }
|
||||
```
|
||||
No `map[string]interface{}` anywhere (#1383).
|
||||
|
||||
### 4.3 Computation & reuse
|
||||
- Resolve `:pubkey`, derive reliable tokens (unique-prefix check).
|
||||
- Build a **prefix→pubkey unique index** + a pubkey→info map. Reuse
|
||||
`buildNodeInfoMap()` (already merges observers) for names/roles/GPS and
|
||||
`haversineKm()` for distance — both already in `cmd/server/neighbor_api.go`.
|
||||
- Query, bounded by the **timestamp index**:
|
||||
`SELECT obs.id, t.from_pubkey, t.payload_type, o.path_json, 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 o.timestamp >= ? AND (o.path_json LIKE '%"TOK"%' OR …)`
|
||||
- Run the §3 attribution as a **pure function**
|
||||
`attributeDirections(rows, ourTokens, resolve) -> (we, they, observers, relayCount)`
|
||||
so it is unit-testable in isolation (DI per AGENTS Testability).
|
||||
- Importance fields:
|
||||
- `neighbor_degree` + `degree_rank` + `nodes_with_edges` from the existing
|
||||
`neighbor_edges` degree CTE (already validated). **These are all-time** (the
|
||||
edge table is not windowed); the directional `links` are windowed. This mix is
|
||||
intentional — degree/rank express standing connectivity, the links express
|
||||
*current* two-way quality. Stated in the UI so it isn't misread.
|
||||
- `relay_observations` = count of windowed observations in which any reliable
|
||||
token of the node appears **anywhere** in the path (its relay throughput).
|
||||
- `bidirectional_links` / `direct_observers` = derived counts from the scan.
|
||||
|
||||
### 4.4 Performance (AGENTS rule 0 — must justify)
|
||||
- The leading-wildcard `LIKE` cannot use an index, so cost = rows in the
|
||||
**timestamp window** only. Measured: 156k obs (≈1.5 d) scanned + filtered in
|
||||
~0.6 s; a 7-day window is a few × that worst-case.
|
||||
- **Bound:** `days` clamped ≤30. **Cache** the JSON per `(pubkey, days)` with a TTL
|
||||
(≈300 s) via the existing server cache layer, keyed like other analytics.
|
||||
- Ship a Go benchmark with realistic fixture sizes (before/after timings) per the
|
||||
"no proof = no merge" rule.
|
||||
|
||||
### 4.5 Errors
|
||||
- Unknown pubkey → `404 {"error":"Not found"}`.
|
||||
- `days` out of range → clamp (no error).
|
||||
- Node with no unique 1–3 byte prefix → `200` with `reliable_tokens: []` and empty
|
||||
`links`/`direct_observers`. The UI detects `reliable_tokens.length === 0` and
|
||||
shows an explanatory empty-state ("node niet betrouwbaar identificeerbaar in
|
||||
paden — alleen 1-byte prefix, botst") rather than a blank report. No extra
|
||||
response field needed.
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend
|
||||
|
||||
### 5.1 Route / deep-link
|
||||
New section on the existing node detail page: `#/nodes/<pubkey>?section=quality`
|
||||
(matches the existing `section=node-neighbors` pattern; satisfies AGENTS deep-link
|
||||
rule). **This hash URL is the shareable "direct URL to the report."**
|
||||
|
||||
### 5.2 Layout
|
||||
- **Importance header**: small stat cards (degree, rank, relay obs, #2-way links,
|
||||
#direct observers).
|
||||
- **Map**: a focused Leaflet map (reuse `map.js` tile config + helpers) showing the
|
||||
node (highlighted) and only its **bidirectional** neighbours, links coloured by
|
||||
bottleneck. Reuse existing marker/role styling.
|
||||
- **Table**: the 2-way links sorted by bottleneck — columns: buur, wij horen,
|
||||
zij horen ons, bottleneck, afstand. A toggle to also show one-way links
|
||||
(default off) for diagnosing asymmetry.
|
||||
|
||||
### 5.3 Print / PDF
|
||||
A print stylesheet (`@media print`) lays out header → map → table on A4 and hides
|
||||
nav/chrome. A "Print / PDF" button calls `window.print()`. Offline PDF via the
|
||||
browser's Save-as-PDF.
|
||||
|
||||
### 5.4 Conventions
|
||||
- Link colours via **CSS variables** (`--link-strong/--link-medium/--link-weak`)
|
||||
in `style.css`, mapped in the customizer (AGENTS rule 8 — note as later milestone,
|
||||
OK to ship with sane defaults).
|
||||
- Bulk fetch (one call to the new endpoint), filter/sort client-side — no per-item
|
||||
calls.
|
||||
- Code in its own file (e.g. `public/node-quality.js`) or a clearly-bounded section
|
||||
module, not entangled with matomo/locode.
|
||||
|
||||
---
|
||||
|
||||
## 6. API contract
|
||||
Add the `GET /api/nodes/:pubkey/quality` section to `docs/api-spec.md` **before**
|
||||
building the UI (contract is authoritative; AGENTS rule 4).
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing
|
||||
- **Go unit** (`cmd/server/node_quality_test.go`): drive `attributeDirections`
|
||||
with synthetic path arrays covering predecessor/successor/last-hop/first-hop-advert,
|
||||
ambiguous-token skipping, and the unique-prefix derivation. Test-first.
|
||||
- **Go endpoint test**: shape + 404 + clamp, against the test fixture DB.
|
||||
- **Go benchmark**: scan timing on a realistic window (perf proof).
|
||||
- **Playwright** (`test-node-quality-*.js`): section loads, map renders bidirectional
|
||||
links, table populates, print layout sane, deep-link round-trips.
|
||||
- `npm test` + local E2E green before any push.
|
||||
|
||||
---
|
||||
|
||||
## 8. Upstreamability checklist
|
||||
- [ ] Self-contained files (`cmd/server/node_quality.go`, `public/node-quality.js`),
|
||||
no matomo/locode coupling.
|
||||
- [ ] Read-only (#1283), named structs (#1383), CSS vars, no npm deps.
|
||||
- [ ] `docs/api-spec.md` updated.
|
||||
- [ ] Tests + benchmark + browser validation.
|
||||
- [ ] Deep-linked hash route.
|
||||
- [ ] Later, when desired: branch off `upstream/master`, cherry-pick the feature
|
||||
commits, open a PR per AGENTS.md (clean markdown body, perf proof).
|
||||
|
||||
---
|
||||
|
||||
## 9. Resolved decisions
|
||||
1. **Map base** — extract a minimal reusable `renderLinkMap(container, node, links)`
|
||||
helper (Leaflet tile config/markers reused from `map.js`); do not duplicate the
|
||||
whole map module nor force the full `map.js` instance.
|
||||
2. **One-way links** — shown in the table behind a toggle (default **off**), so the
|
||||
default report is the bidirectional set but asymmetry stays diagnosable.
|
||||
3. **Placement** — ~~section on the node detail page~~ → **superseded** (see §10):
|
||||
a dedicated full-page view at `#/nodes/<pubkey>/quality` with a top "Quality"
|
||||
button next to Analytics.
|
||||
|
||||
---
|
||||
|
||||
## 10. Revisions (post-review, 2026-06-07)
|
||||
|
||||
> **Feature renamed Quality → Reach.** "Quality" didn't capture the page (it's
|
||||
> about a node's two-way RF *reach*). Endpoint `/api/nodes/:pubkey/quality` →
|
||||
> `/reach`; files `node_quality.go` → `node_reach.go`, `node-quality*.{js,css}` →
|
||||
> `node-reach*`; route `#/nodes/<pk>/quality` → `/reach`; Go types `Quality*` →
|
||||
> `NodeReach*` (the bare `Reach*` namespace was already taken by the topology
|
||||
> per-observer-reach feature). Earlier mentions of "quality" in this doc reflect
|
||||
> the original name.
|
||||
|
||||
After the first staging deploy, review feedback reshaped the frontend:
|
||||
|
||||
- **Standalone page, not a buried section.** Registered as page `node-quality`
|
||||
(route `#/nodes/<pubkey>/quality`), mirroring `node-analytics.js`, with a
|
||||
📈 Quality button next to Analytics on the node detail (full + side pane). The
|
||||
inline far-down section was removed. (Reverses §9.3.)
|
||||
- **English UI** to match the rest of the app.
|
||||
- **Day selector** (24h / 7d / 14d / 30d) re-fetching the endpoint; default 7d.
|
||||
- **Importance grouped + explained**: "Network position (all-time)" (Neighbours,
|
||||
Rank) vs "Last N days" (Links, Two-way, Relay obs, Direct observers), each card
|
||||
carrying a description — clarifies why all-time *Neighbours* (narrow,
|
||||
geo-filtered neighbour_edges) can be **less** than windowed *Two-way* (full
|
||||
path-adjacency, both directions).
|
||||
- **Map fixes**: node shown as a default Leaflet pin (was an invisible white
|
||||
dot); neighbour dots filled with the link colour; print resizes the map to a
|
||||
fixed width + `invalidateSize()` so the whole map prints (was clipped to the
|
||||
left half on wide screens).
|
||||
- **One-way toggle made visible**: a live "showing X of Y (Z two-way)" count,
|
||||
one-way rows muted with a direction hint.
|
||||
- **Clickable neighbours**: each table row links to that node's detail page.
|
||||
@@ -908,6 +908,11 @@ function navigate() {
|
||||
basePage = 'node-analytics';
|
||||
}
|
||||
|
||||
// Special route: nodes/PUBKEY/reach → node-reach page
|
||||
if (basePage === 'nodes' && routeParam && routeParam.endsWith('/reach')) {
|
||||
basePage = 'node-reach';
|
||||
}
|
||||
|
||||
// Special route: packet/123 → standalone packet detail page
|
||||
if (basePage === 'packet' && routeParam) {
|
||||
basePage = 'packet-detail';
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<link rel="stylesheet" href="route-view.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="home.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="live.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="node-reach.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="bottom-nav.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="nav-drawer.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
@@ -213,6 +214,8 @@
|
||||
<script src="observer-detail.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-reach-map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-reach.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="mobile-page-actions.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/* window.NodeReachMap.render(containerId, node, links, colorFn) — focused
|
||||
Leaflet map of a node and its bidirectional links, coloured by bottleneck.
|
||||
Returns the Leaflet map instance (with _nqBounds) so the caller can resize
|
||||
for printing. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function cssColor(varExpr) {
|
||||
var name = varExpr.replace('var(', '').replace(')', '').trim();
|
||||
var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return v || '#888';
|
||||
}
|
||||
|
||||
function render(containerId, node, links, colorFn) {
|
||||
var c = document.getElementById(containerId);
|
||||
if (!c || typeof L === 'undefined') return null;
|
||||
var map = L.map(containerId, { zoomControl: true, attributionControl: false })
|
||||
.setView([node.lat, node.lon], 11);
|
||||
if (typeof window._applyTilesToNodeMap === 'function') {
|
||||
window._applyTilesToNodeMap(map);
|
||||
} else {
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
|
||||
}
|
||||
var pts = [[node.lat, node.lon]];
|
||||
links.forEach(function (l) {
|
||||
if (l.lat == null || l.lon == null) return;
|
||||
pts.push([l.lat, l.lon]);
|
||||
var col = cssColor(colorFn(l.bottleneck));
|
||||
L.polyline([[node.lat, node.lon], [l.lat, l.lon]], {
|
||||
color: col,
|
||||
weight: Math.max(1.5, Math.min(7, 1.2 + 1.6 * Math.log10(l.bottleneck + 1))),
|
||||
opacity: 0.85
|
||||
}).addTo(map).bindPopup(escapeHtml(l.name) + '<br>we ' + l.we_hear + ' / they ' + l.they_hear);
|
||||
// Neighbour dot: filled with the link colour (was white/invisible before).
|
||||
L.circleMarker([l.lat, l.lon], { radius: 5, color: '#ffffff', weight: 1, fillColor: col, fillOpacity: 1 })
|
||||
.addTo(map).bindTooltip(escapeHtml(l.name));
|
||||
});
|
||||
// The node itself: a default Leaflet pin marker — always clearly visible.
|
||||
L.marker([node.lat, node.lon]).addTo(map).bindPopup(escapeHtml(node.name));
|
||||
try { map.fitBounds(pts, { padding: [30, 30] }); } catch (e) {}
|
||||
map._nqBounds = pts;
|
||||
setTimeout(function () { map.invalidateSize(); }, 120);
|
||||
return map;
|
||||
}
|
||||
|
||||
window.NodeReachMap = { render: render };
|
||||
})();
|
||||
@@ -0,0 +1,24 @@
|
||||
/* Node reach page — reuses .analytics-stats/.analytics-stat-card/.analytics-time-range
|
||||
from the analytics page; these are the reach-specific bits. */
|
||||
.nq-group-h { font-size:11px; font-weight:600; color:var(--text-muted, #57606a); text-transform:uppercase; letter-spacing:.3px; margin:14px 0 6px; }
|
||||
.nq-actions { display:flex; align-items:center; gap:14px; margin:10px 0; font-size:12px; }
|
||||
.nq-count { color:var(--text-muted, #57606a); }
|
||||
.nq-empty { color:var(--text-muted, #57606a); font-size:13px; padding:18px 12px; border:1px dashed var(--border, #d0d7de); border-radius:6px; margin:6px 0 24px; }
|
||||
.nq-note { margin:6px 0 0; padding:6px 10px; font-size:11px; line-height:1.4; color:var(--text-muted, #57606a); background:var(--section-bg, #f6f8fa); border:1px solid var(--border, #d0d7de); border-radius:6px; }
|
||||
.nq-map { height:420px; border:1px solid var(--border, #d0d7de); border-radius:6px; margin-bottom:12px; }
|
||||
|
||||
.nq-table { border-collapse:collapse; width:100%; font-size:12px; margin-bottom:24px; }
|
||||
.nq-table th, .nq-table td { border:1px solid var(--border, #d0d7de); padding:3px 7px; }
|
||||
.nq-table th { background:var(--section-bg, #f6f8fa); font-size:10px; text-transform:uppercase; }
|
||||
.nq-n { text-align:right; font-variant-numeric:tabular-nums; }
|
||||
.nq-num { text-align:right; color:var(--text-muted, #8c959f); width:26px; }
|
||||
.nq-link { color:var(--accent); text-decoration:none; font-weight:600; }
|
||||
.nq-link:hover { text-decoration:underline; }
|
||||
.nq-oneway { color:var(--text-muted, #8c959f); }
|
||||
.nq-dir { font-size:10px; color:var(--text-muted, #8c959f); }
|
||||
|
||||
@media print {
|
||||
.nq-noprint { display:none !important; }
|
||||
.nq-map { width:680px !important; height:300px; }
|
||||
.nq-table { font-size:10px; }
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/* === CoreScope — node-reach.js ===
|
||||
Standalone per-node "Reach" page: importance stats + a map of the node's
|
||||
bidirectional RF links + a link table. Registered as page 'node-reach'
|
||||
(route #/nodes/<pubkey>/reach), mirroring node-analytics.js. */
|
||||
'use strict';
|
||||
(function () {
|
||||
var qmap = null;
|
||||
var current = null;
|
||||
|
||||
function colorVar(b) {
|
||||
if (b >= 300) return 'var(--link-strong)';
|
||||
if (b >= 100) return 'var(--link-medium)';
|
||||
return 'var(--link-weak)';
|
||||
}
|
||||
|
||||
function statCard(label, value, desc) {
|
||||
return '<div class="analytics-stat-card"><div class="analytics-stat-label">' + label +
|
||||
'</div><div class="analytics-stat-value">' + value +
|
||||
'</div><div class="analytics-stat-desc">' + desc + '</div></div>';
|
||||
}
|
||||
|
||||
function linkRow(i, l) {
|
||||
var dist = l.distance_km != null ? Number(l.distance_km).toFixed(1) : '—';
|
||||
var dir = l.bidir ? '' : (l.we_hear > 0 ? 'incoming' : 'outgoing');
|
||||
var href = '#/nodes/' + encodeURIComponent(l.pubkey);
|
||||
return '<tr' + (l.bidir ? '' : ' class="nq-oneway"') + '>' +
|
||||
'<td class="nq-num">' + i + '</td>' +
|
||||
'<td><a href="' + href + '" class="nq-link">' + escapeHtml(l.name || l.pubkey.slice(0, 8)) + '</a>' +
|
||||
(dir ? ' <span class="nq-dir">' + dir + '</span>' : '') + '</td>' +
|
||||
'<td class="nq-n">' + l.we_hear + '</td>' +
|
||||
'<td class="nq-n">' + l.they_hear + '</td>' +
|
||||
'<td class="nq-n" style="color:' + colorVar(l.bottleneck) + '"><b>' + l.bottleneck + '</b></td>' +
|
||||
'<td class="nq-n">' + dist + '</td></tr>';
|
||||
}
|
||||
|
||||
function dayBtn(d, cur, label) {
|
||||
return '<button data-days="' + d + '"' + (d === cur ? ' class="active"' : '') + '>' + label + '</button>';
|
||||
}
|
||||
|
||||
function headerHtml(n, nodeName, days) {
|
||||
return '<div class="nq-head" style="max-width:1000px;margin:0 auto;padding:12px 16px">' +
|
||||
'<a href="#/nodes/' + encodeURIComponent(n.pubkey) + '" style="color:var(--accent);text-decoration:none;font-size:12px">← Back to ' + nodeName + '</a>' +
|
||||
'<h2 style="margin:4px 0 2px;font-size:18px">📡 ' + nodeName + ' — Reach</h2>' +
|
||||
'<div style="color:var(--text-muted);font-size:11px">' + escapeHtml(n.role || 'Unknown role') + ' · two-way RF link reach</div>' +
|
||||
'<div class="nq-note">ℹ️ Reliable by design: built only from unique <b>2–3 byte</b> path-hash (multibyte) matches. 1-byte hops collide between nodes and are excluded, so the links shown are trustworthy.</div>' +
|
||||
'<div class="analytics-time-range nq-noprint" id="nqDays" style="margin-top:8px">' +
|
||||
dayBtn(1, days, '24h') + dayBtn(7, days, '7d') + dayBtn(14, days, '14d') + dayBtn(30, days, '30d') +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function wireTimeRange(container, pubkey) {
|
||||
var bar = container.querySelector('#nqDays');
|
||||
if (!bar) return;
|
||||
bar.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('button[data-days]');
|
||||
if (b) load(container, pubkey, parseInt(b.getAttribute('data-days'), 10));
|
||||
});
|
||||
}
|
||||
|
||||
function printReport() {
|
||||
// Leaflet only renders tiles for the on-screen size; on a wide screen the
|
||||
// printed page is narrower and the right half is clipped. Resize the map to
|
||||
// a print-safe width and invalidate before printing.
|
||||
var mapEl = document.getElementById('nqMap');
|
||||
if (qmap && mapEl) {
|
||||
mapEl.style.width = '680px';
|
||||
qmap.invalidateSize();
|
||||
try { qmap.fitBounds(qmap._nqBounds, { padding: [20, 20] }); } catch (e) {}
|
||||
setTimeout(function () {
|
||||
window.print();
|
||||
mapEl.style.width = '';
|
||||
qmap.invalidateSize();
|
||||
try { qmap.fitBounds(qmap._nqBounds, { padding: [30, 30] }); } catch (e) {}
|
||||
}, 350);
|
||||
} else {
|
||||
window.print();
|
||||
}
|
||||
}
|
||||
|
||||
async function load(container, pubkey, days) {
|
||||
current = { pubkey: pubkey, days: days };
|
||||
if (qmap) { try { qmap.remove(); } catch (e) {} qmap = null; }
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading reach…</div>';
|
||||
|
||||
var d;
|
||||
try {
|
||||
d = await api('/nodes/' + encodeURIComponent(pubkey) + '/reach?days=' + days, { ttl: 30000 });
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load reach: ' + escapeHtml(e.message) + '</div>';
|
||||
return;
|
||||
}
|
||||
current.data = d;
|
||||
var n = d.node;
|
||||
var nodeName = escapeHtml(n.name || n.pubkey.slice(0, 12));
|
||||
var imp = d.importance || {};
|
||||
var twoWay = d.links.filter(function (l) { return l.bidir; });
|
||||
|
||||
if (!d.reliable_tokens || d.reliable_tokens.length === 0) {
|
||||
// nodeName is already escaped above; build the fragment then assign
|
||||
// (same build-then-assign pattern as statsHtml below).
|
||||
var emptyHtml = headerHtml(n, nodeName, days) +
|
||||
'<div style="max-width:1000px;margin:0 auto;padding:0 16px;color:var(--text-muted)">This node has no unique 1–3 byte prefix, so it cannot be reliably identified in paths — no link data available.</div>';
|
||||
container.innerHTML = emptyHtml;
|
||||
wireTimeRange(container, pubkey);
|
||||
return;
|
||||
}
|
||||
|
||||
var statsHtml = headerHtml(n, nodeName, days) +
|
||||
'<div class="nq-body" style="max-width:1000px;margin:0 auto;padding:0 16px">' +
|
||||
'<div class="nq-group-h">Network position (all-time)</div>' +
|
||||
'<div class="analytics-stats">' +
|
||||
statCard('Neighbours', imp.neighbor_degree, 'Distinct neighbours in the all-time neighbour graph (built only from advert first-hop + observer last-hop, geo-filtered).') +
|
||||
statCard('Rank', '#' + imp.degree_rank + ' / ' + imp.nodes_with_edges, 'Rank by neighbour count among all nodes that have edges. #1 = the most-connected node in the network.') +
|
||||
'</div>' +
|
||||
'<div class="nq-group-h">Last ' + d.window.days + ' days</div>' +
|
||||
'<div class="analytics-stats">' +
|
||||
statCard('Links', d.links.length, 'Distinct direct neighbours seen in paths this window (any direction).') +
|
||||
statCard('Two-way', imp.bidirectional_links, 'Neighbours heard in BOTH directions — stable links. Counts mid-path adjacency too, so this can exceed the all-time "Neighbours".') +
|
||||
statCard('Relay obs', imp.relay_observations, 'Observations where this node appears anywhere in the path (its relay throughput).') +
|
||||
statCard('Direct observers', imp.direct_observers, 'Stations that received this node directly, at 0 hops.') +
|
||||
'</div>';
|
||||
|
||||
// Identifiable, but no path adjacency observed in this window → explain rather
|
||||
// than showing an empty map + table.
|
||||
if (!d.links.length) {
|
||||
container.innerHTML = statsHtml +
|
||||
'<div class="nq-empty">No observed RF links in the last ' + d.window.days +
|
||||
' days — this node advertises but hasn’t been seen relaying traffic (or no observers captured it). Try a longer window.</div>' +
|
||||
'</div>';
|
||||
wireTimeRange(container, pubkey);
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = statsHtml +
|
||||
'<div class="nq-actions nq-noprint">' +
|
||||
'<label><input type="checkbox" id="nqIncoming"> incoming <span class="nq-dir">(we hear them)</span></label>' +
|
||||
'<label><input type="checkbox" id="nqOutgoing"> outgoing <span class="nq-dir">(they hear us)</span></label>' +
|
||||
'<span id="nqCount" class="nq-count"></span>' +
|
||||
'<button id="nqPrintBtn" class="btn-primary" style="margin-left:auto">🖨️ Print / PDF</button>' +
|
||||
'</div>' +
|
||||
'<div id="nqMap" class="nq-map"></div>' +
|
||||
'<table class="nq-table"><thead><tr><th>#</th><th>Neighbour</th><th>we hear</th>' +
|
||||
'<th>they hear us</th><th>bottleneck</th><th>km</th></tr></thead><tbody id="nqRows"></tbody></table>' +
|
||||
'</div>';
|
||||
|
||||
function renderMap(list) {
|
||||
if (qmap) { try { qmap.remove(); } catch (e) {} qmap = null; }
|
||||
if (window.NodeReachMap && n.lat != null) {
|
||||
qmap = window.NodeReachMap.render('nqMap', n, list, colorVar);
|
||||
}
|
||||
}
|
||||
|
||||
// Two-way links are always shown; the two checkboxes add the asymmetric ones.
|
||||
// incoming = we hear them only; outgoing = they hear us only.
|
||||
function paint() {
|
||||
var inc = document.getElementById('nqIncoming').checked;
|
||||
var out = document.getElementById('nqOutgoing').checked;
|
||||
var list = d.links.filter(function (l) {
|
||||
if (l.bidir) return true;
|
||||
var weOnly = l.we_hear > 0 && l.they_hear === 0;
|
||||
return (inc && weOnly) || (out && !weOnly);
|
||||
}).sort(function (a, b) {
|
||||
return (b.bidir - a.bidir) || (b.bottleneck - a.bottleneck) ||
|
||||
((b.we_hear + b.they_hear) - (a.we_hear + a.they_hear));
|
||||
});
|
||||
document.getElementById('nqRows').innerHTML = list.map(function (l, i) { return linkRow(i + 1, l); }).join('');
|
||||
document.getElementById('nqCount').textContent =
|
||||
'showing ' + list.length + ' of ' + d.links.length + ' (' + twoWay.length + ' two-way)';
|
||||
// Keep the map in sync with the table.
|
||||
renderMap(list);
|
||||
}
|
||||
paint();
|
||||
document.getElementById('nqIncoming').addEventListener('change', paint);
|
||||
document.getElementById('nqOutgoing').addEventListener('change', paint);
|
||||
document.getElementById('nqPrintBtn').addEventListener('click', printReport);
|
||||
|
||||
wireTimeRange(container, pubkey);
|
||||
}
|
||||
|
||||
function init(container, routeParam) {
|
||||
if (!routeParam || !routeParam.endsWith('/reach')) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center">Invalid reach URL</div>';
|
||||
return;
|
||||
}
|
||||
load(container, routeParam.slice(0, -'/reach'.length), 7);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (qmap) { try { qmap.remove(); } catch (e) {} qmap = null; }
|
||||
current = null;
|
||||
}
|
||||
|
||||
registerPage('node-reach', { init: init, destroy: destroy });
|
||||
})();
|
||||
+11
-6
@@ -106,6 +106,9 @@
|
||||
} catch (_e) {}
|
||||
return layer;
|
||||
}
|
||||
// Exposed so the node-reach link-map (node-reach-map.js) reuses the user's
|
||||
// configured tile provider instead of hardcoding OSM.
|
||||
window._applyTilesToNodeMap = _applyTilesToNodeMap;
|
||||
|
||||
|
||||
// ROLE_COLORS loaded from shared roles.js
|
||||
@@ -562,10 +565,11 @@
|
||||
<div style="margin:4px 0 6px">${renderNodeBadges(n, roleColor)}</div>
|
||||
${renderHashInconsistencyWarning(n)}
|
||||
<div class="node-detail-key mono" style="font-size:11px;word-break:break-all;margin-bottom:6px">${n.public_key}</div>
|
||||
<div>
|
||||
<button class="btn-primary" id="copyUrlBtn" style="font-size:12px;padding:4px 10px">📋 Copy URL</button>
|
||||
<button class="btn-primary" id="copyShortUrlBtn" title="Short URL using an 8-char pubkey prefix — easier to send over the mesh (issue #772)" style="font-size:12px;padding:4px 10px;margin-left:6px">📡 Copy short URL</button>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:6px;text-decoration:none;font-size:12px;padding:4px 10px">📊 Analytics</a>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
||||
<button class="btn-primary" id="copyUrlBtn" style="flex:0 0 auto;font-size:12px;padding:4px 10px">📋 Copy URL</button>
|
||||
<button class="btn-primary" id="copyShortUrlBtn" title="Short URL using an 8-char pubkey prefix — easier to send over the mesh (issue #772)" style="flex:0 0 auto;font-size:12px;padding:4px 10px">📡 Copy short URL</button>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px">📊 Analytics</a>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/reach" class="btn-primary" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px">📡 Reach</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1348,8 +1352,8 @@
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
var href = link.getAttribute('href');
|
||||
if (href.indexOf('/analytics') !== -1) {
|
||||
// Analytics link — different page, force hashchange via replaceState + assign
|
||||
if (href.indexOf('/analytics') !== -1 || href.indexOf('/reach') !== -1) {
|
||||
// Analytics/Reach link — different page, force hashchange via replaceState + assign
|
||||
history.replaceState(null, '', '#/');
|
||||
location.hash = href.substring(1);
|
||||
}
|
||||
@@ -1545,6 +1549,7 @@
|
||||
<div class="node-detail-role">${renderNodeBadges(n, roleColor)}
|
||||
<button class="btn-primary node-detail-btn" data-pubkey="${encodeURIComponent(n.public_key)}" aria-label="View details for ${escapeHtml(n.name || n.public_key)}" style="font-size:11px;padding:2px 8px;margin-left:8px;cursor:pointer">🔍 Details</button>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px">📊 Analytics</a>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/reach" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px">📡 Reach</a>
|
||||
</div>
|
||||
${renderStatusExplanation(n)}
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
* ============================================================ */
|
||||
|
||||
:root {
|
||||
/* Node-quality link strength colours (bottleneck tiers) */
|
||||
--link-strong: #1a7f37;
|
||||
--link-medium: #bf8700;
|
||||
--link-weak: #cf222e;
|
||||
/* --- Fluid spacing scale ---------------------------------
|
||||
* Targets at 1440px viewport: 4 / 8 / 16 / 24 / 32 / 48 px.
|
||||
* Min/max clamps keep small viewports usable and prevent
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// E2E for the per-node Reach page (#/nodes/<pubkey>/reach).
|
||||
// Defaults to localhost:3000 — NEVER point at prod (AGENTS.md). CI sets BASE_URL.
|
||||
const { chromium } = require('playwright');
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
// A repeater is most likely to have reach data (it relays).
|
||||
const nodes = await (await page.request.get(BASE + '/api/nodes?role=repeater&limit=1')).json();
|
||||
if (!nodes.nodes || !nodes.nodes.length) {
|
||||
console.log('node-reach E2E SKIP (no repeater in dataset)');
|
||||
await browser.close();
|
||||
return;
|
||||
}
|
||||
const pk = nodes.nodes[0].public_key;
|
||||
|
||||
// 1. The endpoint returns the documented shape.
|
||||
const reach = await (await page.request.get(BASE + '/api/nodes/' + pk + '/reach?days=7')).json();
|
||||
for (const k of ['node', 'window', 'reliable_tokens', 'importance', 'links', 'direct_observers']) {
|
||||
if (!(k in reach)) throw new Error('reach response missing key: ' + k);
|
||||
}
|
||||
if (!Array.isArray(reach.links)) throw new Error('reach.links must be an array');
|
||||
|
||||
// 2. The page renders.
|
||||
await page.goto(BASE + '/#/nodes/' + pk + '/reach');
|
||||
await page.waitForSelector('.nq-head', { timeout: 20000 });
|
||||
if (!(await page.locator('h2', { hasText: 'Reach' }).count())) {
|
||||
throw new Error('Reach header missing');
|
||||
}
|
||||
|
||||
// 3. If this node is identifiable, exercise the table, toggles and links.
|
||||
if (reach.reliable_tokens.length && (await page.locator('#nqRows').count())) {
|
||||
await page.waitForSelector('#nqIncoming');
|
||||
await page.waitForSelector('#nqOutgoing');
|
||||
|
||||
const twoWay = await page.locator('#nqRows tr').count();
|
||||
await page.check('#nqIncoming');
|
||||
const withIncoming = await page.locator('#nqRows tr').count();
|
||||
if (withIncoming < twoWay) throw new Error('incoming toggle reduced rows');
|
||||
await page.check('#nqOutgoing');
|
||||
const withBoth = await page.locator('#nqRows tr').count();
|
||||
if (withBoth < withIncoming) throw new Error('outgoing toggle reduced rows');
|
||||
|
||||
// Neighbour rows link to a node detail page.
|
||||
if (await page.locator('#nqRows a.nq-link').count()) {
|
||||
const href = await page.locator('#nqRows a.nq-link').first().getAttribute('href');
|
||||
if (!href || !href.startsWith('#/nodes/')) throw new Error('neighbour link malformed: ' + href);
|
||||
}
|
||||
|
||||
// Map renders (node has GPS for a repeater fixture).
|
||||
await page.waitForSelector('#nqMap .leaflet-container', { timeout: 10000 }).catch(() => {});
|
||||
}
|
||||
|
||||
console.log('node-reach E2E OK');
|
||||
await browser.close();
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user