mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-01 23:44:15 +00:00
317b59ab10
## Summary - Adds configurable GPS polygon areas to `config.json`; nodes are attributed to an area if their last-known position falls inside the polygon - New `Area: …` dropdown filter (matching the existing region filter style) appears on all analytics, nodes, packets, map, and live screens when areas are configured - Backend resolves area membership with a 30s TTL cache; area filter bypasses the 500-node cap on `/api/bulk-health` so all area nodes are always returned - Includes a polygon builder tool (`/area-map.html`) for drawing and exporting area boundaries ## Changes **Backend** - `AreaEntry` type + `Areas` config field - `GetNodePubkeysInArea` DB query + `resolveAreaNodes` (30s TTL, `areaNodeMu` RWMutex) - `PacketQuery.Area` + `filterPackets` polygon check - `?area=` param propagated through all analytics, topology, clock-health, and bulk-health routes - `/api/config/areas` endpoint **Frontend** - `area-filter.js`: single-select dropdown, persists to localStorage, cleans up stale keys on load - Wired into analytics, nodes, packets, channels, map, and live pages - Live map clears node markers on area change **Docs & tools** - `docs/user-guide/area-filter.md` — configuration and usage guide - `docs/api-spec.md` — updated with new endpoint and `?area=` param table - `tools/area-map.html` — polygon builder for defining area boundaries - Demo areas added to `config.example.json` ## Test plan - [x] No areas configured → filter dropdown does not appear on any page - [x] Areas configured → dropdown appears, "All" selected by default - [x] Selecting an area filters nodes/packets/topology/map correctly - [x] Selecting "All" restores unfiltered view - [x] Selection persists across page reloads (localStorage) - [x] Stale localStorage key (area removed from config) is cleared on load - [x] `/api/bulk-health?area=X` returns all nodes in area (no 500-node cap) - [x] `/api/config/areas` returns correct list 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Kpa-clawbot <kpaclawbot@outlook.com> Co-authored-by: openclaw-bot <bot@openclaw.local>
175 lines
5.4 KiB
Go
175 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"runtime"
|
|
"sort"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func numGoroutinesForTest() int { return runtime.NumGoroutine() }
|
|
|
|
// TestAnalyticsRecomputerSteadyStateLatency asserts that issue #1240's
|
|
// steady-state background recompute is in place: reads of the common
|
|
// analytics endpoints (region="") return from cache in <50ms p99 even
|
|
// under simulated ingest load.
|
|
//
|
|
// On master (pre-fix), GetAnalyticsTopology holds s.mu.RLock for the
|
|
// entire compute. Concurrent ingest writers (s.mu.Lock) starve readers
|
|
// or vice versa, producing per-read latencies in the hundreds of
|
|
// milliseconds. The cache TTL doesn't help: after every expiry one
|
|
// reader still pays the full compute cost.
|
|
//
|
|
// Post-fix, GetAnalyticsTopology with region="" and zero window must
|
|
// Load() from the background-refreshed atomic snapshot — never blocking
|
|
// under writer contention.
|
|
func TestAnalyticsRecomputerSteadyStateLatency(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping latency timing test in -short mode")
|
|
}
|
|
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// Populate with enough records to make on-request compute non-trivial.
|
|
const N = 20000
|
|
hops := make([]distHopRecord, N)
|
|
for i := 0; i < N; i++ {
|
|
hops[i] = distHopRecord{
|
|
FromName: "A", FromPk: "aa",
|
|
ToName: "B", ToPk: "bb",
|
|
Dist: float64(i%500) + 0.5,
|
|
Type: []string{"R↔R", "C↔R", "C↔C"}[i%3],
|
|
Hash: "h",
|
|
Timestamp: "2024-01-01T00:00:00Z",
|
|
HourBucket: "2024-01-01-00",
|
|
}
|
|
}
|
|
store.mu.Lock()
|
|
store.distHops = hops
|
|
store.mu.Unlock()
|
|
|
|
// Start the recomputer infrastructure. On master this method
|
|
// doesn't exist, so this test won't compile until the GREEN commit
|
|
// lands; the RED commit lands the test + a stub. Stub returns
|
|
// without wiring background recompute, so the test still fails on
|
|
// the latency assertion below.
|
|
stop := store.StartAnalyticsRecomputers(10 * time.Millisecond)
|
|
defer stop()
|
|
|
|
// Give the initial compute a moment to populate.
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Simulated writer: contend for s.mu.Lock. This is what makes the
|
|
// non-recomputer path miss the latency target — the old
|
|
// GetAnalyticsTopology grabs s.mu.RLock for the entire compute and
|
|
// blocks behind every writer cycle.
|
|
var stopWriters atomic.Bool
|
|
var writerWg sync.WaitGroup
|
|
const Writers = 4
|
|
writerWg.Add(Writers)
|
|
for w := 0; w < Writers; w++ {
|
|
go func() {
|
|
defer writerWg.Done()
|
|
for !stopWriters.Load() {
|
|
store.mu.Lock()
|
|
// Trivial mutation: extend distHops by one and shrink back.
|
|
store.distHops = append(store.distHops, distHopRecord{
|
|
Dist: 1, Hash: "x", Timestamp: "2024-01-01T00:00:00Z",
|
|
})
|
|
store.distHops = store.distHops[:len(store.distHops)-1]
|
|
store.mu.Unlock()
|
|
// Brief pause to keep the lock-cycle rate realistic.
|
|
time.Sleep(100 * time.Microsecond)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// 100 concurrent reads.
|
|
const Readers = 100
|
|
latencies := make([]time.Duration, Readers)
|
|
var rwg sync.WaitGroup
|
|
rwg.Add(Readers)
|
|
for i := 0; i < Readers; i++ {
|
|
i := i
|
|
go func() {
|
|
defer rwg.Done()
|
|
t0 := time.Now()
|
|
r := store.GetAnalyticsDistance("", "")
|
|
latencies[i] = time.Since(t0)
|
|
if r == nil {
|
|
t.Errorf("reader %d got nil result", i)
|
|
}
|
|
}()
|
|
}
|
|
rwg.Wait()
|
|
stopWriters.Store(true)
|
|
writerWg.Wait()
|
|
|
|
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
|
|
p50 := latencies[Readers/2]
|
|
p99 := latencies[(Readers*99)/100]
|
|
|
|
t.Logf("analytics distance read latency: p50=%v p99=%v max=%v",
|
|
p50, p99, latencies[Readers-1])
|
|
|
|
// p99 budget: 50ms. Atomic-pointer load + JSON-shape map return
|
|
// should be sub-millisecond; 50ms leaves margin for goroutine
|
|
// scheduling jitter under concurrent test runs.
|
|
const budget = 50 * time.Millisecond
|
|
if p99 > budget {
|
|
t.Fatalf("p99 read latency %v exceeds %v budget (issue #1240 not in effect)", p99, budget)
|
|
}
|
|
}
|
|
|
|
// TestAnalyticsRecomputerShutdownNoLeak asserts the background
|
|
// goroutines started by StartAnalyticsRecomputers exit cleanly when
|
|
// the returned stop function is called — no leak across server
|
|
// shutdown (issue #1240 acceptance criterion).
|
|
func TestAnalyticsRecomputerShutdownNoLeak(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// Use a tight tick so we know recompute is actually running (not
|
|
// just blocked on the ticker).
|
|
stop := store.StartAnalyticsRecomputers(20 * time.Millisecond)
|
|
|
|
// Snapshot active goroutines a beat after start.
|
|
time.Sleep(80 * time.Millisecond)
|
|
startGoroutines := runtimeNumGoroutine()
|
|
|
|
stop()
|
|
|
|
// After stop returns, give the scheduler a beat to reap exits.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
var endGoroutines int
|
|
for time.Now().Before(deadline) {
|
|
endGoroutines = runtimeNumGoroutine()
|
|
if endGoroutines <= startGoroutines-5 { // we started 6 recomputers
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
|
|
// We expect ~6 fewer goroutines than the snapshot taken DURING
|
|
// recompute (one per registered recomputer). Allow some slack
|
|
// since test runners can have flaky goroutine counts.
|
|
if endGoroutines >= startGoroutines {
|
|
t.Fatalf("goroutine leak after stop: %d → %d (expected fewer)",
|
|
startGoroutines, endGoroutines)
|
|
}
|
|
t.Logf("goroutines: during=%d after=%d (Δ=%d)",
|
|
startGoroutines, endGoroutines, startGoroutines-endGoroutines)
|
|
}
|
|
|
|
// runtimeNumGoroutine is wrapped to keep the imports section of the
|
|
// production file minimal.
|
|
func runtimeNumGoroutine() int {
|
|
// imported below
|
|
return numGoroutinesForTest()
|
|
}
|