Compare commits

...

14 Commits

Author SHA1 Message Date
Kpa-clawbot
06b609d1de fix: add missing geoFilter arg to handleMessage test calls
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 09:40:26 -07:00
efiten
d2e16e4a51 fix: copy internal/geofilter into Docker build context before go mod download
The replace directive in cmd/server/go.mod points to ../../internal/geofilter
which was not available when go mod download ran inside the builder stage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:31:28 -07:00
efiten
1cd5ce873a refactor(pr215): address important review items
- Extract shared geofilter Go module (internal/geofilter/) with Config
  struct and geometry functions (PointInPolygon, DistToSegmentKm,
  PassesFilter) — eliminates duplication between server and ingestor
- Replace GeoFilterConfig struct in both config.go files with a type
  alias pointing to geofilter.Config
- Slim geo_filter.go in both packages to delegate to shared module
- Add --geo-filter-color CSS variable to style.css; use it in map.js
  and live.js overlay code instead of hardcoded #3b82f6
- Update prune-nodes-outside-geo-filter.py to read polygon/bufferKm
  from config.json (--config flag, default /app/config.json) instead
  of having deployment-specific coordinates hardcoded in the script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:31:28 -07:00
efiten
b40f5fbb75 fix: declare geoFilterLayer in map.js strict mode scope
In strict mode, assigning to an undeclared variable throws a
ReferenceError which was silently caught, hiding the overlay entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:31:28 -07:00
efiten
d02a0eb035 fix: prune opens a separate read-write connection for DB writes
The server's main connection is read-only (mode=ro). PruneOldPackets
now opens a temporary read-write connection just for the delete
transaction, then closes it immediately after.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:31:28 -07:00
efiten
4d6c60d14f fix: add missing geo filter checkbox to map page Display section
The mcGeoFilterLabel/mcGeoFilter elements were never in the map.js
HTML template so the JS couldn't find them. Both pages use the same
localStorage key so state is now shared automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:31:28 -07:00
efiten
827b2a3b8a fix: HB column always shows 1-4, remove null fallback
Every packet has a path byte so hash bytes is always 1-4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:31:28 -07:00
efiten
1df01bf9a7 fix: add HB column to grouped and child rows in packets table
The grouped view (default) rendered its own row templates that were
missing the col-hashsize cell, causing column misalignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:31:28 -07:00
efiten
e835819db3 feat: add hash bytes (HB) column to packets table
Shows the hop hash size in bytes (1-4) decoded from the path byte
of each packet's raw hex. Displayed as 'HB', hidden on small screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:31:28 -07:00
efiten
daf375b458 feat: restore geofilter-builder tool and add packet DB pruning
- Restore tools/geofilter-builder.html — standalone map tool to draw
  a polygon and copy the geo_filter JSON block for config.json
- Add retention.packetDays config option: deletes transmissions and
  their observations older than N days, daily (1 min after startup)
- Add POST /api/admin/prune?days=N endpoint for manual runs
  (requires X-API-Key header if apiKey is configured)
- nodes and observers tables are never pruned

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:31:28 -07:00
efiten
db7a50e408 fix: drop ADVERT packets from out-of-area nodes before any DB write
Move the geo_filter check to before InsertTransmission so that no
transmission, node, or observation data is stored for ADVERT packets
from nodes outside the configured polygon + bufferKm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:31:28 -07:00
efiten
457d38132e feat: enforce geo_filter at ingestor and API level
- Ingestor: skip UpsertNode for ADVERT packets from nodes outside the
  configured geo_filter polygon + bufferKm
- Server: filter /api/nodes responses to exclude out-of-area nodes,
  so any that slipped into the DB are not returned to the frontend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:31:28 -07:00
efiten
9b78a6da5b chore: add script to prune nodes outside geo_filter polygon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:31:28 -07:00
efiten
cf650c889f feat: add geo_filter overlay with buffer zone to map and live pages
- Shows the configured polygon as a solid blue overlay
- Adds a second dashed outer polygon approximating the bufferKm zone
- Both polygons are grouped and toggled together via the checkbox
- Checkbox state is shared between map and live pages (same localStorage key)
- Checkbox is hidden when no geo_filter polygon is configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:31:28 -07:00
21 changed files with 745 additions and 50 deletions

View File

@@ -9,6 +9,7 @@ ARG BUILD_TIME=unknown
# Build server
WORKDIR /build/server
COPY cmd/server/go.mod cmd/server/go.sum ./
COPY internal/geofilter/ ../../internal/geofilter/
RUN go mod download
COPY cmd/server/ ./
RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server .

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"os"
"strings"
"github.com/meshcore-analyzer/geofilter"
)
// MQTTSource represents a single MQTT broker connection.
@@ -34,8 +36,12 @@ type Config struct {
ChannelKeys map[string]string `json:"channelKeys,omitempty"`
HashChannels []string `json:"hashChannels,omitempty"`
Retention *RetentionConfig `json:"retention,omitempty"`
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
}
// GeoFilterConfig is an alias for the shared geofilter.Config type.
type GeoFilterConfig = geofilter.Config
// RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes.
type RetentionConfig struct {
NodeDays int `json:"nodeDays"`

View File

@@ -0,0 +1,15 @@
package main
import "github.com/meshcore-analyzer/geofilter"
// NodePassesGeoFilter returns true if the node should be kept.
// Nodes with no GPS coordinates are always allowed.
func NodePassesGeoFilter(lat, lon *float64, gf *GeoFilterConfig) bool {
if gf == nil {
return true
}
if lat == nil || lon == nil {
return true
}
return geofilter.PassesFilter(*lat, *lon, gf)
}

View File

@@ -4,9 +4,12 @@ go 1.22
require (
github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/meshcore-analyzer/geofilter v0.0.0
modernc.org/sqlite v1.34.5
)
replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect

View File

@@ -136,7 +136,7 @@ func main() {
// Capture source for closure
src := source
opts.SetDefaultPublishHandler(func(c mqtt.Client, m mqtt.Message) {
handleMessage(store, tag, src, m, channelKeys)
handleMessage(store, tag, src, m, channelKeys, cfg.GeoFilter)
})
client := mqtt.NewClient(opts)
@@ -170,7 +170,7 @@ func main() {
log.Println("Done.")
}
func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, channelKeys map[string]string) {
func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, channelKeys map[string]string, geoFilter *GeoFilterConfig) {
defer func() {
if r := recover(); r != nil {
log.Printf("MQTT [%s] panic in handler: %v", tag, r)
@@ -251,33 +251,43 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
mqttMsg.Origin = v
}
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
isNew, err := store.InsertTransmission(pktData)
if err != nil {
log.Printf("MQTT [%s] db insert error: %v", tag, err)
}
// Process ADVERT → upsert node
// For ADVERT packets with known coordinates, enforce geo_filter before
// storing anything — drop the entire message if outside the area.
if decoded.Header.PayloadTypeName == "ADVERT" && decoded.Payload.PubKey != "" {
ok, reason := ValidateAdvert(&decoded.Payload)
if ok {
role := advertRole(decoded.Payload.Flags)
if err := store.UpsertNode(decoded.Payload.PubKey, decoded.Payload.Name, role, decoded.Payload.Lat, decoded.Payload.Lon, pktData.Timestamp); err != nil {
log.Printf("MQTT [%s] node upsert error: %v", tag, err)
}
if isNew {
if err := store.IncrementAdvertCount(decoded.Payload.PubKey); err != nil {
log.Printf("MQTT [%s] advert count error: %v", tag, err)
}
}
// Update telemetry if present in advert
if decoded.Payload.BatteryMv != nil || decoded.Payload.TemperatureC != nil {
if err := store.UpdateNodeTelemetry(decoded.Payload.PubKey, decoded.Payload.BatteryMv, decoded.Payload.TemperatureC); err != nil {
log.Printf("MQTT [%s] node telemetry update error: %v", tag, err)
}
}
} else {
if !ok {
log.Printf("MQTT [%s] skipping corrupted ADVERT: %s", tag, reason)
return
}
if !NodePassesGeoFilter(decoded.Payload.Lat, decoded.Payload.Lon, geoFilter) {
return
}
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
isNew, err := store.InsertTransmission(pktData)
if err != nil {
log.Printf("MQTT [%s] db insert error: %v", tag, err)
}
role := advertRole(decoded.Payload.Flags)
if err := store.UpsertNode(decoded.Payload.PubKey, decoded.Payload.Name, role, decoded.Payload.Lat, decoded.Payload.Lon, pktData.Timestamp); err != nil {
log.Printf("MQTT [%s] node upsert error: %v", tag, err)
}
if isNew {
if err := store.IncrementAdvertCount(decoded.Payload.PubKey); err != nil {
log.Printf("MQTT [%s] advert count error: %v", tag, err)
}
}
// Update telemetry if present in advert
if decoded.Payload.BatteryMv != nil || decoded.Payload.TemperatureC != nil {
if err := store.UpdateNodeTelemetry(decoded.Payload.PubKey, decoded.Payload.BatteryMv, decoded.Payload.TemperatureC); err != nil {
log.Printf("MQTT [%s] node telemetry update error: %v", tag, err)
}
}
} else {
// Non-ADVERT packets: store normally (routing/channel messages from
// in-area observers are relevant regardless of relay hop origin).
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
if _, err := store.InsertTransmission(pktData); err != nil {
log.Printf("MQTT [%s] db insert error: %v", tag, err)
}
}

View File

@@ -124,7 +124,7 @@ func TestHandleMessageRawPacket(t *testing.T) {
payload := []byte(`{"raw":"` + rawHex + `","SNR":5.5,"RSSI":-100.0,"origin":"myobs"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -141,7 +141,7 @@ func TestHandleMessageRawPacketAdvert(t *testing.T) {
payload := []byte(`{"raw":"` + rawHex + `"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
// Should create a node from the ADVERT
var count int
@@ -163,7 +163,7 @@ func TestHandleMessageInvalidJSON(t *testing.T) {
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: []byte(`not json`)}
// Should not panic
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -180,7 +180,7 @@ func TestHandleMessageStatusTopic(t *testing.T) {
payload: []byte(`{"origin":"MyObserver"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var name, iata string
err := store.db.QueryRow("SELECT name, iata FROM observers WHERE id = 'obs1'").Scan(&name, &iata)
@@ -201,11 +201,11 @@ func TestHandleMessageSkipStatusTopics(t *testing.T) {
// meshcore/status should be skipped
msg1 := &mockMessage{topic: "meshcore/status", payload: []byte(`{"raw":"0A00"}`)}
handleMessage(store, "test", source, msg1, nil)
handleMessage(store, "test", source, msg1, nil, nil)
// meshcore/events/connection should be skipped
msg2 := &mockMessage{topic: "meshcore/events/connection", payload: []byte(`{"raw":"0A00"}`)}
handleMessage(store, "test", source, msg2, nil)
handleMessage(store, "test", source, msg2, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -224,7 +224,7 @@ func TestHandleMessageIATAFilter(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -237,7 +237,7 @@ func TestHandleMessageIATAFilter(t *testing.T) {
topic: "meshcore/LAX/obs2/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg2, nil)
handleMessage(store, "test", source, msg2, nil, nil)
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
if count != 1 {
@@ -255,7 +255,7 @@ func TestHandleMessageIATAFilterNoRegion(t *testing.T) {
topic: "meshcore",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
// No region part → filter doesn't apply, message goes through
// Actually the code checks len(parts) > 1 for IATA filter
@@ -271,7 +271,7 @@ func TestHandleMessageNoRawHex(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"type":"companion","data":"something"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -289,7 +289,7 @@ func TestHandleMessageBadRawHex(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"ZZZZ"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -306,7 +306,7 @@ func TestHandleMessageWithSNRRSSIAsNumbers(t *testing.T) {
payload := []byte(`{"raw":"` + rawHex + `","SNR":7.2,"RSSI":-95}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var snr, rssi *float64
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
@@ -325,7 +325,7 @@ func TestHandleMessageMinimalTopic(t *testing.T) {
topic: "meshcore/SJC",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -346,7 +346,7 @@ func TestHandleMessageCorruptedAdvert(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
// Transmission should be inserted (even if advert is invalid)
var count int
@@ -372,7 +372,7 @@ func TestHandleMessageNoObserverID(t *testing.T) {
topic: "packets",
payload: []byte(`{"raw":"` + rawHex + `","origin":"obs1"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -394,7 +394,7 @@ func TestHandleMessageSNRNotFloat(t *testing.T) {
// SNR as a string value — should not parse as float
payload := []byte(`{"raw":"` + rawHex + `","SNR":"bad","RSSI":"bad"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -410,7 +410,7 @@ func TestHandleMessageOriginExtraction(t *testing.T) {
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
payload := []byte(`{"raw":"` + rawHex + `","origin":"MyOrigin"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
// Verify origin was extracted to observer name
var name string
@@ -433,7 +433,7 @@ func TestHandleMessagePanicRecovery(t *testing.T) {
}
// Should not panic — the defer/recover should catch it
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
}
func TestHandleMessageStatusOriginFallback(t *testing.T) {
@@ -445,7 +445,7 @@ func TestHandleMessageStatusOriginFallback(t *testing.T) {
topic: "meshcore/SJC/obs1/status",
payload: []byte(`{"type":"status"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var name string
err := store.db.QueryRow("SELECT name FROM observers WHERE id = 'obs1'").Scan(&name)

View File

@@ -4,6 +4,8 @@ import (
"encoding/json"
"os"
"path/filepath"
"github.com/meshcore-analyzer/geofilter"
)
// Config mirrors the Node.js config.json structure (read-only fields).
@@ -47,6 +49,8 @@ type Config struct {
Retention *RetentionConfig `json:"retention,omitempty"`
PacketStore *PacketStoreConfig `json:"packetStore,omitempty"`
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
}
// PacketStoreConfig controls in-memory packet store limits.
@@ -55,8 +59,12 @@ type PacketStoreConfig struct {
MaxMemoryMB int `json:"maxMemoryMB"` // hard memory ceiling in MB (0 = unlimited)
}
// GeoFilterConfig is an alias for the shared geofilter.Config type.
type GeoFilterConfig = geofilter.Config
type RetentionConfig struct {
NodeDays int `json:"nodeDays"`
NodeDays int `json:"nodeDays"`
PacketDays int `json:"packetDays"`
}
// NodeDaysOrDefault returns the configured retention.nodeDays or 7 if not set.

View File

@@ -1569,3 +1569,39 @@ func nullInt(ni sql.NullInt64) interface{} {
}
return nil
}
// PruneOldPackets deletes transmissions and their observations older than the
// given number of days. Nodes and observers are never touched.
// Returns the number of transmissions deleted.
// Opens a separate read-write connection since the main connection is read-only.
func (db *DB) PruneOldPackets(days int) (int64, error) {
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=10000", db.path)
rw, err := sql.Open("sqlite", dsn)
if err != nil {
return 0, err
}
rw.SetMaxOpenConns(1)
defer rw.Close()
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339)
tx, err := rw.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
// Delete observations linked to old transmissions first (no CASCADE in SQLite)
_, err = tx.Exec(`DELETE FROM observations WHERE transmission_id IN (
SELECT id FROM transmissions WHERE first_seen < ?
)`, cutoff)
if err != nil {
return 0, err
}
res, err := tx.Exec(`DELETE FROM transmissions WHERE first_seen < ?`, cutoff)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, tx.Commit()
}

34
cmd/server/geo_filter.go Normal file
View File

@@ -0,0 +1,34 @@
package main
import "github.com/meshcore-analyzer/geofilter"
// NodePassesGeoFilter returns true if the node should be included in responses.
// Nodes with no GPS coordinates are always allowed.
// lat and lon are interface{} because they come from DB row maps.
func NodePassesGeoFilter(lat, lon interface{}, gf *GeoFilterConfig) bool {
if gf == nil {
return true
}
latF, ok1 := toFloat64(lat)
lonF, ok2 := toFloat64(lon)
if !ok1 || !ok2 {
return true
}
return geofilter.PassesFilter(latF, lonF, gf)
}
func toFloat64(v interface{}) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case float32:
return float64(x), true
case int:
return float64(x), true
case int64:
return float64(x), true
case nil:
return 0, false
}
return 0, false
}

View File

@@ -5,9 +5,12 @@ go 1.22
require (
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/meshcore-analyzer/geofilter v0.0.0
modernc.org/sqlite v1.34.5
)
replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect

View File

@@ -168,6 +168,27 @@ func main() {
stopEviction := store.StartEvictionTicker()
defer stopEviction()
// Auto-prune old packets if retention.packetDays is configured
if cfg.Retention != nil && cfg.Retention.PacketDays > 0 {
days := cfg.Retention.PacketDays
go func() {
time.Sleep(1 * time.Minute)
if n, err := database.PruneOldPackets(days); err != nil {
log.Printf("[prune] error: %v", err)
} else {
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
}
for range time.Tick(24 * time.Hour) {
if n, err := database.PruneOldPackets(days); err != nil {
log.Printf("[prune] error: %v", err)
} else {
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
}
}
}()
log.Printf("[prune] auto-prune enabled: packets older than %d days will be removed daily", days)
}
// Graceful shutdown
httpServer := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),

View File

@@ -102,12 +102,14 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/config/regions", s.handleConfigRegions).Methods("GET")
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")
// System endpoints
r.HandleFunc("/api/health", s.handleHealth).Methods("GET")
r.HandleFunc("/api/stats", s.handleStats).Methods("GET")
r.HandleFunc("/api/perf", s.handlePerf).Methods("GET")
r.HandleFunc("/api/perf/reset", s.handlePerfReset).Methods("POST")
r.HandleFunc("/api/admin/prune", s.handleAdminPrune).Methods("POST")
// Packet endpoints
r.HandleFunc("/api/packets/timestamps", s.handlePacketTimestamps).Methods("GET")
@@ -296,6 +298,15 @@ func (s *Server) handleConfigMap(w http.ResponseWriter, r *http.Request) {
writeJSON(w, MapConfigResponse{Center: center, Zoom: zoom})
}
func (s *Server) handleConfigGeoFilter(w http.ResponseWriter, r *http.Request) {
gf := s.cfg.GeoFilter
if gf == nil || len(gf.Polygon) == 0 {
writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0})
return
}
writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm})
}
// --- System Handlers ---
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -537,6 +548,31 @@ func (s *Server) handlePerfReset(w http.ResponseWriter, r *http.Request) {
writeJSON(w, OkResp{Ok: true})
}
func (s *Server) handleAdminPrune(w http.ResponseWriter, r *http.Request) {
if s.cfg.APIKey != "" && r.Header.Get("X-API-Key") != s.cfg.APIKey {
writeError(w, 401, "invalid or missing API key")
return
}
days := 0
if d := r.URL.Query().Get("days"); d != "" {
fmt.Sscanf(d, "%d", &days)
}
if days <= 0 && s.cfg.Retention != nil {
days = s.cfg.Retention.PacketDays
}
if days <= 0 {
writeError(w, 400, "days parameter required (or set retention.packetDays in config)")
return
}
n, err := s.db.PruneOldPackets(days)
if err != nil {
writeError(w, 500, err.Error())
return
}
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
writeJSON(w, map[string]interface{}{"deleted": n, "days": days})
}
// --- Packet Handlers ---
func (s *Server) handlePackets(w http.ResponseWriter, r *http.Request) {
@@ -830,6 +866,16 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
}
}
}
if s.cfg.GeoFilter != nil {
filtered := nodes[:0]
for _, node := range nodes {
if NodePassesGeoFilter(node["lat"], node["lon"], s.cfg.GeoFilter) {
filtered = append(filtered, node)
}
}
total = len(filtered)
nodes = filtered
}
writeJSON(w, NodeListResponse{Nodes: nodes, Total: total, Counts: counts})
}

View File

@@ -3,7 +3,8 @@
"apiKey": "your-secret-api-key-here",
"retention": {
"nodeDays": 7,
"_comment": "Nodes not seen in this many days are moved to inactive_nodes table. Default 7."
"packetDays": 30,
"_comment": "nodeDays: nodes not seen in N days are moved to inactive_nodes (default 7). packetDays: transmissions+observations older than N days are deleted daily (0 = disabled)."
},
"https": {
"cert": "/path/to/cert.pem",

View File

@@ -0,0 +1,86 @@
// Package geofilter provides the shared geographic filter configuration and
// geometry used by both the server and ingestor packages.
package geofilter
import "math"
// Config defines the geographic filter polygon or bounding box.
// Shared between the server and ingestor packages.
type Config struct {
Polygon [][2]float64 `json:"polygon,omitempty"`
BufferKm float64 `json:"bufferKm,omitempty"`
LatMin *float64 `json:"latMin,omitempty"`
LatMax *float64 `json:"latMax,omitempty"`
LonMin *float64 `json:"lonMin,omitempty"`
LonMax *float64 `json:"lonMax,omitempty"`
}
// PassesFilter returns true if the coordinates fall within the filter area.
// Nodes with no GPS fix (0,0) are always allowed.
func PassesFilter(lat, lon float64, gf *Config) bool {
if gf == nil {
return true
}
if lat == 0 && lon == 0 {
return true
}
if len(gf.Polygon) >= 3 {
if PointInPolygon(lat, lon, gf.Polygon) {
return true
}
if gf.BufferKm > 0 {
n := len(gf.Polygon)
for i := 0; i < n; i++ {
j := (i + 1) % n
if DistToSegmentKm(lat, lon, gf.Polygon[i], gf.Polygon[j]) <= gf.BufferKm {
return true
}
}
}
return false
}
// Legacy bounding box fallback
if gf.LatMin != nil && gf.LatMax != nil && gf.LonMin != nil && gf.LonMax != nil {
return lat >= *gf.LatMin && lat <= *gf.LatMax && lon >= *gf.LonMin && lon <= *gf.LonMax
}
return true
}
// PointInPolygon uses the ray-casting algorithm.
func PointInPolygon(lat, lon float64, polygon [][2]float64) bool {
inside := false
n := len(polygon)
j := n - 1
for i := 0; i < n; i++ {
yi, xi := polygon[i][0], polygon[i][1]
yj, xj := polygon[j][0], polygon[j][1]
if (yi > lat) != (yj > lat) {
if lon < (xj-xi)*(lat-yi)/(yj-yi)+xi {
inside = !inside
}
}
j = i
}
return inside
}
// DistToSegmentKm returns the approximate distance in km from point (lat,lon)
// to line segment a→b using a flat-earth projection.
func DistToSegmentKm(lat, lon float64, a, b [2]float64) float64 {
lat1, lon1 := a[0], a[1]
lat2, lon2 := b[0], b[1]
cosLat := math.Cos((lat1+lat2) / 2.0 * math.Pi / 180.0)
ax := (lon1 - lon) * 111.0 * cosLat
ay := (lat1 - lat) * 111.0
bx := (lon2 - lon) * 111.0 * cosLat
by := (lat2 - lat) * 111.0
abx, aby := bx-ax, by-ay
abSq := abx*abx + aby*aby
if abSq == 0 {
return math.Sqrt(ax*ax + ay*ay)
}
t := math.Max(0, math.Min(1, -(ax*abx+ay*aby)/abSq))
px := ax + t*abx
py := ay + t*aby
return math.Sqrt(px*px + py*py)
}

View File

@@ -0,0 +1,3 @@
module github.com/meshcore-analyzer/geofilter
go 1.22

View File

@@ -5,7 +5,7 @@
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
let map, ws, nodesLayer, pathsLayer, animLayer, heatLayer;
let map, ws, nodesLayer, pathsLayer, animLayer, heatLayer, geoFilterLayer;
let nodeMarkers = {};
let nodeData = {};
let packetCount = 0;
@@ -658,6 +658,7 @@
<span id="audioDesc" class="sr-only">Sonify packets — turn raw bytes into generative music</span>
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> ⭐ Favorites</label>
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
<label id="liveGeoFilterLabel" style="display:none"><input type="checkbox" id="liveGeoFilterToggle"> Mesh live area</label>
</div>
<div class="audio-controls hidden" id="audioControls">
<label class="audio-slider-label">Voice <select id="audioVoiceSelect" class="audio-voice-select"></select></label>
@@ -801,6 +802,50 @@
applyFavoritesFilter();
});
// Geo filter overlay
(async function () {
try {
const gf = await api('/config/geo-filter', { ttl: 3600 });
if (!gf || !gf.polygon || gf.polygon.length < 3) return;
const geoColor = cssVar('--geo-filter-color') || '#3b82f6';
const latlngs = gf.polygon.map(function (p) { return [p[0], p[1]]; });
const innerPoly = L.polygon(latlngs, {
color: geoColor, weight: 2, opacity: 0.8,
fillColor: geoColor, fillOpacity: 0.08
});
const bufferPoly = gf.bufferKm > 0 ? (function () {
let cLat = 0, cLon = 0;
gf.polygon.forEach(function (p) { cLat += p[0]; cLon += p[1]; });
cLat /= gf.polygon.length; cLon /= gf.polygon.length;
const cosLat = Math.cos(cLat * Math.PI / 180);
const outer = gf.polygon.map(function (p) {
const dLatM = (p[0] - cLat) * 111000;
const dLonM = (p[1] - cLon) * 111000 * cosLat;
const dist = Math.sqrt(dLatM * dLatM + dLonM * dLonM);
if (dist === 0) return [p[0], p[1]];
const scale = (gf.bufferKm * 1000) / dist;
return [p[0] + dLatM * scale / 111000, p[1] + dLonM * scale / (111000 * cosLat)];
});
return L.polygon(outer, {
color: geoColor, weight: 1.5, opacity: 0.4, dashArray: '6 4',
fillColor: geoColor, fillOpacity: 0.04
});
})() : null;
geoFilterLayer = L.layerGroup(bufferPoly ? [bufferPoly, innerPoly] : [innerPoly]);
const label = document.getElementById('liveGeoFilterLabel');
if (label) label.style.display = '';
const el = document.getElementById('liveGeoFilterToggle');
if (el) {
const saved = localStorage.getItem('meshcore-map-geo-filter');
if (saved === 'true') { el.checked = true; geoFilterLayer.addTo(map); }
el.addEventListener('change', function (e) {
localStorage.setItem('meshcore-map-geo-filter', e.target.checked);
if (e.target.checked) { geoFilterLayer.addTo(map); } else { map.removeLayer(geoFilterLayer); }
});
}
} catch (e) { /* no geo filter configured */ }
})();
const matrixToggle = document.getElementById('liveMatrixToggle');
matrixToggle.checked = matrixMode;
matrixToggle.addEventListener('change', (e) => {
@@ -2431,7 +2476,7 @@
}
_navCleanup = null;
}
nodesLayer = pathsLayer = animLayer = heatLayer = null;
nodesLayer = pathsLayer = animLayer = heatLayer = geoFilterLayer = null;
stopMatrixRain();
nodeMarkers = {}; nodeData = {};
recentPaths = [];

View File

@@ -12,6 +12,7 @@
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false, hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all' };
let wsHandler = null;
let heatLayer = null;
let geoFilterLayer = null;
let userHasMoved = false;
let controlsCollapsed = false;
@@ -94,6 +95,7 @@
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
<label for="mcHeatmap"><input type="checkbox" id="mcHeatmap"> Heat map</label>
<label for="mcHashLabels"><input type="checkbox" id="mcHashLabels"> Hash prefix labels</label>
<label id="mcGeoFilterLabel" for="mcGeoFilter" style="display:none"><input type="checkbox" id="mcGeoFilter"> Mesh live area</label>
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Status</legend>
@@ -225,6 +227,51 @@
});
});
// Geo filter overlay
(async function () {
try {
const gf = await api('/config/geo-filter', { ttl: 3600 });
if (!gf || !gf.polygon || gf.polygon.length < 3) return;
const geoColor = getComputedStyle(document.documentElement).getPropertyValue('--geo-filter-color').trim() || '#3b82f6';
const latlngs = gf.polygon.map(function (p) { return [p[0], p[1]]; });
const innerPoly = L.polygon(latlngs, {
color: geoColor, weight: 2, opacity: 0.8,
fillColor: geoColor, fillOpacity: 0.08
});
// Approximate buffer zone — expand each vertex outward from centroid by bufferKm
const bufferPoly = gf.bufferKm > 0 ? (function () {
let cLat = 0, cLon = 0;
gf.polygon.forEach(function (p) { cLat += p[0]; cLon += p[1]; });
cLat /= gf.polygon.length; cLon /= gf.polygon.length;
const cosLat = Math.cos(cLat * Math.PI / 180);
const outer = gf.polygon.map(function (p) {
const dLatM = (p[0] - cLat) * 111000;
const dLonM = (p[1] - cLon) * 111000 * cosLat;
const dist = Math.sqrt(dLatM * dLatM + dLonM * dLonM);
if (dist === 0) return [p[0], p[1]];
const scale = (gf.bufferKm * 1000) / dist;
return [p[0] + dLatM * scale / 111000, p[1] + dLonM * scale / (111000 * cosLat)];
});
return L.polygon(outer, {
color: geoColor, weight: 1.5, opacity: 0.4, dashArray: '6 4',
fillColor: geoColor, fillOpacity: 0.04
});
})() : null;
geoFilterLayer = L.layerGroup(bufferPoly ? [bufferPoly, innerPoly] : [innerPoly]);
const label = document.getElementById('mcGeoFilterLabel');
if (label) label.style.display = '';
const el = document.getElementById('mcGeoFilter');
if (el) {
const saved = localStorage.getItem('meshcore-map-geo-filter');
if (saved === 'true') { el.checked = true; geoFilterLayer.addTo(map); }
el.addEventListener('change', function (e) {
localStorage.setItem('meshcore-map-geo-filter', e.target.checked);
if (e.target.checked) { geoFilterLayer.addTo(map); } else { map.removeLayer(geoFilterLayer); }
});
}
} catch (e) { /* no geo filter configured */ }
})();
// WS for live advert updates
wsHandler = debouncedOnWS(function (msgs) {
if (msgs.some(function (m) { return m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'ADVERT'; })) {

View File

@@ -561,6 +561,7 @@
<table class="data-table" id="pktTable">
<thead><tr>
<th scope="col"></th><th scope="col" class="col-region">Region</th><th scope="col" class="col-time">Time</th><th scope="col" class="col-hash">Hash</th><th scope="col" class="col-size">Size</th>
<th scope="col" class="col-hashsize">HB</th>
<th scope="col" class="col-type">Type</th><th scope="col" class="col-observer">Observer</th><th scope="col" class="col-path">Path</th><th scope="col" class="col-rpt">Rpt</th><th scope="col" class="col-details">Details</th>
</tr></thead>
<tbody id="pktBody"></tbody>
@@ -1020,6 +1021,7 @@
const groupTypeName = payloadTypeName(p.payload_type);
const groupTypeClass = payloadTypeColor(p.payload_type);
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const groupHashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
const isSingle = p.count <= 1;
html += `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" tabindex="0" role="row">
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
@@ -1027,6 +1029,7 @@
<td class="col-time">${timeAgo(p.latest)}</td>
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
<td class="col-hashsize mono">${groupHashBytes}</td>
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>` : '—'}</td>
<td class="col-observer">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
@@ -1045,6 +1048,7 @@
const typeName = payloadTypeName(c.payload_type);
const typeClass = payloadTypeColor(c.payload_type);
const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0;
const childHashBytes = ((parseInt(c.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
const childRegion = c.observer_id ? (observers.find(o => o.id === c.observer_id)?.iata || '') : '';
let childPath = [];
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
@@ -1054,6 +1058,7 @@
<td class="col-time">${timeAgo(c.timestamp)}</td>
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
<td class="col-size">${size}B</td>
<td class="col-hashsize mono">${childHashBytes}</td>
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
@@ -1076,6 +1081,7 @@
const typeName = payloadTypeName(p.payload_type);
const typeClass = payloadTypeColor(p.payload_type);
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const hashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
const pathStr = renderPath(pathHops, p.observer_id); const detail = getDetailPreview(decoded);
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
@@ -1083,6 +1089,7 @@
<td class="col-time">${timeAgo(p.timestamp)}</td>
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
<td class="col-size">${size}B</td>
<td class="col-hashsize mono">${hashBytes}</td>
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
<td class="col-observer">${truncate(obsName(p.observer_id), 16)}</td>
<td class="col-path"><span class="path-hops">${pathStr}</span></td>

View File

@@ -6,6 +6,7 @@
--nav-text: #ffffff;
--nav-text-muted: #cbd5e1;
--accent: #4a9eff;
--geo-filter-color: #3b82f6;
--status-green: #22c55e;
--status-yellow: #eab308;
--status-red: #ef4444;
@@ -1205,7 +1206,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
/* Hide low-value columns on mobile */
@media (max-width: 640px) {
.col-region, .col-rpt, .col-size, .col-pubkey { display: none; }
.col-region, .col-rpt, .col-size, .col-hashsize, .col-pubkey { display: none; }
}
/* Clickable hop links */
@@ -1351,6 +1352,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.hide-col-observer .col-observer,
.hide-col-path .col-path,
.hide-col-rpt .col-rpt,
.hide-col-hashsize .col-hashsize,
.hide-col-details .col-details { display: none; }
/* === Home page fixes === */

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
Delete nodes from the database that fall outside the configured geo_filter polygon + bufferKm.
Nodes with no GPS coordinates are always kept.
Usage:
python3 prune-nodes-outside-geo-filter.py [db_path] [--config config.json] [--dry-run]
db_path Path to meshcore.db (default: /app/data/meshcore.db)
--config PATH Path to config.json (default: /app/config.json)
--dry-run Show what would be deleted without making any changes
"""
import sqlite3
import math
import sys
import json
import os
def point_in_polygon(lat, lon, polygon):
"""Ray-casting algorithm."""
inside = False
n = len(polygon)
j = n - 1
for i in range(n):
yi, xi = polygon[i] # lat, lon
yj, xj = polygon[j]
if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi):
inside = not inside
j = i
return inside
def dist_to_segment_km(lat, lon, a, b):
"""Approximate distance (km) from point to line segment, using flat-earth projection."""
lat1, lon1 = a
lat2, lon2 = b
mid_lat = (lat1 + lat2) / 2.0
cos_lat = math.cos(math.radians(mid_lat))
km_per_deg_lat = 111.0
km_per_deg_lon = 111.0 * cos_lat
# Translate so point is at origin
ax = (lon1 - lon) * km_per_deg_lon
ay = (lat1 - lat) * km_per_deg_lat
bx = (lon2 - lon) * km_per_deg_lon
by = (lat2 - lat) * km_per_deg_lat
abx, aby = bx - ax, by - ay
ab_sq = abx * abx + aby * aby
if ab_sq == 0:
return math.sqrt(ax * ax + ay * ay)
t = max(0.0, min(1.0, -(ax * abx + ay * aby) / ab_sq))
px = ax + t * abx
py = ay + t * aby
return math.sqrt(px * px + py * py)
def node_passes_filter(lat, lon, polygon, buffer_km):
"""Return True if the node should be kept."""
if lat is None or lon is None:
return True
if lat == 0.0 and lon == 0.0:
return True # no GPS fix
if point_in_polygon(lat, lon, polygon):
return True
if buffer_km > 0:
n = len(polygon)
for i in range(n):
j = (i + 1) % n
if dist_to_segment_km(lat, lon, polygon[i], polygon[j]) <= buffer_km:
return True
return False
def load_geo_filter(config_path):
"""Load polygon and bufferKm from config.json geo_filter section."""
if not os.path.exists(config_path):
print(f"ERROR: config not found at {config_path}")
sys.exit(1)
with open(config_path) as f:
cfg = json.load(f)
gf = cfg.get('geo_filter')
if not gf:
print("ERROR: no geo_filter section found in config.json")
sys.exit(1)
polygon = gf.get('polygon', [])
if len(polygon) < 3:
print("ERROR: geo_filter.polygon must have at least 3 points")
sys.exit(1)
buffer_km = gf.get('bufferKm', 0.0)
print(f"Loaded geo_filter from {config_path}: {len(polygon)} points, bufferKm={buffer_km}")
return polygon, buffer_km
def main():
args = sys.argv[1:]
dry_run = '--dry-run' in args
args = [a for a in args if a != '--dry-run']
config_path = '/app/config.json'
if '--config' in args:
idx = args.index('--config')
config_path = args[idx + 1]
args = args[:idx] + args[idx + 2:]
db_path = args[0] if args else '/app/data/meshcore.db'
polygon, buffer_km = load_geo_filter(config_path)
if not os.path.exists(db_path):
print(f"ERROR: database not found at {db_path}")
sys.exit(1)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute('SELECT public_key, name, lat, lon FROM nodes ORDER BY name')
nodes = cur.fetchall()
keep, remove = [], []
for row in nodes:
lat = row['lat']
lon = row['lon']
if node_passes_filter(lat, lon, polygon, buffer_km):
keep.append(row)
else:
remove.append(row)
print(f"Total nodes in DB : {len(nodes)}")
print(f"Nodes to keep : {len(keep)}")
print(f"Nodes to delete : {len(remove)}")
if not remove:
print("\nNothing to delete.")
conn.close()
return
print("\nNodes that will be DELETED:")
for row in remove:
lat = row['lat'] or 0
lon = row['lon'] or 0
name = row['name'] or row['public_key'][:12]
print(f" {name:<30} lat={lat:.4f} lon={lon:.4f}")
if dry_run:
print("\n[dry-run] No changes made.")
conn.close()
return
confirm = input(f"\nDelete {len(remove)} nodes? Type 'yes' to confirm: ").strip()
if confirm.lower() != 'yes':
print("Aborted.")
conn.close()
return
pubkeys = [row['public_key'] for row in remove]
cur.executemany('DELETE FROM nodes WHERE public_key = ?', [(pk,) for pk in pubkeys])
conn.commit()
print(f"\nDeleted {cur.rowcount if cur.rowcount >= 0 else len(pubkeys)} nodes.")
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoFilter Builder</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<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; }
.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; }
.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 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; }
#btnCopy { padding: 6px 14px; background: #1a4a7a; color: #7ec8e3; border-radius: 6px; border: none; cursor: pointer; font-size: 0.85rem; white-space: nowrap; align-self: flex-end; }
#btnCopy:hover { background: #2a6aaa; }
#btnCopy.copied { background: #1a6a3a; color: #7effa0; }
#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; }
</style>
</head>
<body>
<header>
<h1>GeoFilter Builder</h1>
<div class="controls">
<button id="btnUndo">↩ Undo</button>
<button id="btnClear">✕ Clear</button>
</div>
<div class="bufferRow">
<label for="bufferKm">Buffer km:</label>
<input type="number" id="bufferKm" value="20" min="0" max="500"/>
</div>
<span class="hint">Click on the map to add polygon points</span>
</header>
<div id="map"></div>
<div id="output-panel">
<label>config.json</label>
<div id="output" class="empty">Add at least 3 points to generate config…</div>
<div style="display:flex;flex-direction:column;gap:8px;align-items:flex-end">
<span id="counter">0 points</span>
<button id="btnCopy">Copy</button>
</div>
</div>
<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', {
attribution: '© OpenStreetMap © CartoDB',
maxZoom: 19
}).addTo(map);
let points = [];
let markers = [];
let polygon = null;
let closingLine = null;
function latLonPair(latlng) {
return [parseFloat(latlng.lat.toFixed(6)), parseFloat(latlng.lng.toFixed(6))];
}
function render() {
// Remove existing polygon and closing line
if (polygon) { map.removeLayer(polygon); polygon = null; }
if (closingLine) { map.removeLayer(closingLine); closingLine = null; }
if (points.length >= 3) {
polygon = L.polygon(points, {
color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.12
}).addTo(map);
} else if (points.length === 2) {
closingLine = L.polyline(points, { color: '#4a9eff', weight: 2, dashArray: '5,5' }).addTo(map);
}
updateOutput();
}
function updateOutput() {
const el = document.getElementById('output');
const counter = document.getElementById('counter');
counter.textContent = points.length + ' point' + (points.length !== 1 ? 's' : '');
if (points.length < 3) {
el.textContent = 'Add at least 3 points to generate config…';
el.classList.add('empty');
return;
}
el.classList.remove('empty');
const bufferKm = parseFloat(document.getElementById('bufferKm').value) || 0;
const config = { bufferKm, polygon: points };
el.textContent = JSON.stringify({ geo_filter: config }, null, 2);
}
map.on('click', function(e) {
const pt = latLonPair(e.latlng);
points.push(pt);
const idx = points.length;
const marker = L.circleMarker(e.latlng, {
radius: 6, color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.9
}).addTo(map).bindTooltip(String(idx), { permanent: true, direction: 'top', offset: [0, -8], className: 'pt-label' });
markers.push(marker);
render();
});
document.getElementById('btnUndo').addEventListener('click', function() {
if (!points.length) return;
points.pop();
const m = markers.pop();
if (m) map.removeLayer(m);
render();
});
document.getElementById('btnClear').addEventListener('click', function() {
points = [];
markers.forEach(m => map.removeLayer(m));
markers = [];
render();
});
document.getElementById('bufferKm').addEventListener('input', updateOutput);
document.getElementById('btnCopy').addEventListener('click', function() {
if (points.length < 3) return;
const text = document.getElementById('output').textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('btnCopy');
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
});
});
</script>
</body>
</html>