diff --git a/cmd/server/db.go b/cmd/server/db.go
index 97d29455..49cc1bed 100644
--- a/cmd/server/db.go
+++ b/cmd/server/db.go
@@ -3494,6 +3494,10 @@ func (db *DB) buildWardrivingSessions(channel, since string) ([]WardrivingSessio
}
cur.EntryPointCount = len(curPrefixes)
cur.ObserverCount = len(curObservers)
+ for p := range curPrefixes {
+ cur.EntryPointPrefixes = append(cur.EntryPointPrefixes, p)
+ }
+ sort.Strings(cur.EntryPointPrefixes)
start, errS := time.Parse(time.RFC3339, cur.StartTime)
end, errE := time.Parse(time.RFC3339, cur.EndTime)
if errS == nil && errE == nil {
diff --git a/cmd/server/routes.go b/cmd/server/routes.go
index 755a9a92..40fcffd5 100644
--- a/cmd/server/routes.go
+++ b/cmd/server/routes.go
@@ -2716,6 +2716,39 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
writeJSON(w, ResolveHopsResponse{Resolved: resolved})
}
+// resolveEntryPointArea approximates a wardriving session's area from its
+// entry-point repeater(s): for each candidate path[0] prefix, only a
+// unique_prefix match (exactly one node in the prefix map, same discipline
+// as handleResolveHops) with a known position is trusted — an ambiguous
+// prefix is skipped rather than guessed. Returns ok=false if no prefix
+// resolves this way, or the resolved node has no position, or areas aren't
+// configured.
+func (s *Server) resolveEntryPointArea(prefixes []string) (label string, ok bool) {
+ if s.store == nil || s.cfg == nil || len(s.cfg.Areas) == 0 || len(prefixes) == 0 {
+ return "", false
+ }
+ s.store.mu.RLock()
+ _, pm := s.store.getCachedNodesAndPM()
+ s.store.mu.RUnlock()
+ if pm == nil {
+ return "", false
+ }
+ for _, prefix := range prefixes {
+ candidates, found := pm.m[strings.ToLower(prefix)]
+ if !found || len(candidates) != 1 {
+ continue
+ }
+ ni := candidates[0]
+ if !ni.HasGPS {
+ continue
+ }
+ if label, ok := AreaForPoint(ni.Lat, ni.Lon, s.cfg.Areas); ok {
+ return label, true
+ }
+ }
+ return "", false
+}
+
func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) {
region := r.URL.Query().Get("region")
includeEncrypted := r.URL.Query().Get("includeEncrypted") == "true"
@@ -3782,6 +3815,13 @@ func (s *Server) handleWardrivingStats(w http.ResponseWriter, r *http.Request) {
resp.GPSShares[i].Area = &label
}
}
+ if s.store != nil {
+ for i := range resp.Sessions {
+ if label, ok := s.resolveEntryPointArea(resp.Sessions[i].EntryPointPrefixes); ok {
+ resp.Sessions[i].Area = &label
+ }
+ }
+ }
}
s.wardrivingStatsMu.Lock()
diff --git a/cmd/server/types.go b/cmd/server/types.go
index 9c14bd90..ac1ac5bf 100644
--- a/cmd/server/types.go
+++ b/cmd/server/types.go
@@ -285,6 +285,19 @@ type WardrivingSession struct {
// TransmissionIDs is internal — the session's own transmission IDs,
// used by the route handler to compute AirtimeMs. Never serialized.
TransmissionIDs []int64 `json:"-"`
+ // EntryPointPrefixes is internal — the session's distinct path[0]
+ // hex prefixes, used by the route handler to resolve Area. Never
+ // serialized directly.
+ EntryPointPrefixes []string `json:"-"`
+ // Area is the most specific configured area containing the session's
+ // entry-point repeater — always approximate (the repeater's known
+ // position, not the sender's own; wardriving sessions never carry a
+ // literal shared GPS fix, unlike WardrivingGPSShare). Resolved by the
+ // route handler from EntryPointPrefixes when exactly one candidate
+ // node matches a prefix (same unique_prefix-only discipline as
+ // /api/resolve-hops) and that node has a known position. Nil when no
+ // prefix resolves unambiguously or areas aren't configured.
+ Area *string `json:"area,omitempty"`
}
// WardrivingGPSShare is one sender who has explicitly shared their own
diff --git a/cmd/server/wardriving_stats_test.go b/cmd/server/wardriving_stats_test.go
index 981098f0..fe6429b3 100644
--- a/cmd/server/wardriving_stats_test.go
+++ b/cmd/server/wardriving_stats_test.go
@@ -507,6 +507,113 @@ func TestHandleWardrivingStats_GPSShareArea(t *testing.T) {
}
}
+// TestHandleWardrivingStats_SessionArea covers approximating a session's
+// area from its entry-point repeater (path[0]) when the sender never shares
+// a literal GPS fix — must only trust a unique_prefix match, same discipline
+// as /api/resolve-hops, and must never guess when a prefix is ambiguous or
+// unresolvable.
+func TestHandleWardrivingStats_SessionArea(t *testing.T) {
+ srv, router := setupTestServer(t)
+ if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
+ t.Fatalf("clear transmissions: %v", err)
+ }
+ if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil {
+ t.Fatalf("clear observations: %v", err)
+ }
+
+ f := func(v float64) *float64 { return &v }
+ srv.cfg.Areas = map[string]AreaEntry{
+ "ODE": {Label: "Odense by", LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)},
+ }
+
+ // A repeater inside the configured area, resolvable only via a unique
+ // full-length prefix match (mirrors TestResolveHopsAPI_UniquePrefix).
+ if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
+ "ff11223344", "OdenseRepeater", 55.4047, 10.3810, "repeater"); err != nil {
+ t.Fatalf("insert node: %v", err)
+ }
+ // A second, ambiguous prefix: two nodes share it, so it must never
+ // resolve to an area even though one of them has a position.
+ srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
+ "ee1aaaaaaa", "AmbigA", 55.40, 10.38, "repeater")
+ srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
+ "ee1bbbbbbb", "AmbigB", 55.41, 10.39, "repeater")
+ srv.store.InvalidateNodeCache()
+
+ now := time.Now().UTC()
+ insertTx := func(hash, sender string, ts time.Time) int64 {
+ res, err := srv.db.conn.Exec(
+ `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`,
+ "aa", hash, ts.Format(time.RFC3339), `{"sender":"`+sender+`","text":"`+sender+`: MM:x"}`,
+ )
+ if err != nil {
+ t.Fatalf("insert tx %s: %v", hash, err)
+ }
+ id, _ := res.LastInsertId()
+ return id
+ }
+ seaIdx := int64(0)
+ res, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, "obsSEA", "SeattleObs", "SEA")
+ if err != nil {
+ t.Fatalf("insert observer: %v", err)
+ }
+ seaIdx, _ = res.LastInsertId()
+ insertObs := func(txID int64, pathJSON string) {
+ if _, err := srv.db.conn.Exec(
+ `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`,
+ txID, seaIdx, 1.0, -90.0, pathJSON, time.Now().Unix(),
+ ); err != nil {
+ t.Fatalf("insert observation: %v", err)
+ }
+ }
+
+ txResolvable := insertTx("area1", "InOdense", now.Add(-10*time.Minute))
+ insertObs(txResolvable, `["ff11223344"]`)
+
+ txAmbiguous := insertTx("area2", "Ambiguous", now.Add(-10*time.Minute))
+ insertObs(txAmbiguous, `["ee1"]`) // shared prefix of ee1aaaaaaa and ee1bbbbbbb -- 2 candidates
+
+ txNoMatch := insertTx("area3", "NoMatch", now.Add(-10*time.Minute))
+ insertObs(txNoMatch, `["deadbeef99"]`)
+
+ req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != 200 {
+ t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
+ }
+ var resp WardrivingStatsResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode: %v body=%s", err, w.Body.String())
+ }
+
+ bySender := map[string]WardrivingSession{}
+ for _, s := range resp.Sessions {
+ bySender[s.Sender] = s
+ }
+ inOdense, ok := bySender["InOdense"]
+ if !ok {
+ t.Fatal("InOdense session missing")
+ }
+ if inOdense.Area == nil || *inOdense.Area != "Odense by" {
+ t.Errorf("InOdense.Area = %v, want \"Odense by\" (unique_prefix match with a known position)", inOdense.Area)
+ }
+ ambiguous, ok := bySender["Ambiguous"]
+ if !ok {
+ t.Fatal("Ambiguous session missing")
+ }
+ if ambiguous.Area != nil {
+ t.Errorf("Ambiguous.Area = %v, want nil (prefix matches 2 candidates, must not guess)", *ambiguous.Area)
+ }
+ noMatch, ok := bySender["NoMatch"]
+ if !ok {
+ t.Fatal("NoMatch session missing")
+ }
+ if noMatch.Area != nil {
+ t.Errorf("NoMatch.Area = %v, want nil (prefix matches no known node)", *noMatch.Area)
+ }
+}
+
// TestHandleWardrivingStats_InvalidWindow mirrors the existing scope-stats
// window validation.
func TestHandleWardrivingStats_InvalidWindow(t *testing.T) {
diff --git a/public/analytics.js b/public/analytics.js
index 43e1b04a..d2883880 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -5545,16 +5545,20 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
}
var shown = expanded ? sessions : sessions.slice(0, TOP_N_LIMIT);
var rows = shown.map(function(s) {
+ var areaBadge = s.area
+ ? '' + esc(s.area) + ''
+ : '—';
return '
| ' + senderTriggerHtml(s.sender, s.startTime, s.endTime) + ' | ' +
'' + (typeof timeAgo === 'function' ? timeAgo(s.startTime) : s.startTime) + ' | ' +
'' + formatSessionDuration(s.durationMinutes) + ' | ' +
'' + s.messageCount.toLocaleString() + ' | ' +
'' + s.entryPointCount.toLocaleString() + ' | ' +
'' + s.observerCount.toLocaleString() + ' | ' +
+ '' + areaBadge + ' | ' +
'' + formatAirtimeMs(s.airtimeMs) + ' |
';
}).join('');
- return '' +
- '| Sender | Started | Duration | Messages | Entry Points | Observers | Airtime |
' +
+ return '' +
+ '| Sender | Started | Duration | Messages | Entry Points | Observers | Area | Airtime |
' +
'' + rows + '' +
'
' + topNToggleHtml(sessions.length, expanded, 'sessions');
}
diff --git a/test-analytics-wardriving-tab.js b/test-analytics-wardriving-tab.js
index c227c190..d281ed9f 100644
--- a/test-analytics-wardriving-tab.js
+++ b/test-analytics-wardriving-tab.js
@@ -351,6 +351,22 @@ function makeApiStub(wardrivingResp, resolveHopsResp) {
assert.ok(section.includes('— | '), 'a session with no airtime data (DB-only mode) should show a dash, not blank/null');
});
+ await testAsync('Sessions table shows the approximate entry-point area, and a dash when unresolved', async () => {
+ const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse({
+ sessions: [
+ { sender: 'Alice', startTime: '2026-07-20T09:00:00Z', endTime: '2026-07-20T09:00:00Z', durationMinutes: 0, messageCount: 1, entryPointCount: 1, observerCount: 1, airtimeMs: 245, area: 'Odense by' },
+ { sender: 'Bob', startTime: '2026-07-20T08:30:00Z', endTime: '2026-07-20T08:30:00Z', durationMinutes: 0, messageCount: 1, entryPointCount: 1, observerCount: 1, airtimeMs: null },
+ ],
+ })));
+ const el = fakeEl();
+ await ctx.window._analyticsRenderWardrivingTab(el);
+ const startIdx = el.innerHTML.indexOf('id="wardrivingSessions"');
+ const endIdx = el.innerHTML.indexOf('id="wardrivingEntryPoints"');
+ const section = el.innerHTML.slice(startIdx, endIdx);
+ assert.ok(section.includes('Area | '), 'Sessions table should have an Area column');
+ assert.ok(section.includes('>Odense by<'), 'a resolved area should render as a badge');
+ });
+
await testAsync('Entry Points resolves unique_prefix repeaters and folds ambiguous into one bucket', async () => {
const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse(), {
resolved: {