mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-31 05:15:39 +00:00
Compare commits
14 Commits
fix/byop-d
...
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
|
||||
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 .
|
||||
|
||||
@@ -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"`
|
||||
|
||||
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 (
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
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 (
|
||||
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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
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 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 = [];
|
||||
|
||||
@@ -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'; })) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 === */
|
||||
|
||||
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