Compare commits

...

20 Commits

Author SHA1 Message Date
openclaw-bot dd2f81270a fix(routes): stop leaking apiKey presence via writeEnabled on public GET
PR review found GET /api/config/geo-filter is unauthenticated and was
returning writeEnabled (derived from APIKey != "" && !IsWeakAPIKey).
That tells any anonymous caller whether a strong API key is configured
on the server — low-severity info disclosure, but free to fix.

Approach: drop the field entirely instead of gating the endpoint behind
requireAPIKey. The polygon itself is intentionally public (read-only
clients depend on it), so auth-gating the whole endpoint would break
them. Clients that want to write should just attempt PUT and handle
401/403 — the customizer JS already has that error path.

- routes.go: remove writeEnabled from both response branches
- customize-v2.js: always reveal edit controls; rely on PUT error path
- docs/user-guide/geofilter.md: drop the writeEnabled API doc + the
  "controls only appear when..." UX line

Green commit for the red test in the previous commit.
2026-05-21 02:41:07 +00:00
openclaw-bot e01b5f58c2 test(routes): assert writeEnabled is NOT exposed in public geo-filter GET
The GET /api/config/geo-filter endpoint is unauthenticated. Returning
writeEnabled (derived from APIKey presence + strength) leaks whether
the server has a strong API key configured to anonymous callers.

This is a red commit: both subtests now assert writeEnabled is absent.
Current production handler still emits the field, so they fail.
2026-05-21 02:39:39 +00:00
efiten d7195136fa Merge branch 'master' into feat/geofilter-m3-customizer 2026-05-20 16:30:47 +02:00
efiten b380be33a3 fix(docker): add COPY internal/dbschema/ for server and ingestor builds (#736)
Upstream added dbschema to go.mod for both cmd/server and cmd/ingestor
but the Dockerfile was not updated. Docker build fails at go mod download
because the replace directive resolves to ../../internal/dbschema which
is not present in the build context without an explicit COPY.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:16:08 +02:00
efiten bb05b0ffc3 chore: remove local-only docs/superpowers from branch index (#736)
These plan files are workspace-local and must not ship upstream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 09:50:16 +02:00
efiten c2d1f8d652 Merge branch 'feat/geofilter-m3-customizer' of https://github.com/efiten/meshcore-analyzer into feat/geofilter-m3-customizer 2026-05-20 08:01:12 +02:00
efiten 8fd5c786c8 fix(geo-filter): serialize concurrent PUT disk writes via saveMu (#736) 2026-05-20 07:48:38 +02:00
efiten 4c3881c657 fix(geo-filter): validate bufferKm range (finite, non-negative, ≤20000 km) (#736) 2026-05-20 07:45:13 +02:00
efiten bb19a1b3e1 fix(geo-filter): SaveGeoFilter preserves config.json file mode (#736) 2026-05-20 07:42:14 +02:00
efiten 022a3635d7 merge: resolve upstream/master conflicts for PR #736 (round 2) 2026-05-19 21:52:36 +02:00
efiten b3d52e0174 Merge branch 'master' into feat/geofilter-m3-customizer 2026-05-18 21:52:13 +02:00
efiten e8e223cf9e Merge branch 'master' into feat/geofilter-m3-customizer 2026-05-18 21:30:27 +02:00
efiten 7caafb9811 merge: resolve upstream/master conflicts for PR #736 2026-05-18 21:06:28 +02:00
Kpa-clawbot 047df38c4f fix(#1254): trim .badge-iata h-padding on mobile to clear 1.25px clip (#1255)
Fixes #1254.

Master CI Playwright fail-fast on every push since #1252:

```
 Mobile viewport (375px): observer IATA badge stays visible — not clipped:
   .badge-iata right edge 376.25 exceeds 375px viewport
```

## Root cause

After #1252 unhid `.col-observer` at narrow widths so the IATA pill from
#1188 renders on mobile, at 375px the cell padding + truncated observer
name (10 chars in grouped rows) + `.badge-iata` pill (`padding: 1px 5px`
+ `margin-left: 4px`) sums to ~376.25px — overflowing the viewport by
1.25px.

Same class of failure as #1250/#1251 (VCR LCD-clip).

## Fix

`public/style.css` — inside the existing `@media (max-width: 640px)`
block, shrink `.badge-iata` `padding: 1px 5px → 1px 3px` and
`margin-left: 4px → 2px`. Reclaims ~6px horizontally, well clear of the
1.25px overflow. Desktop (≥641px) styling untouched.

## TDD

The failing E2E sub-test in `test-observer-iata-1188-e2e.js` (added in
#1189 R1) IS the red. Mutation verified locally:

| Variant            | Result |
|--------------------|--------|
| WITHOUT this fix |  `.badge-iata right edge 376.25 exceeds 375px
viewport` |
| WITH this fix      |  all 3 sub-tests pass |

## Local verification

```
$ go build -o /tmp/corescope-server ./cmd/server
$ /tmp/corescope-server -port 13581 -db test-fixtures/e2e-fixture.db -public public &
$ CHROMIUM_PATH=/usr/bin/chromium BASE_URL=http://localhost:13581 \
    node test-observer-iata-1188-e2e.js
Running observer-IATA E2E tests against http://localhost:13581
   Packets table renders an IATA badge in an observer cell
   Filter grammar: observer_iata == "<code>" narrows the table
   Mobile viewport (375px): observer IATA badge stays visible — not clipped
All observer-IATA E2E tests passed.
```

## Constraints honored

- All colors via existing CSS variables (no theming illusions; only
  `padding` / `margin-left` change inside `@media (max-width: 640px)`).
- No JS changes.
- Desktop badge display unaffected (selector scoped to narrow viewport).
- `config.example.json`: no config field added.
- PII preflight: clean.

Co-authored-by: OpenClaw Bot <bot@openclaw.local>
2026-05-18 20:48:57 +02:00
Kpa-clawbot a58c21a894 fix(#1249): IATA badge missing on fixture + mobile clipping (#1252)
Failing test commit: `bdb4eefb` (added in #1189 R1) — original CI
failure:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/25995819598

Fixes #1249.

## Root cause

Two independent bugs surfaced by the same E2E test:

1. **Fixture join broken.** `scripts/capture-fixture.sh` wrote the text
observer hash into `observations.observer_idx`, but the v3 join in
`cmd/server` is `observers.rowid = observations.observer_idx`. The join
silently nulled out `observer_id` / `observer_iata` for every packet.

2. **Mobile clipping.** `.col-observer` had `data-priority=3` (hides at
≤1024px) and was in the narrow-viewport `defaultHidden` list, so at
375px the cell collapsed to `display:none` and `.badge-iata` had a 0×0
box.

## Changes

- `test-fixtures/e2e-fixture.db`: remap `observer_idx` text hash →
integer rowid (500/500 rows resolved).
- `scripts/capture-fixture.sh`: build an `observer_id → rowid` map
before insert; skip rows whose observer isn't in the fixture. Comment
explains the trap.
- `public/packets.js`: bump `.col-observer` priority `3 → 1` and drop
`observer` from narrow-viewport `defaultHidden`.

## Verification

All three sub-tests in `test-observer-iata-1188-e2e.js` pass locally
against the freshened fixture. `curl /api/packets?limit=5` returns real
IATA codes (OAK / MRY / SFO) instead of empty strings.

Co-authored-by: OpenClaw Bot <bot@openclaw.local>
2026-05-18 20:48:57 +02:00
Kpa-clawbot 67e52ebfcd fix(#1250): trim mobile VCR bar h-padding 8px→4px to clear 0.83px LCD clip (#1251)
Red: master CI run
https://github.com/Kpa-clawbot/CoreScope/actions/runs/25995768081
already fails on `test-e2e-playwright.js` `#1221 LCD clipped on right
(right=375.828125, vw=375)`. No new test commit — the existing E2E
assertion is the gate.

**Root cause.** PR #1222's mobile rule set `.vcr-bar { padding: 4px 8px
}`. The flex row holds three `flex-shrink: 0` children (controls +
scope-btns + lcd) and one `flex: 1 1 0` absorber
(`.vcr-timeline-container`, `min-width: 40px`). At 375px viewport the
absorber hits its floor, so the intrinsic widths of the shrink-frozen
children spill 0.83px past the padding box.

**Fix.** Drop horizontal padding 8px → 4px inside the `@media
(max-width: 640px)` block. That's 8px of new slack — order of magnitude
above the 0.83px clip — keeping LCD's `getBoundingClientRect().right ≤
375`. Desktop layout untouched (rule is mobile-scoped). VCR/feed overlap
(#1206/#1213) not reintroduced because `--vcr-bar-height` is JS-measured
by the ResizeObserver, not pinned in CSS.

Fixes #1250

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-18 20:48:57 +02:00
efiten 4a016e442d merge: resolve upstream/master conflicts for PR #736
- config.go: keep SaveGeoFilter (PR #736) + add upstream observer
  blacklist helpers and AnalyticsConfig/recompute-interval methods (#1240)
- routes.go: add upstream foreign-node pass-through (#730) before the
  NodePassesGeoFilter check; keep gf local variable from PR
- config.example.json: merge geo_filter _comment (mention both
  Customizer and Builder) + add foreignAdverts block from upstream (#730)
- geofilter-builder.html: take upstream dark theme + Save/Load/Download
  button styles (#736 r2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:29:35 +02:00
efiten 5719b9e579 fix(geo-filter): address PR #736 security review feedback
- Fix data race on s.cfg.GeoFilter: add cfgMu RWMutex with getGeoFilter/
  setGeoFilter accessors used in all handler goroutines
- Add 1 MB MaxBytesReader cap on PUT /api/config/geo-filter request body
- Validate polygon coordinate ranges (lat ∈ [-90,90], lon ∈ [-180,180])
  and reject NaN/Inf values
- Cap polygon at 1000 points to bound storage and computation
- Document writeEnabled information-leak trade-off with a comment
- Add tests for coordinate range rejection and oversized polygon rejection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 13:38:36 +02:00
efiten e92a2333f2 feat: geofilter map modal + light tile theme (#669)
Clicking the small inline map in the customizer GeoFilter tab now opens
a full-screen modal (92vw × 86vh) with Undo/Clear/Done/Cancel controls.
The inline map becomes a read-only preview. Both maps and the standalone
geofilter-builder.html now use CartoDB Positron (light) instead of dark.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 13:38:36 +02:00
efiten 1881c92d6e feat: geofilter customizer tab + PUT /api/config/geo-filter (#669 M3)
Backend:
- Add PUT /api/config/geo-filter (requires X-API-Key) — saves geo_filter
  back to config.json atomically and updates in-memory config immediately,
  no restart needed
- Add SaveGeoFilter() to config.go: reads config as raw map (preserving
  _comment fields), updates geo_filter key, writes back via temp+rename
- Add writeEnabled field to GET /api/config/geo-filter response so the
  frontend can gate editing controls on server write capability
- Add Server.configDir field; wired from -config-dir flag in main.go
- Tests: TestPutConfigGeoFilter (4 cases) + TestSaveGeoFilter (3 cases)

Frontend:
- Add GeoFilter tab (🗺️) to the customizer between Display and Export
- Tab shows current polygon on a Leaflet map (read-only for all users)
- Editing controls (undo, clear, buffer km, API key input, save/remove)
  are only revealed when the server reports writeEnabled=true — i.e. the
  deployment has a write-capable apiKey configured. Public instances see
  a read-only polygon view.
- Save calls PUT /api/config/geo-filter; Remove clears the filter
- Map is destroyed on tab switch and panel close to avoid Leaflet leaks

Docs:
- Add docs/user-guide/geofilter.md (full guide: config, customizer,
  builder, prune script, API)
- Update configuration.md and customization.md with geo_filter section
- Update config.example.json _comment to mention the Customizer tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 13:36:48 +02:00
14 changed files with 848 additions and 942 deletions
+2
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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)
}
}
+1
View File
@@ -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
View File
@@ -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)
}
}
+289
View File
@@ -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
View File
@@ -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&region=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
+3 -1
View File
@@ -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
+5 -3
View File
@@ -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
+45 -31
View File
@@ -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
View File
@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
@@ -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');
+16 -17
View File
@@ -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.
&nbsp;·&nbsp; <a href="/geofilter-docs.html">Documentation</a>
&nbsp;·&nbsp; <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() {