mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-04 10:11:41 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd2f81270a | |||
| e01b5f58c2 | |||
| d7195136fa | |||
| b380be33a3 | |||
| bb05b0ffc3 | |||
| c2d1f8d652 | |||
| 8fd5c786c8 | |||
| 4c3881c657 | |||
| bb19a1b3e1 | |||
| 022a3635d7 | |||
| b3d52e0174 | |||
| e8e223cf9e | |||
| 7caafb9811 | |||
| 047df38c4f | |||
| a58c21a894 | |||
| 67e52ebfcd | |||
| 4a016e442d | |||
| 5719b9e579 | |||
| e92a2333f2 | |||
| 1881c92d6e |
@@ -20,6 +20,7 @@ COPY internal/sigvalidate/ ../../internal/sigvalidate/
|
||||
COPY internal/packetpath/ ../../internal/packetpath/
|
||||
COPY internal/dbconfig/ ../../internal/dbconfig/
|
||||
COPY internal/perfio/ ../../internal/perfio/
|
||||
COPY internal/dbschema/ ../../internal/dbschema/
|
||||
RUN go mod download
|
||||
COPY cmd/server/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
@@ -33,6 +34,7 @@ COPY internal/sigvalidate/ ../../internal/sigvalidate/
|
||||
COPY internal/packetpath/ ../../internal/packetpath/
|
||||
COPY internal/dbconfig/ ../../internal/dbconfig/
|
||||
COPY internal/perfio/ ../../internal/perfio/
|
||||
COPY internal/dbschema/ ../../internal/dbschema/
|
||||
RUN go mod download
|
||||
COPY cmd/ingestor/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
|
||||
+65
-2
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -447,6 +448,68 @@ func (c *Config) IsBlacklisted(pubkey string) bool {
|
||||
return c.blacklistSet()[strings.ToLower(strings.TrimSpace(pubkey))]
|
||||
}
|
||||
|
||||
// SaveGeoFilter writes the geo_filter section back to config.json on disk.
|
||||
// Pass gf=nil to remove the filter. The rest of config.json is preserved as-is.
|
||||
func SaveGeoFilter(configDir string, gf *GeoFilterConfig) error {
|
||||
var configPath string
|
||||
for _, p := range []string{
|
||||
filepath.Join(configDir, "config.json"),
|
||||
filepath.Join(configDir, "data", "config.json"),
|
||||
} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
configPath = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if configPath == "" {
|
||||
return fmt.Errorf("config.json not found in %s", configDir)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
// Parse as a raw map so non-struct fields (_comment, etc.) are preserved.
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
if gf == nil || len(gf.Polygon) == 0 {
|
||||
delete(raw, "geo_filter")
|
||||
} else {
|
||||
// Round-trip through JSON to get a plain interface{} value.
|
||||
b, _ := json.Marshal(gf)
|
||||
var v interface{}
|
||||
_ = json.Unmarshal(b, &v)
|
||||
raw["geo_filter"] = v
|
||||
}
|
||||
|
||||
out, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
out = append(out, '\n')
|
||||
|
||||
// Preserve the original file mode so operators' chmod 0600 survives the write.
|
||||
origMode := os.FileMode(0644)
|
||||
if fi, err := os.Stat(configPath); err == nil {
|
||||
origMode = fi.Mode().Perm()
|
||||
}
|
||||
|
||||
// Atomic write: temp file + rename.
|
||||
tmp := configPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, out, origMode); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, configPath); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("rename config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// obsBlacklistSet lazily builds and caches the observerBlacklist as a set for O(1) lookups.
|
||||
func (c *Config) obsBlacklistSet() map[string]bool {
|
||||
c.obsBlacklistOnce.Do(func() {
|
||||
@@ -485,8 +548,8 @@ func (c *Config) IsObserverBlacklisted(id string) bool {
|
||||
// RecomputeIntervalSeconds keys (all optional):
|
||||
// topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew
|
||||
type AnalyticsConfig struct {
|
||||
DefaultIntervalSeconds int `json:"defaultIntervalSeconds,omitempty"`
|
||||
RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"`
|
||||
DefaultIntervalSeconds int `json:"defaultIntervalSeconds,omitempty"`
|
||||
RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"`
|
||||
}
|
||||
|
||||
// AnalyticsDefaultRecomputeInterval returns the configured default
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -387,3 +388,25 @@ func TestObserverDaysOrDefault(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveGeoFilter_PreservesFileMode(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix file permissions not supported on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"port":3000}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gf := &GeoFilterConfig{Polygon: [][2]float64{{1, 2}, {3, 4}, {5, 6}}, BufferKm: 0}
|
||||
if err := SaveGeoFilter(dir, gf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0600 {
|
||||
t.Errorf("file mode downgraded: want 0600, got %04o", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,6 +273,7 @@ func main() {
|
||||
|
||||
// HTTP server
|
||||
srv := NewServer(database, cfg, hub)
|
||||
srv.configDir = configDir
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
+91
-8
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"runtime"
|
||||
@@ -25,12 +26,20 @@ type Server struct {
|
||||
cfg *Config
|
||||
hub *Hub
|
||||
store *PacketStore // in-memory packet store (nil = fallback to DB)
|
||||
configDir string // directory containing config.json (for write-back)
|
||||
startedAt time.Time
|
||||
perfStats *PerfStats
|
||||
version string
|
||||
commit string
|
||||
buildTime string
|
||||
|
||||
// Guards s.cfg.GeoFilter — read by ingest/handler goroutines, written by PUT handler
|
||||
cfgMu sync.RWMutex
|
||||
|
||||
// Serializes concurrent PUT /api/config/geo-filter disk writes so requests
|
||||
// can't race on the .tmp file or interleave disk/memory updates.
|
||||
saveMu sync.Mutex
|
||||
|
||||
// Cached runtime.MemStats to avoid stop-the-world pauses on every health check
|
||||
memStatsMu sync.Mutex
|
||||
memStatsCache runtime.MemStats
|
||||
@@ -59,6 +68,18 @@ type PerfStats struct {
|
||||
StartedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Server) getGeoFilter() *GeoFilterConfig {
|
||||
s.cfgMu.RLock()
|
||||
defer s.cfgMu.RUnlock()
|
||||
return s.cfg.GeoFilter
|
||||
}
|
||||
|
||||
func (s *Server) setGeoFilter(gf *GeoFilterConfig) {
|
||||
s.cfgMu.Lock()
|
||||
defer s.cfgMu.Unlock()
|
||||
s.cfg.GeoFilter = gf
|
||||
}
|
||||
|
||||
type EndpointPerf struct {
|
||||
Count int
|
||||
TotalMs float64
|
||||
@@ -120,6 +141,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/config/theme", s.handleConfigTheme).Methods("GET")
|
||||
r.HandleFunc("/api/config/map", s.handleConfigMap).Methods("GET")
|
||||
r.HandleFunc("/api/config/geo-filter", s.handleConfigGeoFilter).Methods("GET")
|
||||
r.Handle("/api/config/geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePutConfigGeoFilter))).Methods("PUT")
|
||||
|
||||
// Readiness endpoint (gated on background init completion)
|
||||
r.HandleFunc("/api/healthz", s.handleHealthz).Methods("GET")
|
||||
@@ -444,7 +466,11 @@ func (s *Server) handleConfigMap(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleConfigGeoFilter(w http.ResponseWriter, r *http.Request) {
|
||||
gf := s.cfg.GeoFilter
|
||||
gf := s.getGeoFilter()
|
||||
// NOTE: do NOT include any field that derives from APIKey presence/strength here.
|
||||
// This endpoint is intentionally public; leaking whether a write-capable key is
|
||||
// configured is an info-disclosure. Clients that want to write should just try
|
||||
// PUT and handle 401/403. See PR #736 review.
|
||||
if gf == nil || len(gf.Polygon) == 0 {
|
||||
writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0})
|
||||
return
|
||||
@@ -452,6 +478,66 @@ func (s *Server) handleConfigGeoFilter(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm})
|
||||
}
|
||||
|
||||
func (s *Server) handlePutConfigGeoFilter(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB cap
|
||||
|
||||
var body struct {
|
||||
Polygon [][2]float64 `json:"polygon"`
|
||||
BufferKm float64 `json:"bufferKm"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
// Allow clearing (empty/null polygon) or a valid polygon with ≥ 3 points.
|
||||
if len(body.Polygon) > 0 && len(body.Polygon) < 3 {
|
||||
writeError(w, http.StatusBadRequest, "polygon must have at least 3 points")
|
||||
return
|
||||
}
|
||||
if len(body.Polygon) > 1000 {
|
||||
writeError(w, http.StatusBadRequest, "polygon must have at most 1000 points")
|
||||
return
|
||||
}
|
||||
for _, pt := range body.Polygon {
|
||||
if math.IsNaN(pt[0]) || math.IsNaN(pt[1]) || math.IsInf(pt[0], 0) || math.IsInf(pt[1], 0) ||
|
||||
pt[0] < -90 || pt[0] > 90 || pt[1] < -180 || pt[1] > 180 {
|
||||
writeError(w, http.StatusBadRequest, "polygon point out of range: lat must be in [-90,90], lon in [-180,180]")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// bufferKm must be finite, non-negative, and ≤ 20000 km (half Earth circumference).
|
||||
if math.IsNaN(body.BufferKm) || math.IsInf(body.BufferKm, 0) ||
|
||||
body.BufferKm < 0 || body.BufferKm > 20000 {
|
||||
writeError(w, http.StatusBadRequest, "bufferKm must be a finite number in [0, 20000]")
|
||||
return
|
||||
}
|
||||
|
||||
var gf *GeoFilterConfig
|
||||
if len(body.Polygon) >= 3 {
|
||||
gf = &GeoFilterConfig{Polygon: body.Polygon, BufferKm: body.BufferKm}
|
||||
}
|
||||
|
||||
s.saveMu.Lock()
|
||||
if s.configDir != "" {
|
||||
if err := SaveGeoFilter(s.configDir, gf); err != nil {
|
||||
s.saveMu.Unlock()
|
||||
log.Printf("[geofilter] save failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to save config")
|
||||
return
|
||||
}
|
||||
}
|
||||
s.setGeoFilter(gf)
|
||||
s.saveMu.Unlock()
|
||||
|
||||
if gf != nil {
|
||||
writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm})
|
||||
} else {
|
||||
writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0})
|
||||
}
|
||||
}
|
||||
|
||||
// --- System Handlers ---
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1145,20 +1231,17 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.cfg.GeoFilter != nil {
|
||||
if gf := s.getGeoFilter(); gf != nil {
|
||||
filtered := nodes[:0]
|
||||
for _, node := range nodes {
|
||||
// Foreign-flagged nodes (#730) are kept even when their GPS lies
|
||||
// outside the geofilter polygon — that's the whole point of the
|
||||
// flag: operators need to SEE bridged/leaked nodes, not have them
|
||||
// filtered away. The ingestor sets foreign_advert=1 when its
|
||||
// configured geo_filter rejected the advert; the server must
|
||||
// surface those.
|
||||
// outside the geofilter polygon — operators need to SEE bridged/
|
||||
// leaked nodes, not have them filtered away.
|
||||
if isForeign, _ := node["foreign"].(bool); isForeign {
|
||||
filtered = append(filtered, node)
|
||||
continue
|
||||
}
|
||||
if NodePassesGeoFilter(node["lat"], node["lon"], s.cfg.GeoFilter) {
|
||||
if NodePassesGeoFilter(node["lat"], node["lon"], gf) {
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -2839,6 +2843,32 @@ func TestConfigGeoFilterEndpoint(t *testing.T) {
|
||||
if body["bufferKm"] == nil {
|
||||
t.Error("expected bufferKm in response")
|
||||
}
|
||||
// writeEnabled must NOT be leaked: the public GET endpoint should not
|
||||
// disclose whether a strong apiKey is configured to unauthenticated callers.
|
||||
if _, ok := body["writeEnabled"]; ok {
|
||||
t.Errorf("writeEnabled must not be present in public GET response (info disclosure), got %v", body["writeEnabled"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("writeEnabled is not exposed even when strong apiKey configured", func(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
cfg := &Config{Port: 3000, APIKey: "a-strong-api-key-1234"}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
srv.store = NewPacketStore(db, nil)
|
||||
srv.store.Load()
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/config/geo-filter", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if _, ok := body["writeEnabled"]; ok {
|
||||
t.Errorf("writeEnabled must not be present (would leak apiKey presence), got %v", body["writeEnabled"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3973,3 +4003,262 @@ func TestPacketDetailPrefersStoreOverDB(t *testing.T) {
|
||||
t.Errorf("expected observation_count=2 (from store), got %v", body["observation_count"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- geo-filter write-back tests ---
|
||||
|
||||
func setupGeoFilterServer(t *testing.T, apiKey string) (*Server, *mux.Router, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfgJSON := `{"port":3000,"apiKey":"` + apiKey + `"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(cfgJSON), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
cfg := &Config{Port: 3000, APIKey: apiKey}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
srv.configDir = dir
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
return srv, router, dir
|
||||
}
|
||||
|
||||
func TestPutConfigGeoFilter(t *testing.T) {
|
||||
const apiKey = "a-strong-api-key-for-testing"
|
||||
|
||||
t.Run("saves valid polygon and updates in-memory config", func(t *testing.T) {
|
||||
srv, router, dir := setupGeoFilterServer(t, apiKey)
|
||||
|
||||
body := `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,5.0],[50.5,4.0]],"bufferKm":15}`
|
||||
req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body))
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// In-memory config updated
|
||||
if srv.cfg.GeoFilter == nil {
|
||||
t.Fatal("expected in-memory GeoFilter to be set")
|
||||
}
|
||||
if len(srv.cfg.GeoFilter.Polygon) != 4 {
|
||||
t.Errorf("expected 4 polygon points, got %d", len(srv.cfg.GeoFilter.Polygon))
|
||||
}
|
||||
if srv.cfg.GeoFilter.BufferKm != 15 {
|
||||
t.Errorf("expected bufferKm=15, got %v", srv.cfg.GeoFilter.BufferKm)
|
||||
}
|
||||
|
||||
// config.json updated on disk
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "config.json"))
|
||||
if !bytes.Contains(data, []byte("geo_filter")) {
|
||||
t.Error("expected geo_filter key in saved config.json")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clears filter when polygon is empty", func(t *testing.T) {
|
||||
srv, router, dir := setupGeoFilterServer(t, apiKey)
|
||||
// Pre-set a filter so we can clear it
|
||||
srv.setGeoFilter(&GeoFilterConfig{Polygon: [][2]float64{{51.0, 4.0}, {51.0, 5.0}, {50.5, 4.0}}, BufferKm: 10})
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(`{"polygon":null}`))
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if srv.cfg.GeoFilter != nil {
|
||||
t.Error("expected in-memory GeoFilter to be cleared")
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "config.json"))
|
||||
if bytes.Contains(data, []byte("geo_filter")) {
|
||||
t.Error("expected geo_filter to be removed from config.json")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects polygon with fewer than 3 points", func(t *testing.T) {
|
||||
_, router, _ := setupGeoFilterServer(t, apiKey)
|
||||
|
||||
body := `{"polygon":[[51.0,4.0],[51.0,5.0]],"bufferKm":0}`
|
||||
req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body))
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects out-of-range coordinates", func(t *testing.T) {
|
||||
_, router, _ := setupGeoFilterServer(t, apiKey)
|
||||
|
||||
body := `{"polygon":[[91.0,4.0],[51.0,5.0],[50.5,4.0]],"bufferKm":0}`
|
||||
req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body))
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for out-of-range lat, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects polygon exceeding 1000 points", func(t *testing.T) {
|
||||
_, router, _ := setupGeoFilterServer(t, apiKey)
|
||||
|
||||
pts := make([][2]float64, 1001)
|
||||
for i := range pts {
|
||||
pts[i] = [2]float64{51.0 + float64(i)*0.0001, 4.0}
|
||||
}
|
||||
b, _ := json.Marshal(map[string]interface{}{"polygon": pts, "bufferKm": 0})
|
||||
req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(string(b)))
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for oversized polygon, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects missing API key", func(t *testing.T) {
|
||||
_, router, _ := setupGeoFilterServer(t, apiKey)
|
||||
|
||||
body := `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,4.0]],"bufferKm":0}`
|
||||
req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects negative bufferKm", func(t *testing.T) {
|
||||
_, router, _ := setupGeoFilterServer(t, apiKey)
|
||||
body := `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,4.0]],"bufferKm":-1}`
|
||||
req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body))
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for negative bufferKm, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects excessive bufferKm", func(t *testing.T) {
|
||||
_, router, _ := setupGeoFilterServer(t, apiKey)
|
||||
body := `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,4.0]],"bufferKm":99999999}`
|
||||
req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body))
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for excessive bufferKm, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPutConfigGeoFilter_ConcurrentSafe(t *testing.T) {
|
||||
const apiKey = "a-strong-api-key-for-testing"
|
||||
srv, router, dir := setupGeoFilterServer(t, apiKey)
|
||||
_ = srv
|
||||
|
||||
const n = 10
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
lat := 51.0 + float64(i)*0.001
|
||||
body := fmt.Sprintf(`{"polygon":[[%f,4.0],[%f,5.0],[50.5,4.0]],"bufferKm":0}`, lat, lat)
|
||||
req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body))
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
errs <- fmt.Sprintf("goroutine %d: got %d: %s", i, w.Code, w.Body.String())
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for e := range errs {
|
||||
t.Error(e)
|
||||
}
|
||||
// config.json must be valid JSON after concurrent writes
|
||||
data, err := os.ReadFile(filepath.Join(dir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var v interface{}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
t.Errorf("config.json corrupted after concurrent PUTs: %v\ncontents: %s", err, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveGeoFilter(t *testing.T) {
|
||||
t.Run("saves and reads back", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"port":3000}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gf := &GeoFilterConfig{
|
||||
Polygon: [][2]float64{{51.0, 4.0}, {51.0, 5.0}, {50.5, 4.0}},
|
||||
BufferKm: 20,
|
||||
}
|
||||
if err := SaveGeoFilter(dir, gf); err != nil {
|
||||
t.Fatalf("SaveGeoFilter: %v", err)
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "config.json"))
|
||||
if !bytes.Contains(data, []byte("geo_filter")) {
|
||||
t.Error("expected geo_filter in saved config")
|
||||
}
|
||||
if !bytes.Contains(data, []byte(`"bufferKm"`)) {
|
||||
t.Error("expected bufferKm in saved config")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("removes geo_filter key when gf is nil", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
initial := `{"port":3000,"geo_filter":{"polygon":[[1,2],[3,4],[5,6]],"bufferKm":5}}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(initial), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveGeoFilter(dir, nil); err != nil {
|
||||
t.Fatalf("SaveGeoFilter: %v", err)
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "config.json"))
|
||||
if bytes.Contains(data, []byte("geo_filter")) {
|
||||
t.Error("expected geo_filter to be removed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error when config.json not found", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := SaveGeoFilter(dir, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error when config.json not found")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+1
-1
@@ -175,7 +175,7 @@
|
||||
[37.20, -122.52]
|
||||
],
|
||||
"bufferKm": 20,
|
||||
"_comment": "Optional. Restricts ingestion and API responses to nodes within the polygon + bufferKm. Polygon is an array of [lat, lon] pairs (minimum 3). Use the GeoFilter Builder (`/geofilter-builder.html`) to draw a polygon, save drafts to localStorage with Save Draft, and export a config snippet with Download — paste the snippet here as the `geo_filter` block. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through."
|
||||
"_comment": "Optional. Restricts ingestion and API responses to nodes within the polygon + bufferKm. Polygon is an array of [lat, lon] pairs (minimum 3). Use the GeoFilter tab in the Customizer (requires apiKey) or the GeoFilter Builder (`/geofilter-builder.html`) to draw a polygon visually. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through."
|
||||
},
|
||||
"foreignAdverts": {
|
||||
"mode": "flag",
|
||||
|
||||
@@ -1,674 +0,0 @@
|
||||
# Deep Linking P1 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make P1 UI states in nodes, packets, and channels URL-addressable so they survive refresh and can be shared.
|
||||
|
||||
**Architecture:** Each page reads URL params from `location.hash.split('?')[1]` on init (router strips query string before passing `routeParam`, so pages must read `location.hash` directly). State changes call `history.replaceState` to keep the URL in sync. localStorage remains the fallback default; URL params override when present.
|
||||
|
||||
**Tech Stack:** Vanilla JS (ES5/6), browser History API, URLSearchParams
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `public/region-filter.js` | Add `setSelected(codesArray)`, track `_container` for re-render |
|
||||
| `public/nodes.js` | Read `?tab=`/`?search=` on init; `updateNodesUrl()` on tab/search change; expose `buildNodesQuery` on `window` |
|
||||
| `public/packets.js` | Read `?timeWindow=`/`?region=` on init; `updatePacketsUrl()` on timeWindow/region change; expose `buildPacketsUrl` on `window` |
|
||||
| `public/channels.js` | Read `?node=` on init; update URL in `showNodeDetail`/`closeNodeDetail` |
|
||||
| `test-frontend-helpers.js` | Add unit tests for `buildNodesQuery` and `buildPacketsUrl` |
|
||||
| `test-e2e-playwright.js` | Add Playwright tests: tab URL persistence, timeWindow URL persistence |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `setSelected` to RegionFilter
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/region-filter.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing unit test**
|
||||
|
||||
Add to `test-frontend-helpers.js` before the `// ===== SUMMARY =====` line:
|
||||
|
||||
```javascript
|
||||
// ===== REGION-FILTER.JS: setSelected =====
|
||||
console.log('\n=== region-filter.js: setSelected ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
ctx.fetch = () => Promise.resolve({ json: () => Promise.resolve({ 'US-SFO': 'San Jose', 'US-LAX': 'Los Angeles' }) });
|
||||
loadInCtx(ctx, 'public/region-filter.js');
|
||||
|
||||
const RF = ctx.RegionFilter;
|
||||
RF.init(document.createElement('div'));
|
||||
|
||||
test('setSelected sets region codes', async () => {
|
||||
await RF.init(document.createElement('div'));
|
||||
RF.setSelected(['US-SFO', 'US-LAX']);
|
||||
assert.strictEqual(RF.getRegionParam(), 'US-SFO,US-LAX');
|
||||
});
|
||||
|
||||
test('setSelected with null clears selection', async () => {
|
||||
await RF.init(document.createElement('div'));
|
||||
RF.setSelected(['US-SFO']);
|
||||
RF.setSelected(null);
|
||||
assert.strictEqual(RF.getRegionParam(), '');
|
||||
});
|
||||
|
||||
test('setSelected with empty array clears selection', async () => {
|
||||
await RF.init(document.createElement('div'));
|
||||
RF.setSelected(['US-SFO']);
|
||||
RF.setSelected([]);
|
||||
assert.strictEqual(RF.getRegionParam(), '');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -A2 "setSelected"
|
||||
```
|
||||
|
||||
Expected: `❌ setSelected sets region codes: RF.setSelected is not a function`
|
||||
|
||||
- [ ] **Step 3: Add `_container` tracking and `setSelected` to region-filter.js**
|
||||
|
||||
In `region-filter.js`, add `var _container = null;` after the existing module-level vars (after line 9 `var _listeners = [];`):
|
||||
|
||||
```javascript
|
||||
var _listeners = [];
|
||||
var _container = null; // ← add this line
|
||||
var _loaded = false;
|
||||
```
|
||||
|
||||
In `initFilter`, save the container:
|
||||
|
||||
```javascript
|
||||
async function initFilter(container, opts) {
|
||||
_container = container; // ← add this line
|
||||
if (opts && opts.dropdown) container._forceDropdown = true;
|
||||
await fetchRegions();
|
||||
render(container);
|
||||
}
|
||||
```
|
||||
|
||||
Add `setSelected` function before `// Expose globally`:
|
||||
|
||||
```javascript
|
||||
/** Override selected regions (e.g. from URL param). Persists to localStorage and re-renders. */
|
||||
function setSelected(codesArray) {
|
||||
_selected = (codesArray && codesArray.length > 0) ? new Set(codesArray) : null;
|
||||
saveToStorage();
|
||||
if (_container) render(_container);
|
||||
}
|
||||
```
|
||||
|
||||
Add `setSelected` to the public API object:
|
||||
|
||||
```javascript
|
||||
window.RegionFilter = {
|
||||
init: initFilter,
|
||||
render: render,
|
||||
getSelected: getSelected,
|
||||
getRegionParam: getRegionParam,
|
||||
regionQueryString: regionQueryString,
|
||||
onChange: onChange,
|
||||
offChange: offChange,
|
||||
fetchRegions: fetchRegions,
|
||||
setSelected: setSelected, // ← add this line
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -E "(setSelected|FAIL|passed|failed)"
|
||||
```
|
||||
|
||||
Expected: 3 passing `setSelected` tests, overall pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add public/region-filter.js test-frontend-helpers.js
|
||||
git commit -m "feat: add RegionFilter.setSelected for URL param initialization (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: nodes.js — tab and search deep linking
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/nodes.js`
|
||||
- Test: `test-frontend-helpers.js`
|
||||
- Test: `test-e2e-playwright.js`
|
||||
|
||||
- [ ] **Step 1: Write the unit test (add to test-frontend-helpers.js)**
|
||||
|
||||
Add before the `// ===== SUMMARY =====` line:
|
||||
|
||||
```javascript
|
||||
// ===== NODES.JS: buildNodesQuery =====
|
||||
console.log('\n=== nodes.js: buildNodesQuery ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
|
||||
// Provide required globals for nodes.js IIFE to execute
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '' };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.debouncedOnWS = () => () => {};
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.favStar = () => '';
|
||||
ctx.bindFavStars = () => {};
|
||||
ctx.getFavorites = () => [];
|
||||
ctx.isFavorite = () => false;
|
||||
ctx.connectWS = () => {};
|
||||
ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false };
|
||||
ctx.initTabBar = () => {};
|
||||
ctx.debounce = (fn) => fn;
|
||||
ctx.copyToClipboard = () => {};
|
||||
ctx.api = () => Promise.resolve({});
|
||||
ctx.escapeHtml = (s) => s;
|
||||
ctx.timeAgo = () => '';
|
||||
ctx.formatTimestampWithTooltip = () => '';
|
||||
ctx.getTimestampMode = () => 'ago';
|
||||
ctx.CLIENT_TTL = {};
|
||||
ctx.qrcode = null;
|
||||
|
||||
try {
|
||||
const src = fs.readFileSync('public/nodes.js', 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
} catch (e) {
|
||||
console.log(' ⚠️ nodes.js sandbox load failed:', e.message.slice(0, 120));
|
||||
}
|
||||
|
||||
const buildNodesQuery = ctx.buildNodesQuery;
|
||||
|
||||
if (buildNodesQuery) {
|
||||
test('buildNodesQuery: all tab + no search = empty', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', ''), '');
|
||||
});
|
||||
test('buildNodesQuery: repeater tab only', () => {
|
||||
assert.strictEqual(buildNodesQuery('repeater', ''), '?tab=repeater');
|
||||
});
|
||||
test('buildNodesQuery: search only (all tab)', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', 'foo'), '?search=foo');
|
||||
});
|
||||
test('buildNodesQuery: tab + search combined', () => {
|
||||
assert.strictEqual(buildNodesQuery('companion', 'bar'), '?tab=companion&search=bar');
|
||||
});
|
||||
test('buildNodesQuery: null search treated as empty', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', null), '');
|
||||
});
|
||||
test('buildNodesQuery: sensor tab', () => {
|
||||
assert.strictEqual(buildNodesQuery('sensor', ''), '?tab=sensor');
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠️ buildNodesQuery not exposed — skipping');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails (or skips)**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -A3 "buildNodesQuery"
|
||||
```
|
||||
|
||||
Expected: `⚠️ buildNodesQuery not exposed — skipping`
|
||||
|
||||
- [ ] **Step 3: Add URL param reading and helpers to nodes.js**
|
||||
|
||||
**3a.** Add `buildNodesQuery` and `updateNodesUrl` functions inside the nodes.js IIFE, after the `TABS` definition (around line 86, before `function renderNodeTimestampHtml`):
|
||||
|
||||
```javascript
|
||||
function buildNodesQuery(tab, searchStr) {
|
||||
var parts = [];
|
||||
if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab));
|
||||
if (searchStr) parts.push('search=' + encodeURIComponent(searchStr));
|
||||
return parts.length ? '?' + parts.join('&') : '';
|
||||
}
|
||||
window.buildNodesQuery = buildNodesQuery;
|
||||
|
||||
function updateNodesUrl() {
|
||||
history.replaceState(null, '', '#/nodes' + buildNodesQuery(activeTab, search));
|
||||
}
|
||||
```
|
||||
|
||||
**3b.** In the list-view branch of `init` (after the `return;` that ends the full-screen block at line 317), add URL param reading before `app.innerHTML`:
|
||||
|
||||
```javascript
|
||||
// Read URL params for list view (router strips query string from routeParam)
|
||||
const _listUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
const _urlTab = _listUrlParams.get('tab');
|
||||
const _urlSearch = _listUrlParams.get('search');
|
||||
if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab;
|
||||
if (_urlSearch) search = _urlSearch;
|
||||
|
||||
app.innerHTML = `<div class="nodes-page">
|
||||
```
|
||||
|
||||
**3c.** After `app.innerHTML = ...` (after the closing backtick at line ~330), populate the search input:
|
||||
|
||||
```javascript
|
||||
if (search) {
|
||||
var _si = document.getElementById('nodeSearch');
|
||||
if (_si) _si.value = search;
|
||||
}
|
||||
```
|
||||
|
||||
**3d.** In the search input event listener (around line 335), add `updateNodesUrl()`:
|
||||
|
||||
```javascript
|
||||
document.getElementById('nodeSearch').addEventListener('input', debounce(e => {
|
||||
search = e.target.value;
|
||||
updateNodesUrl();
|
||||
loadNodes();
|
||||
}, 250));
|
||||
```
|
||||
|
||||
**3e.** In the tab click handler inside `renderLeft` (around line 875), add `updateNodesUrl()`:
|
||||
|
||||
```javascript
|
||||
btn.addEventListener('click', () => { activeTab = btn.dataset.tab; updateNodesUrl(); loadNodes(); });
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run unit tests**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -E "(buildNodesQuery|✅|❌)" | grep -v "helpers"
|
||||
```
|
||||
|
||||
Expected: 6 passing `buildNodesQuery` tests.
|
||||
|
||||
- [ ] **Step 5: Write Playwright test (add to test-e2e-playwright.js)**
|
||||
|
||||
Add before the closing `await browser.close()` line:
|
||||
|
||||
```javascript
|
||||
// --- Group: Deep linking (#536) ---
|
||||
|
||||
// Test: nodes tab deep link
|
||||
await test('Nodes tab deep link restores active tab', async () => {
|
||||
await page.goto(BASE + '#/nodes?tab=repeater', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.node-tab', { timeout: 8000 });
|
||||
const activeTab = await page.$('.node-tab.active');
|
||||
assert(activeTab, 'No active tab found');
|
||||
const tabText = await activeTab.textContent();
|
||||
assert(tabText.includes('Repeater'), `Expected Repeater tab active, got: ${tabText}`);
|
||||
const url = page.url();
|
||||
assert(url.includes('tab=repeater'), `URL should contain tab=repeater, got: ${url}`);
|
||||
});
|
||||
|
||||
// Test: nodes tab click updates URL
|
||||
await test('Nodes tab click updates URL', async () => {
|
||||
await page.goto(BASE + '#/nodes', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.node-tab', { timeout: 8000 });
|
||||
const roomTab = await page.$('.node-tab[data-tab="room"]');
|
||||
if (roomTab) {
|
||||
await roomTab.click();
|
||||
await page.waitForTimeout(300);
|
||||
const url = page.url();
|
||||
assert(url.includes('tab=room'), `URL should contain tab=room after click, got: ${url}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run full test suite**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add public/nodes.js test-frontend-helpers.js test-e2e-playwright.js
|
||||
git commit -m "feat: deep link nodes tab and search query (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: packets.js — timeWindow and region deep linking
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/packets.js`
|
||||
- Test: `test-frontend-helpers.js`
|
||||
- Test: `test-e2e-playwright.js`
|
||||
|
||||
> Depends on Task 1 (RegionFilter.setSelected).
|
||||
|
||||
- [ ] **Step 1: Write the unit test**
|
||||
|
||||
Add to `test-frontend-helpers.js` before `// ===== SUMMARY =====`:
|
||||
|
||||
```javascript
|
||||
// ===== PACKETS.JS: buildPacketsUrl =====
|
||||
console.log('\n=== packets.js: buildPacketsUrl ===');
|
||||
{
|
||||
// Test the pure helper function
|
||||
// (loaded via packets.js after it exposes window.buildPacketsUrl)
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '', setSelected: () => {} };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.debouncedOnWS = () => () => {};
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.api = () => Promise.resolve({});
|
||||
ctx.observerMap = new Map();
|
||||
ctx.getParsedPath = () => [];
|
||||
ctx.getParsedDecoded = () => ({});
|
||||
ctx.clearParsedCache = () => {};
|
||||
ctx.escapeHtml = (s) => s;
|
||||
ctx.timeAgo = () => '';
|
||||
ctx.formatTimestampWithTooltip = () => '';
|
||||
ctx.getTimestampMode = () => 'ago';
|
||||
ctx.copyToClipboard = () => {};
|
||||
ctx.CLIENT_TTL = {};
|
||||
ctx.debounce = (fn) => fn;
|
||||
ctx.initTabBar = () => {};
|
||||
|
||||
try {
|
||||
const src = fs.readFileSync('public/packet-helpers.js', 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
const src2 = fs.readFileSync('public/packets.js', 'utf8');
|
||||
vm.runInContext(src2, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
} catch (e) {
|
||||
console.log(' ⚠️ packets.js sandbox load failed:', e.message.slice(0, 120));
|
||||
}
|
||||
|
||||
const buildPacketsUrl = ctx.buildPacketsUrl;
|
||||
|
||||
if (buildPacketsUrl) {
|
||||
test('buildPacketsUrl: default (15min, no region) = bare #/packets', () => {
|
||||
assert.strictEqual(buildPacketsUrl(15, ''), '#/packets');
|
||||
});
|
||||
test('buildPacketsUrl: non-default timeWindow', () => {
|
||||
assert.strictEqual(buildPacketsUrl(60, ''), '#/packets?timeWindow=60');
|
||||
});
|
||||
test('buildPacketsUrl: region only', () => {
|
||||
assert.strictEqual(buildPacketsUrl(15, 'US-SFO'), '#/packets?region=US-SFO');
|
||||
});
|
||||
test('buildPacketsUrl: timeWindow + region', () => {
|
||||
assert.strictEqual(buildPacketsUrl(30, 'US-SFO,US-LAX'), '#/packets?timeWindow=30®ion=US-SFO%2CUS-LAX');
|
||||
});
|
||||
test('buildPacketsUrl: timeWindow=0 treated as default', () => {
|
||||
assert.strictEqual(buildPacketsUrl(0, ''), '#/packets');
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠️ buildPacketsUrl not exposed — skipping');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it skips**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -A2 "buildPacketsUrl"
|
||||
```
|
||||
|
||||
Expected: `⚠️ buildPacketsUrl not exposed — skipping`
|
||||
|
||||
- [ ] **Step 3: Add helpers and URL param reading to packets.js**
|
||||
|
||||
**3a.** Add `buildPacketsUrl` and `updatePacketsUrl` inside the packets.js IIFE, after the existing constants at the top (around line 36, after `let showHexHashes`):
|
||||
|
||||
```javascript
|
||||
function buildPacketsUrl(timeWindowMin, regionParam) {
|
||||
var parts = [];
|
||||
if (timeWindowMin && timeWindowMin !== 15) parts.push('timeWindow=' + timeWindowMin);
|
||||
if (regionParam) parts.push('region=' + encodeURIComponent(regionParam));
|
||||
return '#/packets' + (parts.length ? '?' + parts.join('&') : '');
|
||||
}
|
||||
window.buildPacketsUrl = buildPacketsUrl;
|
||||
|
||||
function updatePacketsUrl() {
|
||||
history.replaceState(null, '', buildPacketsUrl(savedTimeWindowMin, RegionFilter.getRegionParam()));
|
||||
}
|
||||
```
|
||||
|
||||
**3b.** In the `init` function (around line 263), add URL param reading after the existing `routeParam`/`directObsId` parsing and before `app.innerHTML`:
|
||||
|
||||
```javascript
|
||||
// Read URL params for filter state (router strips query from routeParam; read from location.hash)
|
||||
var _initUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
var _urlTimeWindow = Number(_initUrlParams.get('timeWindow'));
|
||||
if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) {
|
||||
savedTimeWindowMin = _urlTimeWindow;
|
||||
localStorage.setItem('meshcore-time-window', String(_urlTimeWindow));
|
||||
}
|
||||
var _urlRegion = _initUrlParams.get('region');
|
||||
if (_urlRegion) {
|
||||
RegionFilter.setSelected(_urlRegion.split(',').filter(Boolean));
|
||||
}
|
||||
|
||||
app.innerHTML = `<div class="split-layout detail-collapsed">
|
||||
```
|
||||
|
||||
**3c.** In the time window change handler (around line 865), add `updatePacketsUrl()`:
|
||||
|
||||
```javascript
|
||||
fTimeWindow.addEventListener('change', () => {
|
||||
savedTimeWindowMin = Number(fTimeWindow.value);
|
||||
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15;
|
||||
localStorage.setItem('meshcore-time-window', fTimeWindow.value);
|
||||
updatePacketsUrl();
|
||||
loadPackets();
|
||||
});
|
||||
```
|
||||
|
||||
**3d.** In the RegionFilter.onChange callback (around line 719), add `updatePacketsUrl()`:
|
||||
|
||||
```javascript
|
||||
RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); });
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run unit tests**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -E "(buildPacketsUrl|✅|❌)" | grep -v "helpers"
|
||||
```
|
||||
|
||||
Expected: 5 passing `buildPacketsUrl` tests.
|
||||
|
||||
- [ ] **Step 5: Write Playwright test (add to test-e2e-playwright.js, inside the deep-linking group)**
|
||||
|
||||
```javascript
|
||||
// Test: packets timeWindow deep link
|
||||
await test('Packets timeWindow deep link restores dropdown', async () => {
|
||||
await page.goto(BASE + '#/packets?timeWindow=60', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
|
||||
const val = await page.$eval('#fTimeWindow', el => el.value);
|
||||
assert(val === '60', `Expected timeWindow dropdown = 60, got: ${val}`);
|
||||
const url = page.url();
|
||||
assert(url.includes('timeWindow=60'), `URL should still contain timeWindow=60, got: ${url}`);
|
||||
});
|
||||
|
||||
// Test: timeWindow change updates URL
|
||||
await test('Packets timeWindow change updates URL', async () => {
|
||||
await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
|
||||
await page.selectOption('#fTimeWindow', '30');
|
||||
await page.waitForTimeout(300);
|
||||
const url = page.url();
|
||||
assert(url.includes('timeWindow=30'), `URL should contain timeWindow=30 after change, got: ${url}`);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run full test suite**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add public/packets.js test-frontend-helpers.js test-e2e-playwright.js
|
||||
git commit -m "feat: deep link packets timeWindow and region filter (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: channels.js — node panel deep linking
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/channels.js`
|
||||
|
||||
No unit tests needed for this task — the URL manipulation is side-effectful (DOM + History API). Playwright tests cover it.
|
||||
|
||||
- [ ] **Step 1: Write the Playwright test (add to test-e2e-playwright.js, inside the deep-linking group)**
|
||||
|
||||
```javascript
|
||||
// Test: channels selected channel survives refresh (already implemented, verify it still works)
|
||||
await test('Channels channel selection is URL-addressable', async () => {
|
||||
await page.goto(BASE + '#/channels', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.ch-item', { timeout: 8000 }).catch(() => null);
|
||||
const firstChannel = await page.$('.ch-item');
|
||||
if (firstChannel) {
|
||||
await firstChannel.click();
|
||||
await page.waitForTimeout(500);
|
||||
const url = page.url();
|
||||
assert(url.includes('#/channels/') || url.includes('#/channels'), `URL should reflect channel selection, got: ${url}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `showNodeDetail` to write `?node=` to the URL**
|
||||
|
||||
In `channels.js`, in `showNodeDetail` (around line 171), add the URL update right after `selectedNode = name;`:
|
||||
|
||||
```javascript
|
||||
async function showNodeDetail(name) {
|
||||
_nodePanelTrigger = document.activeElement;
|
||||
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
|
||||
const node = await lookupNode(name);
|
||||
selectedNode = name;
|
||||
var _chBase = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels';
|
||||
history.replaceState(null, '', _chBase + '?node=' + encodeURIComponent(name));
|
||||
|
||||
let panel = document.getElementById('chNodePanel');
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `closeNodeDetail` to strip `?node=` from the URL**
|
||||
|
||||
In `closeNodeDetail` (around line 232), add URL restore right after `selectedNode = null;`:
|
||||
|
||||
```javascript
|
||||
function closeNodeDetail() {
|
||||
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
|
||||
const panel = document.getElementById('chNodePanel');
|
||||
if (panel) panel.classList.remove('open');
|
||||
selectedNode = null;
|
||||
var _chRestoreUrl = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels';
|
||||
history.replaceState(null, '', _chRestoreUrl);
|
||||
if (_nodePanelTrigger && typeof _nodePanelTrigger.focus === 'function') {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Read `?node=` on init and auto-open panel**
|
||||
|
||||
In `channels.js` `init` (line 316), add URL param reading at the very top of the function (before `app.innerHTML`):
|
||||
|
||||
```javascript
|
||||
function init(app, routeParam) {
|
||||
var _initUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
var _pendingNode = _initUrlParams.get('node');
|
||||
|
||||
app.innerHTML = `<div class="ch-layout">
|
||||
```
|
||||
|
||||
Then update the `loadChannels().then(...)` call (around line 350) to auto-open the node panel:
|
||||
|
||||
```javascript
|
||||
loadChannels().then(async function () {
|
||||
if (routeParam) await selectChannel(routeParam);
|
||||
if (_pendingNode) showNodeDetail(_pendingNode);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run full test suite**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js
|
||||
```
|
||||
|
||||
Expected: all tests pass (no channels unit tests, but regression tests still pass).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add public/channels.js
|
||||
git commit -m "feat: deep link channels node panel via ?node= (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Run E2E Playwright tests
|
||||
|
||||
- [ ] **Step 1: Start the local server**
|
||||
|
||||
```bash
|
||||
cd cmd/server && go run . &
|
||||
```
|
||||
|
||||
Wait for it to be ready (check `http://localhost:3000`).
|
||||
|
||||
- [ ] **Step 2: Run Playwright tests**
|
||||
|
||||
```bash
|
||||
node test-e2e-playwright.js
|
||||
```
|
||||
|
||||
Expected: all tests pass including the new deep-linking group.
|
||||
|
||||
- [ ] **Step 3: If any deep-linking test fails, debug**
|
||||
|
||||
Common failures:
|
||||
- Selector `.node-tab.active` not found: check that nodes.js correctly reads `?tab=` from URL before rendering
|
||||
- `#fTimeWindow` value wrong: check that `savedTimeWindowMin` is overridden before the DOM is built
|
||||
- URL doesn't update: check `history.replaceState` calls in the change handlers
|
||||
|
||||
- [ ] **Step 4: Final commit (if any fixes needed)**
|
||||
|
||||
```bash
|
||||
git add public/nodes.js public/packets.js public/channels.js
|
||||
git commit -m "fix: deep linking E2E adjustments (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage check:**
|
||||
- ✅ P1: Nodes role tab → Task 2
|
||||
- ✅ P1: Packets time window → Task 3
|
||||
- ✅ P1: Packets region filter → Task 3 (depends on Task 1)
|
||||
- ✅ P1: Channels selected channel → Already implemented via `#/channels/{hash}` (verified in channels.js init line 351)
|
||||
- ✅ P1: Channels node panel → Task 4
|
||||
- ✅ P2+ items → explicitly out of scope per issue
|
||||
|
||||
**Architecture note:** The router in `app.js` strips the query string at line 422 (`const route = hash.split('?')[0]`) before computing `basePage` and `routeParam`. Therefore `#/nodes?tab=repeater` gives `routeParam=null` (not `?tab=repeater`). All pages must read URL params from `location.hash` directly, not from `routeParam`. This is the established pattern in `analytics.js` and `nodes.js` (section scroll).
|
||||
|
||||
**Placeholder scan:** No TBDs, no "implement later", all code blocks complete. ✅
|
||||
|
||||
**Type consistency:**
|
||||
- `buildNodesQuery(tab, searchStr)` — used consistently in `updateNodesUrl()` and in tests ✅
|
||||
- `buildPacketsUrl(timeWindowMin, regionParam)` — used consistently in `updatePacketsUrl()` and in tests ✅
|
||||
- `RegionFilter.setSelected(codesArray)` — defined in Task 1, used in Task 3 ✅
|
||||
@@ -1,204 +0,0 @@
|
||||
# Scope Stats Page — Design Spec
|
||||
|
||||
**Issue**: Kpa-clawbot/CoreScope#899
|
||||
**Date**: 2026-04-23
|
||||
**Branch target**: `master`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Add a dedicated **Scopes** page showing scope/region statistics for MeshCore transport-route packets. Scope filtering in MeshCore uses `TRANSPORT_FLOOD` (route_type 0) and `TRANSPORT_DIRECT` (route_type 3) packets that carry two 16-bit transport codes. Code1 ≠ `0000` means the packet is region-scoped.
|
||||
|
||||
Feature 3 from the issue (default scope per client via advert) is **not implemented** — the advert format has no scope field in the current firmware.
|
||||
|
||||
---
|
||||
|
||||
## How Scopes Work (Firmware)
|
||||
|
||||
Transport code derivation (authoritative source: `meshcore-dev/MeshCore`):
|
||||
|
||||
```
|
||||
key = SHA256("#regionname")[:16] // TransportKeyStore::getAutoKeyFor
|
||||
Code1 = HMAC-SHA256(key, type || payload) // TransportKey::calcTransportCode, 2-byte output
|
||||
```
|
||||
|
||||
Code1 is a **per-message** HMAC — the same region produces a different Code1 for every message. Identifying a region from Code1 requires knowing the region name in advance and recomputing the HMAC.
|
||||
|
||||
`Code1 = 0000` is the "no scope" sentinel (also `FFFF` is reserved). Packets with route_type 1 or 2 (plain FLOOD/DIRECT) carry no transport codes.
|
||||
|
||||
---
|
||||
|
||||
## Config
|
||||
|
||||
Add `hashRegions` to the ingestor `Config` struct in `cmd/ingestor/config.go`, mirroring `hashChannels`:
|
||||
|
||||
```json
|
||||
"hashRegions": ["#belgium", "#eu", "#brussels"]
|
||||
```
|
||||
|
||||
Normalization (same rules as `hashChannels`):
|
||||
- Trim whitespace
|
||||
- Prepend `#` if missing
|
||||
- Skip empty entries
|
||||
|
||||
---
|
||||
|
||||
## Ingestor Changes
|
||||
|
||||
### Key derivation (`loadRegionKeys`)
|
||||
|
||||
```go
|
||||
func loadRegionKeys(cfg *Config) map[string][]byte {
|
||||
// key = first 16 bytes of SHA256("#regionname")
|
||||
}
|
||||
```
|
||||
|
||||
Returns `map[string][]byte` (region name → 16-byte HMAC key). Called once at startup, stored on the `Store`.
|
||||
|
||||
### Decoder: expose raw payload bytes
|
||||
|
||||
Add `PayloadRaw []byte` to `DecodedPacket` in `cmd/ingestor/decoder.go`. Populated from the raw `buf` slice at the payload offset — zero-copy slice, no allocation. This is the **encrypted** payload bytes, matching what the firmware feeds into `calcTransportCode`.
|
||||
|
||||
### At-ingest region matching
|
||||
|
||||
In `BuildPacketData`:
|
||||
- Skip if `route_type` not in `{0, 3}` → `scope_name` stays `nil`
|
||||
- If `Code1 == "0000"` → `scope_name = nil` (unscoped transport, no scope involvement)
|
||||
- If `Code1 != "0000"` → try each region key:
|
||||
```
|
||||
HMAC-SHA256(key, payloadType_byte || PayloadRaw) → first 2 bytes as uint16
|
||||
```
|
||||
First match → `scope_name = "#regionname"`. No match → `scope_name = ""` (unknown scope).
|
||||
|
||||
Add `ScopeName *string` to `PacketData`.
|
||||
|
||||
### MQTT-sourced packets (DM / CHAN paths in main.go)
|
||||
|
||||
These are injected directly without going through `BuildPacketData`. They use `route_type = 1` (FLOOD), so they are never transport-route packets. No scope matching needed for these paths.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
### Migration
|
||||
|
||||
```sql
|
||||
ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL;
|
||||
CREATE INDEX idx_tx_scope_name ON transmissions(scope_name) WHERE scope_name IS NOT NULL;
|
||||
```
|
||||
|
||||
### Column semantics
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| `NULL` | Either: non-transport-route packet (route_type 1/2), or transport-route with Code1=0000 |
|
||||
| `""` (empty string) | Transport-route, Code1 ≠ 0000, but no configured region matched |
|
||||
| `"#belgium"` | Matched named region |
|
||||
|
||||
The API stats queries resolve the NULL ambiguity by always filtering `route_type IN (0, 3)` first:
|
||||
- `unscoped` count = `route_type IN (0,3) AND scope_name IS NULL`
|
||||
- `scoped` count = `route_type IN (0,3) AND scope_name IS NOT NULL`
|
||||
|
||||
### Backfill
|
||||
|
||||
On migration, re-decode `raw_hex` for all rows where `route_type IN (0, 3)` and `scope_name IS NULL`. Run the same HMAC matching logic. Rows with `Code1 = 0000` remain `NULL`.
|
||||
|
||||
The backfill runs in the existing migration framework in `cmd/ingestor/db.go`. If no regions are configured, backfill is skipped.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `GET /api/scope-stats`
|
||||
|
||||
**Query param**: `window` — one of `1h`, `24h` (default), `7d`
|
||||
|
||||
**Time-series bucket sizes**:
|
||||
| Window | Bucket |
|
||||
|--------|--------|
|
||||
| `1h` | 5 min |
|
||||
| `24h` | 1 hour |
|
||||
| `7d` | 6 hours|
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"window": "24h",
|
||||
"summary": {
|
||||
"transportTotal": 1240,
|
||||
"scoped": 890,
|
||||
"unscoped": 350,
|
||||
"unknownScope": 42
|
||||
},
|
||||
"byRegion": [
|
||||
{ "name": "#belgium", "count": 612 },
|
||||
{ "name": "#eu", "count": 236 }
|
||||
],
|
||||
"timeSeries": [
|
||||
{ "t": "2026-04-23T10:00:00Z", "scoped": 45, "unscoped": 18 },
|
||||
{ "t": "2026-04-23T11:00:00Z", "scoped": 51, "unscoped": 22 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `transportTotal` = `scoped + unscoped` (transport-route packets only)
|
||||
- `scoped` = Code1 ≠ 0000 (named + unknown)
|
||||
- `unscoped` = transport-route with Code1 = 0000
|
||||
- `unknownScope` = scoped but no region name matched (subset of `scoped`)
|
||||
- `byRegion` sorted by count descending, excludes unknown
|
||||
- `timeSeries` covers the full window at the bucket granularity
|
||||
|
||||
Route: `GET /api/scope-stats` registered in `cmd/server/routes.go`.
|
||||
No auth required (same as other read endpoints).
|
||||
TTL cache: 30 seconds (heavier query than `/api/stats`).
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### Navigation
|
||||
|
||||
Add nav link between Channels and Nodes in `public/index.html`:
|
||||
```html
|
||||
<a href="#/scopes" class="nav-link" data-route="scopes">Scopes</a>
|
||||
```
|
||||
|
||||
### `public/scopes.js`
|
||||
|
||||
Three sections on the page:
|
||||
|
||||
**1. Summary cards** (reuse existing card CSS pattern from home/analytics pages)
|
||||
- Transport total, Scoped, Unscoped, Unknown scope
|
||||
- Each card shows count + percentage of transport total
|
||||
|
||||
**2. Per-region table**
|
||||
Columns: Region, Messages, % of Scoped
|
||||
Sorted by count descending. Last row: "Unknown scope" (italic) if unknownScope > 0.
|
||||
Shows "No regions configured" message if `byRegion` is empty and `unknownScope = 0`.
|
||||
|
||||
**3. Time-series chart**
|
||||
- Window selector: `1h / 24h / 7d` (default 24h)
|
||||
- Two lines: **Scoped** (blue) and **Unscoped** (grey)
|
||||
- Uses the same lightweight canvas chart pattern as other pages (no external chart lib)
|
||||
|
||||
### Cache buster
|
||||
|
||||
`scopes.js` added to the `__BUST__` entries in `index.html` in the same commit.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests for `loadRegionKeys`: normalization, key bytes match firmware SHA256 derivation
|
||||
- Unit tests for HMAC matching: known Code1 value computed from firmware logic, verified against Go implementation
|
||||
- Integration test: ingest a synthetic transport-route packet with a known region, assert `scope_name` column is set correctly
|
||||
- API test: `GET /api/scope-stats` returns correct summary counts against fixture DB
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Feature 3 (default scope per client via advert) — firmware has no advert scope field
|
||||
- Drill-down from region row to filtered packet list (deferred)
|
||||
- Private regions (`$`-prefixed) — use secret keys not publicly derivable
|
||||
@@ -206,7 +206,9 @@ Provide cert and key paths to enable HTTPS.
|
||||
|
||||
Restricts ingestion and API responses to nodes within the polygon plus a buffer margin. Remove the block to disable filtering. Nodes with no GPS fix always pass through.
|
||||
|
||||
See [Geographic Filtering](geofilter.md) for the full guide including the visual polygon builder and the prune script for cleaning up historical data.
|
||||
Can also be configured live via the **🗺️ GeoFilter** tab in the Customizer (requires `apiKey`).
|
||||
|
||||
See [Geographic Filtering](geofilter.md) for the full guide.
|
||||
|
||||
## Home page
|
||||
|
||||
|
||||
@@ -66,11 +66,13 @@ Click **Import JSON** and paste a previously exported theme. The customizer load
|
||||
|
||||
Click **Reset to Defaults** to restore all settings to the built-in defaults.
|
||||
|
||||
## GeoFilter Builder
|
||||
## GeoFilter (admin only)
|
||||
|
||||
The Export tab includes a **GeoFilter Builder →** link. Click it to open a Leaflet map where you can draw a polygon boundary for your deployment area. The tool generates a `geo_filter` block you can paste directly into `config.json`.
|
||||
The **🗺️ GeoFilter** tab lets operators configure geographic filtering directly from the customizer. It shows the active polygon on a Leaflet map and — on servers with a write-capable `apiKey` — allows editing the polygon and saving back to `config.json` without a restart.
|
||||
|
||||
See [Geographic Filtering](geofilter.md) for full details on what geo filtering does and how to configure it.
|
||||
The editing controls are only revealed after the server confirms write access. On public deployments without an `apiKey`, the tab is read-only.
|
||||
|
||||
See [Geographic Filtering](geofilter.md) for the full guide, including the API, the prune script, and the standalone GeoFilter Builder.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ Add a `geo_filter` block to `config.json`:
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `polygon` | `[[lat, lon], ...]` | Array of at least 3 coordinate pairs defining the boundary |
|
||||
| `bufferKm` | number | Extra distance (km) around the polygon edge that is also accepted. `0` = exact boundary |
|
||||
| `bufferKm` | number | Extra distance (km) outside the polygon edge that is also accepted. `0` = exact boundary |
|
||||
|
||||
Both the server and the ingestor read `geo_filter` from `config.json`. Restart both after changing this section.
|
||||
Both the server and the ingestor read `geo_filter` from `config.json`. Restart both after changing this section manually.
|
||||
|
||||
To disable filtering entirely, remove the `geo_filter` block.
|
||||
|
||||
@@ -51,50 +51,64 @@ An older bounding box format is also supported as a fallback when no `polygon` i
|
||||
|
||||
Prefer the polygon format — it supports irregular shapes and the `bufferKm` margin.
|
||||
|
||||
## API endpoint
|
||||
## Configuring via the customizer
|
||||
|
||||
The current geo filter configuration is exposed at:
|
||||
If your server has an `apiKey` configured, the **GeoFilter tab** in the Customizer lets you edit the polygon visually without touching `config.json`:
|
||||
|
||||
1. Open the Customizer (nav bar → customize icon)
|
||||
2. Click the **🗺️ GeoFilter** tab
|
||||
3. Click on the map to draw your polygon (at least 3 points)
|
||||
4. Adjust **Buffer km**
|
||||
5. Enter your **Server API Key** (the `apiKey` value from `config.json`)
|
||||
6. Click **Save to server** — the filter is applied immediately, no restart needed
|
||||
|
||||
The editing controls are always visible. Saving requires entering the server's `apiKey`; on servers without one (or with a weak key), the save request returns `401`/`403` and the error is shown inline.
|
||||
|
||||
To remove the filter, click **Remove filter** (also requires the API key).
|
||||
|
||||
## GeoFilter Builder (standalone tool)
|
||||
|
||||
For a full-screen editing experience, use the built-in GeoFilter Builder at `/geofilter-builder.html`:
|
||||
|
||||
1. Navigate to `http://your-server/geofilter-builder.html`
|
||||
2. Click on the map to add polygon vertices
|
||||
3. Adjust **Buffer km** (default 20)
|
||||
4. Copy the generated JSON from the output panel
|
||||
5. Paste it as a top-level key into `config.json` and restart the server
|
||||
|
||||
The builder is also accessible from the Customizer's Export tab via the **GeoFilter Builder →** link.
|
||||
|
||||
For local/offline use without a running server, open `tools/geofilter-builder.html` directly in a browser.
|
||||
|
||||
## API endpoint
|
||||
|
||||
```
|
||||
GET /api/config/geo-filter
|
||||
```
|
||||
|
||||
The frontend reads this endpoint to display the active filter. No authentication is required (the endpoint returns config, not private data).
|
||||
Returns the current geo filter configuration (`polygon`, `bufferKm`). Whether the `PUT` endpoint will accept a write depends on whether the server has an `apiKey` configured; clients should attempt the write and handle `401`/`403` if it isn't.
|
||||
|
||||
## GeoFilter Builder
|
||||
|
||||
The simplest way to create a polygon is the included visual builder:
|
||||
|
||||
**File:** `tools/geofilter-builder.html`
|
||||
|
||||
Open it directly in a browser — it runs entirely client-side, no server required:
|
||||
|
||||
```bash
|
||||
# From the project root
|
||||
open tools/geofilter-builder.html # macOS
|
||||
xdg-open tools/geofilter-builder.html # Linux
|
||||
start tools/geofilter-builder.html # Windows
|
||||
```
|
||||
PUT /api/config/geo-filter
|
||||
```
|
||||
|
||||
**Workflow:**
|
||||
Requires `X-API-Key` header. Saves the polygon to `config.json` and applies it in-memory immediately.
|
||||
|
||||
1. The map opens centered on Belgium by default. Navigate to your region.
|
||||
2. Click on the map to add polygon vertices. Each click adds a numbered point.
|
||||
3. Add at least 3 points to form a closed polygon.
|
||||
4. Adjust **Buffer km** (default 20) to add a margin around the polygon edge.
|
||||
5. The generated JSON block appears at the bottom of the page — copy it directly into `config.json`.
|
||||
6. Use **↩ Undo** to remove the last point, **✕ Clear** to start over.
|
||||
Request body:
|
||||
```json
|
||||
{"polygon": [[lat, lon], ...], "bufferKm": 20}
|
||||
```
|
||||
|
||||
The output is a complete `{ "geo_filter": { ... } }` block ready to paste into `config.json`.
|
||||
To clear the filter, send `{"polygon": null}`.
|
||||
|
||||
## Cleaning up historical nodes
|
||||
|
||||
The ingestor prevents new out-of-bounds nodes from being ingested, but it does not retroactively remove nodes that were stored before the filter was configured. For that, use the prune script.
|
||||
The ingestor prevents new out-of-bounds nodes from being ingested, but it does not retroactively remove nodes stored before the filter was configured. For that, use the prune script:
|
||||
|
||||
**File:** `scripts/prune-nodes-outside-geo-filter.py`
|
||||
|
||||
```bash
|
||||
# Dry run — shows what would be deleted without making any changes
|
||||
# Dry run — shows what would be deleted without making changes
|
||||
python3 scripts/prune-nodes-outside-geo-filter.py --dry-run
|
||||
|
||||
# Default paths: /app/data/meshcore.db and /app/config.json
|
||||
@@ -104,11 +118,11 @@ python3 scripts/prune-nodes-outside-geo-filter.py
|
||||
python3 scripts/prune-nodes-outside-geo-filter.py /path/to/meshcore.db \
|
||||
--config /path/to/config.json
|
||||
|
||||
# In Docker — run inside the container
|
||||
# In Docker
|
||||
docker exec -it meshcore-analyzer \
|
||||
python3 /app/scripts/prune-nodes-outside-geo-filter.py --dry-run
|
||||
```
|
||||
|
||||
The script reads `geo_filter.polygon` and `geo_filter.bufferKm` from config, lists the nodes that fall outside, then asks for `yes` confirmation before deleting. Nodes without coordinates are always kept.
|
||||
The script reads `geo_filter.polygon` and `geo_filter.bufferKm` from config, lists nodes that fall outside, then asks for `yes` confirmation before deleting. Nodes without coordinates are always kept.
|
||||
|
||||
This is a **one-time migration tool** — run it once after first configuring `geo_filter` to clean up pre-filter data. The ingestor handles all subsequent filtering automatically at ingest time.
|
||||
This is a **one-time migration tool** — run it once after first configuring `geo_filter` to clean up pre-filter data. The ingestor handles all subsequent filtering automatically.
|
||||
|
||||
+307
-1
@@ -878,6 +878,16 @@
|
||||
var _activeTab = 'branding';
|
||||
var _styleEl = null;
|
||||
|
||||
// GeoFilter tab state
|
||||
var _gfMap = null;
|
||||
var _gfModalMap = null;
|
||||
var _gfWriteEnabled = false;
|
||||
var _gfPoints = [];
|
||||
var _gfMarkers = [];
|
||||
var _gfPolygon = null;
|
||||
var _gfClosingLine = null;
|
||||
var _gfLoaded = false; // true after initial server load
|
||||
|
||||
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
||||
function escAttr(s) { return (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<'); }
|
||||
|
||||
@@ -1004,6 +1014,7 @@
|
||||
{ id: 'nodes', label: '🎯', title: 'Colors', badge: (function () { var n = _countOverrides('nodeColors') + _countOverrides('typeColors'); return n ? ' <span class="cv2-tab-badge">' + n + '</span>' : ''; })() },
|
||||
{ id: 'home', label: '🏠', title: 'Home', badge: _tabBadge('home') },
|
||||
{ id: 'display', label: '🖥️', title: 'Display', badge: (function () { var n = _countOverrides('timestamps') + (_isOverridden(null, 'distanceUnit') ? 1 : 0); return n ? ' <span class="cv2-tab-badge">' + n + '</span>' : ''; })() },
|
||||
{ id: 'geofilter', label: '🗺️', title: 'GeoFilter' },
|
||||
{ id: 'export', label: '📤', title: 'Export' }
|
||||
];
|
||||
return '<div class="cust-tabs">' + tabs.map(function (t) {
|
||||
@@ -1256,6 +1267,293 @@
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function _renderGeoFilter() {
|
||||
return '<div class="cust-panel' + (_activeTab === 'geofilter' ? ' active' : '') + '" data-panel="geofilter">' +
|
||||
'<p class="cust-section-title">Geographic Filter</p>' +
|
||||
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">Shows the active geographic filter. Nodes outside this area are excluded at ingest time and in API responses.</p>' +
|
||||
'<div style="position:relative;margin-bottom:8px">' +
|
||||
'<div id="cv2-gf-map" style="height:200px;border-radius:6px;border:1px solid var(--border);background:var(--surface-1);cursor:pointer"></div>' +
|
||||
'<div style="position:absolute;top:7px;right:7px;background:rgba(255,255,255,0.88);border-radius:4px;padding:3px 8px;font-size:11px;color:#444;pointer-events:none;box-shadow:0 1px 3px rgba(0,0,0,0.15)">🔍 click to expand</div>' +
|
||||
'</div>' +
|
||||
'<div id="cv2-gf-status" style="font-size:12px;color:var(--text-muted);margin-bottom:10px">Loading current filter…</div>' +
|
||||
// Edit controls — hidden until server confirms write access (writeEnabled=true)
|
||||
'<div id="cv2-gf-edit" style="display:none">' +
|
||||
'<div style="display:flex;gap:8px;margin-bottom:10px;align-items:center">' +
|
||||
'<label style="font-size:12px;color:var(--text-muted)">Buffer km:</label>' +
|
||||
'<input type="number" id="cv2-gf-buffer" value="20" min="0" max="500" style="width:64px;padding:4px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text);font-size:12px">' +
|
||||
'</div>' +
|
||||
'<div class="cust-field"><label>Server API Key</label>' +
|
||||
'<input type="password" id="cv2-gf-apikey" placeholder="apiKey from config.json" style="width:100%;padding:5px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text);font-size:12px">' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:8px;margin-top:12px">' +
|
||||
'<button id="cv2-gf-save" style="padding:7px 16px;background:var(--accent);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;font-weight:500">Save to server</button>' +
|
||||
'<button id="cv2-gf-remove" style="padding:7px 14px;background:var(--surface-1);color:var(--status-red);border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:13px">Remove filter</button>' +
|
||||
'</div>' +
|
||||
'<div id="cv2-gf-msg" style="margin-top:8px;font-size:12px;display:none"></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function _gfOpenModal(container) {
|
||||
var existing = document.getElementById('cv2-gf-modal-overlay');
|
||||
if (existing) existing.remove();
|
||||
if (_gfModalMap) { _gfModalMap.remove(); _gfModalMap = null; }
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.id = 'cv2-gf-modal-overlay';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.65);z-index:99999;display:flex;align-items:center;justify-content:center;';
|
||||
|
||||
var dialog = document.createElement('div');
|
||||
dialog.style.cssText = 'width:92vw;height:86vh;background:#fff;border-radius:10px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.4);';
|
||||
|
||||
var toolbarEl = document.createElement('div');
|
||||
toolbarEl.style.cssText = 'padding:10px 14px;display:flex;gap:8px;align-items:center;border-bottom:1px solid #e0e0e0;background:#f5f5f5;flex-shrink:0;';
|
||||
var title = document.createElement('span');
|
||||
title.style.cssText = 'font-weight:600;color:#333;font-size:14px;';
|
||||
title.textContent = _gfWriteEnabled ? 'Edit GeoFilter — click map to add points' : 'GeoFilter — read only';
|
||||
toolbarEl.appendChild(title);
|
||||
|
||||
if (_gfWriteEnabled) {
|
||||
var undoBtn = document.createElement('button');
|
||||
undoBtn.id = 'cv2-gfm-undo';
|
||||
undoBtn.textContent = '↩ Undo';
|
||||
undoBtn.style.cssText = 'padding:5px 10px;background:#eee;color:#555;border:1px solid #ccc;border-radius:6px;cursor:pointer;font-size:12px;';
|
||||
var clearBtn = document.createElement('button');
|
||||
clearBtn.id = 'cv2-gfm-clear';
|
||||
clearBtn.textContent = '✕ Clear';
|
||||
clearBtn.style.cssText = 'padding:5px 10px;background:#fee;color:#c44;border:1px solid #fcc;border-radius:6px;cursor:pointer;font-size:12px;';
|
||||
var countEl = document.createElement('span');
|
||||
countEl.id = 'cv2-gfm-count';
|
||||
countEl.style.cssText = 'font-size:12px;color:#888;';
|
||||
var spacer = document.createElement('span');
|
||||
spacer.style.cssText = 'flex:1;';
|
||||
var doneBtn = document.createElement('button');
|
||||
doneBtn.id = 'cv2-gfm-done';
|
||||
doneBtn.textContent = 'Done';
|
||||
doneBtn.style.cssText = 'padding:7px 18px;background:#4a9eff;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;font-weight:500;';
|
||||
toolbarEl.appendChild(undoBtn);
|
||||
toolbarEl.appendChild(clearBtn);
|
||||
toolbarEl.appendChild(countEl);
|
||||
toolbarEl.appendChild(spacer);
|
||||
toolbarEl.appendChild(doneBtn);
|
||||
} else {
|
||||
var spacer2 = document.createElement('span');
|
||||
spacer2.style.cssText = 'flex:1;';
|
||||
toolbarEl.appendChild(spacer2);
|
||||
}
|
||||
|
||||
var closeBtn = document.createElement('button');
|
||||
closeBtn.id = 'cv2-gfm-close';
|
||||
closeBtn.textContent = _gfWriteEnabled ? 'Cancel' : 'Close';
|
||||
closeBtn.style.cssText = 'padding:7px 14px;background:#eee;color:#555;border:1px solid #ccc;border-radius:6px;cursor:pointer;font-size:13px;';
|
||||
toolbarEl.appendChild(closeBtn);
|
||||
|
||||
var mapDiv = document.createElement('div');
|
||||
mapDiv.id = 'cv2-gf-modal-map';
|
||||
mapDiv.style.cssText = 'flex:1;';
|
||||
|
||||
dialog.appendChild(toolbarEl);
|
||||
dialog.appendChild(mapDiv);
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
var modalPoints = _gfPoints.map(function (p) { return [p[0], p[1]]; });
|
||||
var modalMarkers = [];
|
||||
var modalPolygon = null;
|
||||
var modalClosingLine = null;
|
||||
|
||||
_gfModalMap = L.map(mapDiv, { zoomControl: true });
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap © CartoDB', maxZoom: 19
|
||||
}).addTo(_gfModalMap);
|
||||
|
||||
function renderModal() {
|
||||
if (modalPolygon) { _gfModalMap.removeLayer(modalPolygon); modalPolygon = null; }
|
||||
if (modalClosingLine) { _gfModalMap.removeLayer(modalClosingLine); modalClosingLine = null; }
|
||||
modalMarkers.forEach(function (m) { _gfModalMap.removeLayer(m); });
|
||||
modalMarkers = [];
|
||||
modalPoints.forEach(function (pt, i) {
|
||||
var m = L.circleMarker(pt, { radius: 6, color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.9 })
|
||||
.addTo(_gfModalMap)
|
||||
.bindTooltip(String(i + 1), { permanent: true, direction: 'top', offset: [0, -8] });
|
||||
modalMarkers.push(m);
|
||||
});
|
||||
if (modalPoints.length >= 3) {
|
||||
modalPolygon = L.polygon(modalPoints, { color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.12 }).addTo(_gfModalMap);
|
||||
} else if (modalPoints.length === 2) {
|
||||
modalClosingLine = L.polyline(modalPoints, { color: '#4a9eff', weight: 2, dashArray: '5,5' }).addTo(_gfModalMap);
|
||||
}
|
||||
var ce = document.getElementById('cv2-gfm-count');
|
||||
if (ce) ce.textContent = modalPoints.length + ' point' + (modalPoints.length !== 1 ? 's' : '');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (_gfModalMap) { _gfModalMap.remove(); _gfModalMap = null; }
|
||||
overlay.remove();
|
||||
}
|
||||
|
||||
setTimeout(function () {
|
||||
_gfModalMap.invalidateSize();
|
||||
renderModal();
|
||||
if (modalPoints.length >= 3) {
|
||||
_gfModalMap.fitBounds(L.latLngBounds(modalPoints), { padding: [40, 40] });
|
||||
} else {
|
||||
_gfModalMap.setView([50.5, 4.4], 5);
|
||||
}
|
||||
}, 80);
|
||||
|
||||
if (_gfWriteEnabled) {
|
||||
_gfModalMap.on('click', function (e) {
|
||||
modalPoints.push([parseFloat(e.latlng.lat.toFixed(6)), parseFloat(e.latlng.lng.toFixed(6))]);
|
||||
renderModal();
|
||||
});
|
||||
document.getElementById('cv2-gfm-undo').addEventListener('click', function () {
|
||||
if (!modalPoints.length) return;
|
||||
modalPoints.pop();
|
||||
renderModal();
|
||||
});
|
||||
document.getElementById('cv2-gfm-clear').addEventListener('click', function () {
|
||||
modalPoints = [];
|
||||
renderModal();
|
||||
});
|
||||
document.getElementById('cv2-gfm-done').addEventListener('click', function () {
|
||||
_gfPoints = modalPoints;
|
||||
_gfRender();
|
||||
var prune = container.querySelector('#cv2-gf-prune-section');
|
||||
if (prune) prune.style.display = _gfPoints.length >= 3 ? '' : 'none';
|
||||
_gfStatus(container, _gfPoints.length + ' point' + (_gfPoints.length !== 1 ? 's' : '') + '.');
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
closeBtn.addEventListener('click', closeModal);
|
||||
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeModal(); });
|
||||
}
|
||||
|
||||
function _gfRender() {
|
||||
if (!_gfMap) return;
|
||||
if (_gfPolygon) { _gfMap.removeLayer(_gfPolygon); _gfPolygon = null; }
|
||||
if (_gfClosingLine) { _gfMap.removeLayer(_gfClosingLine); _gfClosingLine = null; }
|
||||
_gfMarkers.forEach(function (m) { _gfMap.removeLayer(m); });
|
||||
_gfMarkers = [];
|
||||
|
||||
_gfPoints.forEach(function (pt, i) {
|
||||
var m = L.circleMarker(pt, { radius: 6, color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.9 })
|
||||
.addTo(_gfMap)
|
||||
.bindTooltip(String(i + 1), { permanent: true, direction: 'top', offset: [0, -8] });
|
||||
_gfMarkers.push(m);
|
||||
});
|
||||
|
||||
if (_gfPoints.length >= 3) {
|
||||
_gfPolygon = L.polygon(_gfPoints, { color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.12 }).addTo(_gfMap);
|
||||
} else if (_gfPoints.length === 2) {
|
||||
_gfClosingLine = L.polyline(_gfPoints, { color: '#4a9eff', weight: 2, dashArray: '5,5' }).addTo(_gfMap);
|
||||
}
|
||||
}
|
||||
|
||||
function _gfStatus(container, msg) {
|
||||
var el = container.querySelector('#cv2-gf-status');
|
||||
if (el) el.textContent = msg;
|
||||
}
|
||||
|
||||
function _gfMsg(container, msg, ok) {
|
||||
var el = container.querySelector('#cv2-gf-msg');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.style.display = msg ? '' : 'none';
|
||||
el.style.color = ok ? 'var(--status-green)' : 'var(--status-red)';
|
||||
}
|
||||
|
||||
function _gfSave(container) {
|
||||
if (_gfPoints.length < 3) { _gfMsg(container, 'Need at least 3 polygon points.', false); return; }
|
||||
var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || '';
|
||||
if (!apiKey) { _gfMsg(container, 'API key required to save.', false); return; }
|
||||
var bufferKm = parseFloat((container.querySelector('#cv2-gf-buffer') || {}).value) || 0;
|
||||
fetch('/api/config/geo-filter', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
||||
body: JSON.stringify({ polygon: _gfPoints, bufferKm: bufferKm })
|
||||
}).then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); });
|
||||
_gfMsg(container, 'Saved. Filter is active immediately.', true);
|
||||
_gfStatus(container, _gfPoints.length + ' points · bufferKm=' + bufferKm + ' · saved');
|
||||
}).catch(function (e) { _gfMsg(container, 'Error: ' + e.message, false); });
|
||||
}
|
||||
|
||||
function _gfRemove(container) {
|
||||
var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || '';
|
||||
if (!apiKey) { _gfMsg(container, 'API key required.', false); return; }
|
||||
if (!confirm('Remove geo filter? All nodes will be allowed through.')) return;
|
||||
fetch('/api/config/geo-filter', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
||||
body: JSON.stringify({ polygon: null })
|
||||
}).then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); });
|
||||
_gfPoints = []; _gfLoaded = true;
|
||||
_gfRender();
|
||||
_gfStatus(container, 'No geo filter. Click the map to draw a polygon.');
|
||||
_gfMsg(container, 'Geo filter removed.', true);
|
||||
}).catch(function (e) { _gfMsg(container, 'Error: ' + e.message, false); });
|
||||
}
|
||||
|
||||
function _initGeoFilterTab(container) {
|
||||
var mapEl = container.querySelector('#cv2-gf-map');
|
||||
if (!mapEl || typeof L === 'undefined') return;
|
||||
|
||||
_gfMap = L.map(mapEl, { zoomControl: false, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, touchZoom: false });
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap © CartoDB', maxZoom: 19
|
||||
}).addTo(_gfMap);
|
||||
|
||||
if (!_gfLoaded) {
|
||||
api('/config/geo-filter', { ttl: 0 }).then(function (gf) {
|
||||
// Always expose edit controls. The server no longer leaks apiKey presence
|
||||
// via writeEnabled (info-disclosure on a public endpoint); instead, the
|
||||
// user enters their API key into the editor and writes either succeed or
|
||||
// get a 401/403 surfaced as an inline error.
|
||||
_gfWriteEnabled = true;
|
||||
var editEl = container.querySelector('#cv2-gf-edit');
|
||||
if (editEl) editEl.style.display = '';
|
||||
if (gf && gf.polygon && gf.polygon.length >= 3) {
|
||||
_gfPoints = gf.polygon.map(function (p) { return [p[0], p[1]]; });
|
||||
var buf = container.querySelector('#cv2-gf-buffer');
|
||||
if (buf) buf.value = gf.bufferKm || 0;
|
||||
_gfRender();
|
||||
if (_gfPolygon) _gfMap.fitBounds(_gfPolygon.getBounds(), { padding: [20, 20] });
|
||||
_gfStatus(container, gf.polygon.length + ' points · bufferKm=' + (gf.bufferKm || 0));
|
||||
} else {
|
||||
_gfPoints = [];
|
||||
_gfStatus(container, 'No geo filter. Click the map to open the editor.');
|
||||
_gfMap.setView([50.5, 4.4], 5);
|
||||
}
|
||||
_gfLoaded = true;
|
||||
setTimeout(function () { if (_gfMap) _gfMap.invalidateSize(); }, 100);
|
||||
}).catch(function () {
|
||||
_gfStatus(container, 'Could not load current filter.');
|
||||
_gfMap.setView([50.5, 4.4], 5);
|
||||
_gfLoaded = true;
|
||||
setTimeout(function () { if (_gfMap) _gfMap.invalidateSize(); }, 100);
|
||||
});
|
||||
} else {
|
||||
if (_gfPoints.length >= 3) {
|
||||
_gfRender();
|
||||
if (_gfPolygon) _gfMap.fitBounds(_gfPolygon.getBounds(), { padding: [20, 20] });
|
||||
_gfStatus(container, _gfPoints.length + ' points.');
|
||||
} else {
|
||||
_gfMap.setView([50.5, 4.4], 5);
|
||||
_gfStatus(container, _gfPoints.length ? _gfPoints.length + ' points (need at least 3).' : 'Click the map to draw a polygon.');
|
||||
_gfRender();
|
||||
}
|
||||
setTimeout(function () { if (_gfMap) _gfMap.invalidateSize(); }, 100);
|
||||
}
|
||||
|
||||
_gfMap.on('click', function () { _gfOpenModal(container); });
|
||||
|
||||
container.querySelector('#cv2-gf-save').addEventListener('click', function () { _gfSave(container); });
|
||||
container.querySelector('#cv2-gf-remove').addEventListener('click', function () { _gfRemove(container); });
|
||||
}
|
||||
|
||||
function _renderExport() {
|
||||
var delta = readOverrides();
|
||||
var json = JSON.stringify(delta, null, 2);
|
||||
@@ -1290,6 +1588,7 @@
|
||||
_renderNodes() +
|
||||
_renderHome() +
|
||||
_renderDisplay() +
|
||||
_renderGeoFilter() +
|
||||
_renderExport() +
|
||||
'</div>';
|
||||
_bindEvents(container);
|
||||
@@ -1358,11 +1657,15 @@
|
||||
// Tab switching
|
||||
container.querySelectorAll('.cust-tab').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
if (_gfMap) { _gfMap.remove(); _gfMap = null; _gfMarkers = []; _gfPolygon = null; _gfClosingLine = null; } if (_gfModalMap) { _gfModalMap.remove(); _gfModalMap = null; } var _ov = document.getElementById('cv2-gf-modal-overlay'); if (_ov) _ov.remove();
|
||||
_activeTab = btn.dataset.tab;
|
||||
_renderPanel(container);
|
||||
});
|
||||
});
|
||||
|
||||
// GeoFilter tab init
|
||||
if (_activeTab === 'geofilter') _initGeoFilterTab(container);
|
||||
|
||||
// Preset buttons
|
||||
container.querySelectorAll('.cust-preset-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
@@ -1645,7 +1948,10 @@
|
||||
'<div class="cv2-footer"><span id="cv2-save-status">All changes saved</span></div>';
|
||||
document.body.appendChild(_panelEl);
|
||||
|
||||
_panelEl.querySelector('.cust-close').addEventListener('click', function () { _panelEl.classList.add('hidden'); });
|
||||
_panelEl.querySelector('.cust-close').addEventListener('click', function () {
|
||||
if (_gfMap) { _gfMap.remove(); _gfMap = null; _gfMarkers = []; _gfPolygon = null; _gfClosingLine = null; } if (_gfModalMap) { _gfModalMap.remove(); _gfModalMap = null; } var _ov = document.getElementById('cv2-gf-modal-overlay'); if (_ov) _ov.remove();
|
||||
_panelEl.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Drag support
|
||||
var header = _panelEl.querySelector('.cust-header');
|
||||
|
||||
@@ -8,18 +8,18 @@
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
|
||||
header { padding: 12px 16px; background: #0f0f23; border-bottom: 1px solid #333; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
header h1 { font-size: 1rem; font-weight: 600; color: #4a9eff; white-space: nowrap; }
|
||||
body { font-family: system-ui, sans-serif; background: #f5f5f5; color: #222; height: 100vh; display: flex; flex-direction: column; }
|
||||
header { padding: 12px 16px; background: #fff; border-bottom: 1px solid #ddd; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
header h1 { font-size: 1rem; font-weight: 600; color: #1a6abf; white-space: nowrap; }
|
||||
.controls { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
button { padding: 6px 14px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; }
|
||||
#btnUndo { background: #333; color: #ccc; }
|
||||
#btnClear { background: #5a2020; color: #ffaaaa; }
|
||||
#btnUndo:hover { background: #444; }
|
||||
#btnClear:hover { background: #7a2020; }
|
||||
#btnUndo { background: #eee; color: #555; border: 1px solid #ccc; }
|
||||
#btnClear { background: #fee; color: #c44; border: 1px solid #fcc; }
|
||||
#btnUndo:hover { background: #e0e0e0; }
|
||||
#btnClear:hover { background: #fdd; }
|
||||
.hint { font-size: 0.8rem; color: #888; margin-left: auto; }
|
||||
#map { flex: 1; }
|
||||
#output-panel { background: #0f0f23; border-top: 1px solid #333; padding: 12px 16px; display: flex; gap: 12px; align-items: flex-start; }
|
||||
#output-panel { background: #fff; border-top: 1px solid #ddd; padding: 12px 16px; display: flex; gap: 12px; align-items: flex-start; }
|
||||
#output-panel label { font-size: 0.75rem; color: #888; white-space: nowrap; padding-top: 6px; }
|
||||
#output { flex: 1; background: #111; border: 1px solid #333; border-radius: 6px; padding: 10px 12px; font-family: monospace; font-size: 0.78rem; color: #7ec8e3; white-space: pre; overflow-x: auto; min-height: 54px; max-height: 140px; overflow-y: auto; cursor: text; }
|
||||
#output.empty { color: #555; font-style: italic; }
|
||||
@@ -34,12 +34,12 @@
|
||||
#btnDownload:hover { background: #2a6aaa; }
|
||||
#counter { font-size: 0.8rem; color: #888; padding-top: 6px; white-space: nowrap; }
|
||||
.bufferRow { display: flex; align-items: center; gap: 8px; }
|
||||
.bufferRow label { font-size: 0.85rem; color: #aaa; }
|
||||
.bufferRow input { width: 60px; padding: 5px 8px; background: #222; border: 1px solid #444; border-radius: 6px; color: #eee; font-size: 0.85rem; }
|
||||
#help-bar { background: #0f0f23; padding: 6px 16px; font-size: 0.75rem; color: #666; border-top: 1px solid #222; }
|
||||
#help-bar a { color: #4a9eff; text-decoration: none; }
|
||||
.bufferRow label { font-size: 0.85rem; color: #555; }
|
||||
.bufferRow input { width: 60px; padding: 5px 8px; background: #fff; border: 1px solid #ccc; border-radius: 6px; color: #222; font-size: 0.85rem; }
|
||||
#help-bar { background: #fff; padding: 6px 16px; font-size: 0.75rem; color: #888; border-top: 1px solid #e0e0e0; }
|
||||
#help-bar a { color: #1a6abf; text-decoration: none; }
|
||||
#help-bar a:hover { text-decoration: underline; }
|
||||
#back-link { font-size: 0.8rem; color: #4a9eff; text-decoration: none; white-space: nowrap; }
|
||||
#back-link { font-size: 0.8rem; color: #1a6abf; text-decoration: none; white-space: nowrap; }
|
||||
#back-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -79,14 +79,14 @@
|
||||
<div id="help-bar">
|
||||
<strong>Save Draft</strong> preserves your polygon across sessions. <strong>Download</strong> exports a JSON snippet → paste as a top-level key in <code>config.json</code> → restart the server.
|
||||
Nodes with no GPS fix always pass through. Remove the <code>geo_filter</code> block to disable filtering.
|
||||
· <a href="/geofilter-docs.html">Documentation</a>
|
||||
· <a href="https://github.com/Kpa-clawbot/CoreScope/blob/master/docs/user-guide/geofilter.md" target="_blank">Documentation ↗</a>
|
||||
</div>
|
||||
|
||||
<script src="geofilter-draft.js"></script>
|
||||
<script>
|
||||
const map = L.map('map').setView([50.5, 4.4], 8);
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
@@ -97,8 +97,7 @@ let polygon = null;
|
||||
let closingLine = null;
|
||||
|
||||
function latLonPair(latlng) {
|
||||
const w = latlng.wrap();
|
||||
return [parseFloat(w.lat.toFixed(6)), parseFloat(w.lng.toFixed(6))];
|
||||
return [parseFloat(latlng.lat.toFixed(6)), parseFloat(latlng.lng.toFixed(6))];
|
||||
}
|
||||
|
||||
function render() {
|
||||
|
||||
Reference in New Issue
Block a user