mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-26 12:57:22 +00:00
Compare commits
14 Commits
master
...
pr/updates
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06b609d1de | ||
|
|
d2e16e4a51 | ||
|
|
1cd5ce873a | ||
|
|
b40f5fbb75 | ||
|
|
d02a0eb035 | ||
|
|
4d6c60d14f | ||
|
|
827b2a3b8a | ||
|
|
1df01bf9a7 | ||
|
|
e835819db3 | ||
|
|
daf375b458 | ||
|
|
db7a50e408 | ||
|
|
457d38132e | ||
|
|
9b78a6da5b | ||
|
|
cf650c889f |
@@ -9,6 +9,7 @@ ARG BUILD_TIME=unknown
|
|||||||
# Build server
|
# Build server
|
||||||
WORKDIR /build/server
|
WORKDIR /build/server
|
||||||
COPY cmd/server/go.mod cmd/server/go.sum ./
|
COPY cmd/server/go.mod cmd/server/go.sum ./
|
||||||
|
COPY internal/geofilter/ ../../internal/geofilter/
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY cmd/server/ ./
|
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 .
|
RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server .
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/meshcore-analyzer/geofilter"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MQTTSource represents a single MQTT broker connection.
|
// MQTTSource represents a single MQTT broker connection.
|
||||||
@@ -34,8 +36,12 @@ type Config struct {
|
|||||||
ChannelKeys map[string]string `json:"channelKeys,omitempty"`
|
ChannelKeys map[string]string `json:"channelKeys,omitempty"`
|
||||||
HashChannels []string `json:"hashChannels,omitempty"`
|
HashChannels []string `json:"hashChannels,omitempty"`
|
||||||
Retention *RetentionConfig `json:"retention,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.
|
// RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes.
|
||||||
type RetentionConfig struct {
|
type RetentionConfig struct {
|
||||||
NodeDays int `json:"nodeDays"`
|
NodeDays int `json:"nodeDays"`
|
||||||
|
|||||||
15
cmd/ingestor/geo_filter.go
Normal file
15
cmd/ingestor/geo_filter.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -4,9 +4,12 @@ go 1.22
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
github.com/eclipse/paho.mqtt.golang v1.5.0
|
||||||
|
github.com/meshcore-analyzer/geofilter v0.0.0
|
||||||
modernc.org/sqlite v1.34.5
|
modernc.org/sqlite v1.34.5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func main() {
|
|||||||
// Capture source for closure
|
// Capture source for closure
|
||||||
src := source
|
src := source
|
||||||
opts.SetDefaultPublishHandler(func(c mqtt.Client, m mqtt.Message) {
|
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)
|
client := mqtt.NewClient(opts)
|
||||||
@@ -170,7 +170,7 @@ func main() {
|
|||||||
log.Println("Done.")
|
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() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
log.Printf("MQTT [%s] panic in handler: %v", tag, r)
|
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
|
mqttMsg.Origin = v
|
||||||
}
|
}
|
||||||
|
|
||||||
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
|
// For ADVERT packets with known coordinates, enforce geo_filter before
|
||||||
isNew, err := store.InsertTransmission(pktData)
|
// storing anything — drop the entire message if outside the area.
|
||||||
if err != nil {
|
|
||||||
log.Printf("MQTT [%s] db insert error: %v", tag, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process ADVERT → upsert node
|
|
||||||
if decoded.Header.PayloadTypeName == "ADVERT" && decoded.Payload.PubKey != "" {
|
if decoded.Header.PayloadTypeName == "ADVERT" && decoded.Payload.PubKey != "" {
|
||||||
ok, reason := ValidateAdvert(&decoded.Payload)
|
ok, reason := ValidateAdvert(&decoded.Payload)
|
||||||
if ok {
|
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 {
|
|
||||||
log.Printf("MQTT [%s] skipping corrupted ADVERT: %s", tag, reason)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ func TestHandleMessageRawPacket(t *testing.T) {
|
|||||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":5.5,"RSSI":-100.0,"origin":"myobs"}`)
|
payload := []byte(`{"raw":"` + rawHex + `","SNR":5.5,"RSSI":-100.0,"origin":"myobs"}`)
|
||||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
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
|
var count int
|
||||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||||
@@ -141,7 +141,7 @@ func TestHandleMessageRawPacketAdvert(t *testing.T) {
|
|||||||
payload := []byte(`{"raw":"` + rawHex + `"}`)
|
payload := []byte(`{"raw":"` + rawHex + `"}`)
|
||||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
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
|
// Should create a node from the ADVERT
|
||||||
var count int
|
var count int
|
||||||
@@ -163,7 +163,7 @@ func TestHandleMessageInvalidJSON(t *testing.T) {
|
|||||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: []byte(`not json`)}
|
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: []byte(`not json`)}
|
||||||
|
|
||||||
// Should not panic
|
// Should not panic
|
||||||
handleMessage(store, "test", source, msg, nil)
|
handleMessage(store, "test", source, msg, nil, nil)
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||||
@@ -180,7 +180,7 @@ func TestHandleMessageStatusTopic(t *testing.T) {
|
|||||||
payload: []byte(`{"origin":"MyObserver"}`),
|
payload: []byte(`{"origin":"MyObserver"}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMessage(store, "test", source, msg, nil)
|
handleMessage(store, "test", source, msg, nil, nil)
|
||||||
|
|
||||||
var name, iata string
|
var name, iata string
|
||||||
err := store.db.QueryRow("SELECT name, iata FROM observers WHERE id = 'obs1'").Scan(&name, &iata)
|
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
|
// meshcore/status should be skipped
|
||||||
msg1 := &mockMessage{topic: "meshcore/status", payload: []byte(`{"raw":"0A00"}`)}
|
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
|
// meshcore/events/connection should be skipped
|
||||||
msg2 := &mockMessage{topic: "meshcore/events/connection", payload: []byte(`{"raw":"0A00"}`)}
|
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
|
var count int
|
||||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||||
@@ -224,7 +224,7 @@ func TestHandleMessageIATAFilter(t *testing.T) {
|
|||||||
topic: "meshcore/SJC/obs1/packets",
|
topic: "meshcore/SJC/obs1/packets",
|
||||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||||
}
|
}
|
||||||
handleMessage(store, "test", source, msg, nil)
|
handleMessage(store, "test", source, msg, nil, nil)
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||||
@@ -237,7 +237,7 @@ func TestHandleMessageIATAFilter(t *testing.T) {
|
|||||||
topic: "meshcore/LAX/obs2/packets",
|
topic: "meshcore/LAX/obs2/packets",
|
||||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
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)
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
@@ -255,7 +255,7 @@ func TestHandleMessageIATAFilterNoRegion(t *testing.T) {
|
|||||||
topic: "meshcore",
|
topic: "meshcore",
|
||||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
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
|
// No region part → filter doesn't apply, message goes through
|
||||||
// Actually the code checks len(parts) > 1 for IATA filter
|
// Actually the code checks len(parts) > 1 for IATA filter
|
||||||
@@ -271,7 +271,7 @@ func TestHandleMessageNoRawHex(t *testing.T) {
|
|||||||
topic: "meshcore/SJC/obs1/packets",
|
topic: "meshcore/SJC/obs1/packets",
|
||||||
payload: []byte(`{"type":"companion","data":"something"}`),
|
payload: []byte(`{"type":"companion","data":"something"}`),
|
||||||
}
|
}
|
||||||
handleMessage(store, "test", source, msg, nil)
|
handleMessage(store, "test", source, msg, nil, nil)
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||||
@@ -289,7 +289,7 @@ func TestHandleMessageBadRawHex(t *testing.T) {
|
|||||||
topic: "meshcore/SJC/obs1/packets",
|
topic: "meshcore/SJC/obs1/packets",
|
||||||
payload: []byte(`{"raw":"ZZZZ"}`),
|
payload: []byte(`{"raw":"ZZZZ"}`),
|
||||||
}
|
}
|
||||||
handleMessage(store, "test", source, msg, nil)
|
handleMessage(store, "test", source, msg, nil, nil)
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
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}`)
|
payload := []byte(`{"raw":"` + rawHex + `","SNR":7.2,"RSSI":-95}`)
|
||||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
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
|
var snr, rssi *float64
|
||||||
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
|
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",
|
topic: "meshcore/SJC",
|
||||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||||
}
|
}
|
||||||
handleMessage(store, "test", source, msg, nil)
|
handleMessage(store, "test", source, msg, nil, nil)
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||||
@@ -346,7 +346,7 @@ func TestHandleMessageCorruptedAdvert(t *testing.T) {
|
|||||||
topic: "meshcore/SJC/obs1/packets",
|
topic: "meshcore/SJC/obs1/packets",
|
||||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
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)
|
// Transmission should be inserted (even if advert is invalid)
|
||||||
var count int
|
var count int
|
||||||
@@ -372,7 +372,7 @@ func TestHandleMessageNoObserverID(t *testing.T) {
|
|||||||
topic: "packets",
|
topic: "packets",
|
||||||
payload: []byte(`{"raw":"` + rawHex + `","origin":"obs1"}`),
|
payload: []byte(`{"raw":"` + rawHex + `","origin":"obs1"}`),
|
||||||
}
|
}
|
||||||
handleMessage(store, "test", source, msg, nil)
|
handleMessage(store, "test", source, msg, nil, nil)
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
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
|
// SNR as a string value — should not parse as float
|
||||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":"bad","RSSI":"bad"}`)
|
payload := []byte(`{"raw":"` + rawHex + `","SNR":"bad","RSSI":"bad"}`)
|
||||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
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
|
var count int
|
||||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||||
@@ -410,7 +410,7 @@ func TestHandleMessageOriginExtraction(t *testing.T) {
|
|||||||
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
||||||
payload := []byte(`{"raw":"` + rawHex + `","origin":"MyOrigin"}`)
|
payload := []byte(`{"raw":"` + rawHex + `","origin":"MyOrigin"}`)
|
||||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
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
|
// Verify origin was extracted to observer name
|
||||||
var name string
|
var name string
|
||||||
@@ -433,7 +433,7 @@ func TestHandleMessagePanicRecovery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Should not panic — the defer/recover should catch it
|
// 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) {
|
func TestHandleMessageStatusOriginFallback(t *testing.T) {
|
||||||
@@ -445,7 +445,7 @@ func TestHandleMessageStatusOriginFallback(t *testing.T) {
|
|||||||
topic: "meshcore/SJC/obs1/status",
|
topic: "meshcore/SJC/obs1/status",
|
||||||
payload: []byte(`{"type":"status"}`),
|
payload: []byte(`{"type":"status"}`),
|
||||||
}
|
}
|
||||||
handleMessage(store, "test", source, msg, nil)
|
handleMessage(store, "test", source, msg, nil, nil)
|
||||||
|
|
||||||
var name string
|
var name string
|
||||||
err := store.db.QueryRow("SELECT name FROM observers WHERE id = 'obs1'").Scan(&name)
|
err := store.db.QueryRow("SELECT name FROM observers WHERE id = 'obs1'").Scan(&name)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/meshcore-analyzer/geofilter"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config mirrors the Node.js config.json structure (read-only fields).
|
// Config mirrors the Node.js config.json structure (read-only fields).
|
||||||
@@ -47,6 +49,8 @@ type Config struct {
|
|||||||
Retention *RetentionConfig `json:"retention,omitempty"`
|
Retention *RetentionConfig `json:"retention,omitempty"`
|
||||||
|
|
||||||
PacketStore *PacketStoreConfig `json:"packetStore,omitempty"`
|
PacketStore *PacketStoreConfig `json:"packetStore,omitempty"`
|
||||||
|
|
||||||
|
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PacketStoreConfig controls in-memory packet store limits.
|
// 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)
|
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 {
|
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.
|
// NodeDaysOrDefault returns the configured retention.nodeDays or 7 if not set.
|
||||||
|
|||||||
@@ -1569,3 +1569,39 @@ func nullInt(ni sql.NullInt64) interface{} {
|
|||||||
}
|
}
|
||||||
return nil
|
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
34
cmd/server/geo_filter.go
Normal 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
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@ go 1.22
|
|||||||
require (
|
require (
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/meshcore-analyzer/geofilter v0.0.0
|
||||||
modernc.org/sqlite v1.34.5
|
modernc.org/sqlite v1.34.5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
|||||||
@@ -168,6 +168,27 @@ func main() {
|
|||||||
stopEviction := store.StartEvictionTicker()
|
stopEviction := store.StartEvictionTicker()
|
||||||
defer stopEviction()
|
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
|
// Graceful shutdown
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||||
|
|||||||
@@ -102,12 +102,14 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
|||||||
r.HandleFunc("/api/config/regions", s.handleConfigRegions).Methods("GET")
|
r.HandleFunc("/api/config/regions", s.handleConfigRegions).Methods("GET")
|
||||||
r.HandleFunc("/api/config/theme", s.handleConfigTheme).Methods("GET")
|
r.HandleFunc("/api/config/theme", s.handleConfigTheme).Methods("GET")
|
||||||
r.HandleFunc("/api/config/map", s.handleConfigMap).Methods("GET")
|
r.HandleFunc("/api/config/map", s.handleConfigMap).Methods("GET")
|
||||||
|
r.HandleFunc("/api/config/geo-filter", s.handleConfigGeoFilter).Methods("GET")
|
||||||
|
|
||||||
// System endpoints
|
// System endpoints
|
||||||
r.HandleFunc("/api/health", s.handleHealth).Methods("GET")
|
r.HandleFunc("/api/health", s.handleHealth).Methods("GET")
|
||||||
r.HandleFunc("/api/stats", s.handleStats).Methods("GET")
|
r.HandleFunc("/api/stats", s.handleStats).Methods("GET")
|
||||||
r.HandleFunc("/api/perf", s.handlePerf).Methods("GET")
|
r.HandleFunc("/api/perf", s.handlePerf).Methods("GET")
|
||||||
r.HandleFunc("/api/perf/reset", s.handlePerfReset).Methods("POST")
|
r.HandleFunc("/api/perf/reset", s.handlePerfReset).Methods("POST")
|
||||||
|
r.HandleFunc("/api/admin/prune", s.handleAdminPrune).Methods("POST")
|
||||||
|
|
||||||
// Packet endpoints
|
// Packet endpoints
|
||||||
r.HandleFunc("/api/packets/timestamps", s.handlePacketTimestamps).Methods("GET")
|
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})
|
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 ---
|
// --- System Handlers ---
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
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})
|
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 ---
|
// --- Packet Handlers ---
|
||||||
|
|
||||||
func (s *Server) handlePackets(w http.ResponseWriter, r *http.Request) {
|
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})
|
writeJSON(w, NodeListResponse{Nodes: nodes, Total: total, Counts: counts})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"apiKey": "your-secret-api-key-here",
|
"apiKey": "your-secret-api-key-here",
|
||||||
"retention": {
|
"retention": {
|
||||||
"nodeDays": 7,
|
"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": {
|
"https": {
|
||||||
"cert": "/path/to/cert.pem",
|
"cert": "/path/to/cert.pem",
|
||||||
|
|||||||
86
internal/geofilter/geofilter.go
Normal file
86
internal/geofilter/geofilter.go
Normal 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)
|
||||||
|
}
|
||||||
3
internal/geofilter/go.mod
Normal file
3
internal/geofilter/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/meshcore-analyzer/geofilter
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||||||
function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
|
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 nodeMarkers = {};
|
||||||
let nodeData = {};
|
let nodeData = {};
|
||||||
let packetCount = 0;
|
let packetCount = 0;
|
||||||
@@ -658,6 +658,7 @@
|
|||||||
<span id="audioDesc" class="sr-only">Sonify packets — turn raw bytes into generative music</span>
|
<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>
|
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> ⭐ Favorites</label>
|
||||||
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
|
<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>
|
||||||
<div class="audio-controls hidden" id="audioControls">
|
<div class="audio-controls hidden" id="audioControls">
|
||||||
<label class="audio-slider-label">Voice <select id="audioVoiceSelect" class="audio-voice-select"></select></label>
|
<label class="audio-slider-label">Voice <select id="audioVoiceSelect" class="audio-voice-select"></select></label>
|
||||||
@@ -801,6 +802,50 @@
|
|||||||
applyFavoritesFilter();
|
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');
|
const matrixToggle = document.getElementById('liveMatrixToggle');
|
||||||
matrixToggle.checked = matrixMode;
|
matrixToggle.checked = matrixMode;
|
||||||
matrixToggle.addEventListener('change', (e) => {
|
matrixToggle.addEventListener('change', (e) => {
|
||||||
@@ -2431,7 +2476,7 @@
|
|||||||
}
|
}
|
||||||
_navCleanup = null;
|
_navCleanup = null;
|
||||||
}
|
}
|
||||||
nodesLayer = pathsLayer = animLayer = heatLayer = null;
|
nodesLayer = pathsLayer = animLayer = heatLayer = geoFilterLayer = null;
|
||||||
stopMatrixRain();
|
stopMatrixRain();
|
||||||
nodeMarkers = {}; nodeData = {};
|
nodeMarkers = {}; nodeData = {};
|
||||||
recentPaths = [];
|
recentPaths = [];
|
||||||
|
|||||||
@@ -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 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 wsHandler = null;
|
||||||
let heatLayer = null;
|
let heatLayer = null;
|
||||||
|
let geoFilterLayer = null;
|
||||||
let userHasMoved = false;
|
let userHasMoved = false;
|
||||||
let controlsCollapsed = false;
|
let controlsCollapsed = false;
|
||||||
|
|
||||||
@@ -94,6 +95,7 @@
|
|||||||
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
|
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
|
||||||
<label for="mcHeatmap"><input type="checkbox" id="mcHeatmap"> Heat map</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 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>
|
||||||
<fieldset class="mc-section">
|
<fieldset class="mc-section">
|
||||||
<legend class="mc-label">Status</legend>
|
<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
|
// WS for live advert updates
|
||||||
wsHandler = debouncedOnWS(function (msgs) {
|
wsHandler = debouncedOnWS(function (msgs) {
|
||||||
if (msgs.some(function (m) { return m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'ADVERT'; })) {
|
if (msgs.some(function (m) { return m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'ADVERT'; })) {
|
||||||
|
|||||||
@@ -561,6 +561,7 @@
|
|||||||
<table class="data-table" id="pktTable">
|
<table class="data-table" id="pktTable">
|
||||||
<thead><tr>
|
<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"></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>
|
<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>
|
</tr></thead>
|
||||||
<tbody id="pktBody"></tbody>
|
<tbody id="pktBody"></tbody>
|
||||||
@@ -1020,6 +1021,7 @@
|
|||||||
const groupTypeName = payloadTypeName(p.payload_type);
|
const groupTypeName = payloadTypeName(p.payload_type);
|
||||||
const groupTypeClass = payloadTypeColor(p.payload_type);
|
const groupTypeClass = payloadTypeColor(p.payload_type);
|
||||||
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
|
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;
|
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">
|
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>
|
<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="col-time">${timeAgo(p.latest)}</td>
|
||||||
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
|
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
|
||||||
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</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-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-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>
|
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
|
||||||
@@ -1045,6 +1048,7 @@
|
|||||||
const typeName = payloadTypeName(c.payload_type);
|
const typeName = payloadTypeName(c.payload_type);
|
||||||
const typeClass = payloadTypeColor(c.payload_type);
|
const typeClass = payloadTypeColor(c.payload_type);
|
||||||
const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0;
|
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 || '') : '';
|
const childRegion = c.observer_id ? (observers.find(o => o.id === c.observer_id)?.iata || '') : '';
|
||||||
let childPath = [];
|
let childPath = [];
|
||||||
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
|
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
|
||||||
@@ -1054,6 +1058,7 @@
|
|||||||
<td class="col-time">${timeAgo(c.timestamp)}</td>
|
<td class="col-time">${timeAgo(c.timestamp)}</td>
|
||||||
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
|
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
|
||||||
<td class="col-size">${size}B</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-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
|
||||||
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
|
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
|
||||||
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
|
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
|
||||||
@@ -1076,6 +1081,7 @@
|
|||||||
const typeName = payloadTypeName(p.payload_type);
|
const typeName = payloadTypeName(p.payload_type);
|
||||||
const typeClass = payloadTypeColor(p.payload_type);
|
const typeClass = payloadTypeColor(p.payload_type);
|
||||||
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
|
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);
|
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' : ''}">
|
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="col-time">${timeAgo(p.timestamp)}</td>
|
||||||
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
|
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
|
||||||
<td class="col-size">${size}B</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-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
|
||||||
<td class="col-observer">${truncate(obsName(p.observer_id), 16)}</td>
|
<td class="col-observer">${truncate(obsName(p.observer_id), 16)}</td>
|
||||||
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
|
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
--nav-text: #ffffff;
|
--nav-text: #ffffff;
|
||||||
--nav-text-muted: #cbd5e1;
|
--nav-text-muted: #cbd5e1;
|
||||||
--accent: #4a9eff;
|
--accent: #4a9eff;
|
||||||
|
--geo-filter-color: #3b82f6;
|
||||||
--status-green: #22c55e;
|
--status-green: #22c55e;
|
||||||
--status-yellow: #eab308;
|
--status-yellow: #eab308;
|
||||||
--status-red: #ef4444;
|
--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 */
|
/* Hide low-value columns on mobile */
|
||||||
@media (max-width: 640px) {
|
@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 */
|
/* 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-observer .col-observer,
|
||||||
.hide-col-path .col-path,
|
.hide-col-path .col-path,
|
||||||
.hide-col-rpt .col-rpt,
|
.hide-col-rpt .col-rpt,
|
||||||
|
.hide-col-hashsize .col-hashsize,
|
||||||
.hide-col-details .col-details { display: none; }
|
.hide-col-details .col-details { display: none; }
|
||||||
|
|
||||||
/* === Home page fixes === */
|
/* === Home page fixes === */
|
||||||
|
|||||||
168
scripts/prune-nodes-outside-geo-filter.py
Normal file
168
scripts/prune-nodes-outside-geo-filter.py
Normal 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()
|
||||||
153
tools/geofilter-builder.html
Normal file
153
tools/geofilter-builder.html
Normal 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>
|
||||||
Reference in New Issue
Block a user