mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-13 23:23:07 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0be8b897bc |
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"83 passed","color":"brightgreen"}
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"82 passed","color":"brightgreen"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"37.74%","color":"red"}
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"37.26%","color":"red"}
|
||||
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
name: "🚀 Deploy Staging"
|
||||
if: github.event_name == 'push'
|
||||
if: false # disabled: staging VM offline, manual deploy required
|
||||
needs: [build-and-publish]
|
||||
runs-on: [self-hosted, meshcore-runner-2]
|
||||
steps:
|
||||
@@ -448,7 +448,7 @@ jobs:
|
||||
publish:
|
||||
name: "📝 Publish Badges & Summary"
|
||||
if: github.event_name == 'push'
|
||||
needs: [deploy]
|
||||
needs: [build-and-publish]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -41,7 +41,6 @@ type Config struct {
|
||||
Metrics *MetricsConfig `json:"metrics,omitempty"`
|
||||
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
|
||||
ValidateSignatures *bool `json:"validateSignatures,omitempty"`
|
||||
DB *DBConfig `json:"db,omitempty"`
|
||||
}
|
||||
|
||||
// GeoFilterConfig is an alias for the shared geofilter.Config type.
|
||||
@@ -59,20 +58,6 @@ type MetricsConfig struct {
|
||||
SampleIntervalSec int `json:"sampleIntervalSec"`
|
||||
}
|
||||
|
||||
// DBConfig controls SQLite vacuum and maintenance behavior (#919).
|
||||
type DBConfig struct {
|
||||
VacuumOnStartup bool `json:"vacuumOnStartup"` // one-time full VACUUM on startup if auto_vacuum is not INCREMENTAL
|
||||
IncrementalVacuumPages int `json:"incrementalVacuumPages"` // pages returned to OS per reaper cycle (default 1024)
|
||||
}
|
||||
|
||||
// IncrementalVacuumPages returns the configured pages per vacuum or 1024 default.
|
||||
func (c *Config) IncrementalVacuumPages() int {
|
||||
if c.DB != nil && c.DB.IncrementalVacuumPages > 0 {
|
||||
return c.DB.IncrementalVacuumPages
|
||||
}
|
||||
return 1024
|
||||
}
|
||||
|
||||
// ShouldValidateSignatures returns true (default) unless explicitly disabled.
|
||||
func (c *Config) ShouldValidateSignatures() bool {
|
||||
if c.ValidateSignatures != nil {
|
||||
|
||||
+1
-56
@@ -59,7 +59,7 @@ func OpenStoreWithInterval(dbPath string, sampleIntervalSec int) (*Store, error)
|
||||
return nil, fmt.Errorf("creating data dir: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath+"?_pragma=auto_vacuum(INCREMENTAL)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)")
|
||||
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening db: %w", err)
|
||||
}
|
||||
@@ -85,9 +85,6 @@ func OpenStoreWithInterval(dbPath string, sampleIntervalSec int) (*Store, error)
|
||||
}
|
||||
|
||||
func applySchema(db *sql.DB) error {
|
||||
// auto_vacuum=INCREMENTAL is set via DSN pragma (must be before journal_mode).
|
||||
// Logging of current mode is handled by CheckAutoVacuum — no duplicate log here.
|
||||
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
@@ -791,58 +788,6 @@ func (s *Store) PruneOldMetrics(retentionDays int) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CheckAutoVacuum inspects the current auto_vacuum mode and logs a warning
|
||||
// if not INCREMENTAL. Performs opt-in full VACUUM if db.vacuumOnStartup is set (#919).
|
||||
func (s *Store) CheckAutoVacuum(cfg *Config) {
|
||||
var autoVacuum int
|
||||
if err := s.db.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum); err != nil {
|
||||
log.Printf("[db] warning: could not read auto_vacuum: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if autoVacuum == 2 {
|
||||
log.Printf("[db] auto_vacuum=INCREMENTAL")
|
||||
return
|
||||
}
|
||||
|
||||
modes := map[int]string{0: "NONE", 1: "FULL", 2: "INCREMENTAL"}
|
||||
mode := modes[autoVacuum]
|
||||
if mode == "" {
|
||||
mode = fmt.Sprintf("UNKNOWN(%d)", autoVacuum)
|
||||
}
|
||||
|
||||
log.Printf("[db] auto_vacuum=%s — DB needs one-time VACUUM to enable incremental auto-vacuum. "+
|
||||
"Set db.vacuumOnStartup: true in config to migrate (will block startup for several minutes on large DBs). "+
|
||||
"See https://github.com/Kpa-clawbot/CoreScope/issues/919", mode)
|
||||
|
||||
if cfg.DB != nil && cfg.DB.VacuumOnStartup {
|
||||
// WARNING: Full VACUUM creates a temporary copy of the entire DB file.
|
||||
// Requires ~2× the DB file size in free disk space or it will fail.
|
||||
log.Printf("[db] vacuumOnStartup=true — starting one-time full VACUUM (ensure 2x DB size free disk space)...")
|
||||
start := time.Now()
|
||||
|
||||
if _, err := s.db.Exec("PRAGMA auto_vacuum = INCREMENTAL"); err != nil {
|
||||
log.Printf("[db] VACUUM failed: could not set auto_vacuum: %v", err)
|
||||
return
|
||||
}
|
||||
if _, err := s.db.Exec("VACUUM"); err != nil {
|
||||
log.Printf("[db] VACUUM failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
log.Printf("[db] VACUUM complete in %v — auto_vacuum is now INCREMENTAL", elapsed.Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
|
||||
// RunIncrementalVacuum returns free pages to the OS (#919).
|
||||
// Safe to call on auto_vacuum=NONE databases (noop).
|
||||
func (s *Store) RunIncrementalVacuum(pages int) {
|
||||
if _, err := s.db.Exec(fmt.Sprintf("PRAGMA incremental_vacuum(%d)", pages)); err != nil {
|
||||
log.Printf("[vacuum] incremental_vacuum error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Checkpoint forces a WAL checkpoint to release the WAL lock file,
|
||||
// preventing lock contention with a new process starting up.
|
||||
func (s *Store) Checkpoint() {
|
||||
|
||||
@@ -57,9 +57,6 @@ func main() {
|
||||
defer store.Close()
|
||||
log.Printf("SQLite opened: %s", cfg.DBPath)
|
||||
|
||||
// Check auto_vacuum mode and optionally migrate (#919)
|
||||
store.CheckAutoVacuum(cfg)
|
||||
|
||||
// Node retention: move stale nodes to inactive_nodes on startup
|
||||
nodeDays := cfg.NodeDaysOrDefault()
|
||||
store.MoveStaleNodes(nodeDays)
|
||||
@@ -72,15 +69,12 @@ func main() {
|
||||
metricsDays := cfg.MetricsRetentionDays()
|
||||
store.PruneOldMetrics(metricsDays)
|
||||
store.PruneDroppedPackets(metricsDays)
|
||||
vacuumPages := cfg.IncrementalVacuumPages()
|
||||
store.RunIncrementalVacuum(vacuumPages)
|
||||
|
||||
// Daily ticker for node retention
|
||||
retentionTicker := time.NewTicker(1 * time.Hour)
|
||||
go func() {
|
||||
for range retentionTicker.C {
|
||||
store.MoveStaleNodes(nodeDays)
|
||||
store.RunIncrementalVacuum(vacuumPages)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -89,10 +83,8 @@ func main() {
|
||||
go func() {
|
||||
time.Sleep(90 * time.Second) // stagger after metrics prune
|
||||
store.RemoveStaleObservers(observerDays)
|
||||
store.RunIncrementalVacuum(vacuumPages)
|
||||
for range observerRetentionTicker.C {
|
||||
store.RemoveStaleObservers(observerDays)
|
||||
store.RunIncrementalVacuum(vacuumPages)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -102,7 +94,6 @@ func main() {
|
||||
for range metricsRetentionTicker.C {
|
||||
store.PruneOldMetrics(metricsDays)
|
||||
store.PruneDroppedPackets(metricsDays)
|
||||
store.RunIncrementalVacuum(vacuumPages)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -127,92 +127,6 @@ func TestBoundedLoad_AscendingOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// loadStoreWithRetention creates a PacketStore with retentionHours set.
|
||||
func loadStoreWithRetention(t *testing.T, dbPath string, retentionHours float64) *PacketStore {
|
||||
t.Helper()
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &PacketStoreConfig{RetentionHours: retentionHours}
|
||||
store := NewPacketStore(db, cfg)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// createTestDBWithAgedPackets inserts numRecent packets with timestamps within
|
||||
// the last hour and numOld packets with timestamps 48 hours ago.
|
||||
func createTestDBWithAgedPackets(t *testing.T, numRecent, numOld int) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
execOrFail := func(s string) {
|
||||
if _, err := conn.Exec(s); err != nil {
|
||||
t.Fatalf("setup: %v\nSQL: %s", err, s)
|
||||
}
|
||||
}
|
||||
execOrFail(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, raw_hex TEXT, hash TEXT, first_seen TEXT, route_type INTEGER, payload_type INTEGER, payload_version INTEGER, decoded_json TEXT)`)
|
||||
execOrFail(`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT, direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT)`)
|
||||
execOrFail(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
|
||||
execOrFail(`CREATE TABLE nodes (pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, frequency REAL)`)
|
||||
execOrFail(`CREATE TABLE schema_version (version INTEGER)`)
|
||||
execOrFail(`INSERT INTO schema_version (version) VALUES (1)`)
|
||||
execOrFail(`CREATE INDEX idx_tx_first_seen ON transmissions(first_seen)`)
|
||||
|
||||
now := time.Now().UTC()
|
||||
id := 1
|
||||
// Insert old packets (48 hours ago)
|
||||
for i := 0; i < numOld; i++ {
|
||||
ts := now.Add(-48 * time.Hour).Add(time.Duration(i) * time.Second).Format(time.RFC3339)
|
||||
conn.Exec("INSERT INTO transmissions VALUES (?,?,?,?,0,4,1,?)", id, "aa", fmt.Sprintf("old%d", i), ts, `{}`)
|
||||
conn.Exec("INSERT INTO observations VALUES (?,?,?,?,?,?,?,?,?,?,?)", id, id, "obs1", "Obs1", "RX", -10.0, -80.0, 5, `[]`, ts, "")
|
||||
id++
|
||||
}
|
||||
// Insert recent packets (within last hour)
|
||||
for i := 0; i < numRecent; i++ {
|
||||
ts := now.Add(-30 * time.Minute).Add(time.Duration(i) * time.Second).Format(time.RFC3339)
|
||||
conn.Exec("INSERT INTO transmissions VALUES (?,?,?,?,0,4,1,?)", id, "bb", fmt.Sprintf("new%d", i), ts, `{}`)
|
||||
conn.Exec("INSERT INTO observations VALUES (?,?,?,?,?,?,?,?,?,?,?)", id, id, "obs1", "Obs1", "RX", -10.0, -80.0, 5, `[]`, ts, "")
|
||||
id++
|
||||
}
|
||||
return dbPath
|
||||
}
|
||||
|
||||
func TestRetentionLoad_OnlyLoadsRecentPackets(t *testing.T) {
|
||||
dbPath := createTestDBWithAgedPackets(t, 50, 100)
|
||||
defer os.RemoveAll(filepath.Dir(dbPath))
|
||||
|
||||
// retention = 2 hours — should load only the 50 recent packets, not the 100 old ones
|
||||
store := loadStoreWithRetention(t, dbPath, 2)
|
||||
defer store.db.conn.Close()
|
||||
|
||||
if len(store.packets) != 50 {
|
||||
t.Errorf("expected 50 recent packets, got %d (old packets should be excluded by retentionHours)", len(store.packets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetentionLoad_ZeroRetentionLoadsAll(t *testing.T) {
|
||||
dbPath := createTestDBWithAgedPackets(t, 50, 100)
|
||||
defer os.RemoveAll(filepath.Dir(dbPath))
|
||||
|
||||
// retention = 0 (unlimited) — should load all 150 packets
|
||||
store := loadStoreWithRetention(t, dbPath, 0)
|
||||
defer store.db.conn.Close()
|
||||
|
||||
if len(store.packets) != 150 {
|
||||
t.Errorf("expected all 150 packets with retentionHours=0, got %d", len(store.packets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateStoreTxBytesTypical(t *testing.T) {
|
||||
est := estimateStoreTxBytesTypical(10)
|
||||
if est < 1000 {
|
||||
|
||||
+255
-165
@@ -12,28 +12,20 @@ import (
|
||||
type SkewSeverity string
|
||||
|
||||
const (
|
||||
SkewDefault SkewSeverity = "default" // firmware-default epoch + uptime
|
||||
SkewOK SkewSeverity = "ok" // |skew| <= 15s
|
||||
SkewDegrading SkewSeverity = "degrading" // 15s < |skew| <= 60s
|
||||
SkewDegraded SkewSeverity = "degraded" // 60s < |skew| <= 600s
|
||||
SkewWrong SkewSeverity = "wrong" // |skew| > 600s and not default
|
||||
SkewOK SkewSeverity = "ok" // < 5 min
|
||||
SkewWarning SkewSeverity = "warning" // 5 min – 1 hour
|
||||
SkewCritical SkewSeverity = "critical" // 1 hour – 30 days
|
||||
SkewAbsurd SkewSeverity = "absurd" // > 30 days
|
||||
SkewNoClock SkewSeverity = "no_clock" // > 365 days — uninitialized RTC
|
||||
SkewBimodalClock SkewSeverity = "bimodal_clock" // mixed good+bad recent samples (flaky RTC)
|
||||
)
|
||||
|
||||
// Known firmware default epochs. Nodes with advert_ts in
|
||||
// [epoch, epoch + maxPlausibleUptimeSec] are classified as "default".
|
||||
// See docs/clock-skew-redesign.md for provenance of each value.
|
||||
var defaultEpochs = []int64{0, 1609459200, 1672531200, 1715770351}
|
||||
|
||||
// Default thresholds in seconds.
|
||||
const (
|
||||
// maxPlausibleUptimeSec caps how far past a default epoch we still
|
||||
// consider "default + uptime ticking". 730 days ≈ 2 years.
|
||||
maxPlausibleUptimeSec = 1095 * 86400 // 3 years — covers solar repeater deployment lifetimes at firmware default
|
||||
|
||||
// Severity band boundaries (absolute skew in seconds).
|
||||
skewThresholdOKSec = 15
|
||||
skewThresholdDegradingSec = 60
|
||||
skewThresholdDegradedSec = 600
|
||||
skewThresholdWarnSec = 5 * 60 // 5 minutes
|
||||
skewThresholdCriticalSec = 60 * 60 // 1 hour
|
||||
skewThresholdAbsurdSec = 30 * 24 * 3600 // 30 days
|
||||
skewThresholdNoClockSec = 365 * 24 * 3600 // 365 days — uninitialized RTC
|
||||
|
||||
// minDriftSamples is the minimum number of advert transmissions needed
|
||||
// to compute a meaningful linear drift rate.
|
||||
@@ -43,52 +35,54 @@ const (
|
||||
// drift rates (> 1 day/day) indicate insufficient or outlier samples.
|
||||
maxReasonableDriftPerDay = 86400.0
|
||||
|
||||
// recentSkewWindowCount is the number of most-recent advert samples
|
||||
// used to derive the "current" skew for severity classification (see
|
||||
// issue #789). The all-time median is poisoned by historical bad
|
||||
// samples (e.g. a node that was off and then GPS-corrected); severity
|
||||
// must reflect current health, not lifetime statistics.
|
||||
recentSkewWindowCount = 5
|
||||
|
||||
// recentSkewWindowSec bounds the recent-window in time as well: only
|
||||
// samples from the last N seconds count as "recent" for severity.
|
||||
// The effective window is min(recentSkewWindowCount, samples in 1h).
|
||||
recentSkewWindowSec = 3600
|
||||
|
||||
// bimodalSkewThresholdSec is the absolute skew threshold (1 hour)
|
||||
// above which a sample is considered "bad" — likely firmware emitting
|
||||
// a nonsense timestamp from an uninitialized RTC, not real drift.
|
||||
// Chosen to match the warning/critical severity boundary: real clock
|
||||
// drift rarely exceeds 1 hour, while epoch-0 RTCs produce ~1.7B sec.
|
||||
bimodalSkewThresholdSec = 3600.0
|
||||
|
||||
// maxPlausibleSkewJumpSec is the largest skew change between
|
||||
// consecutive samples that we treat as physical drift.
|
||||
// consecutive samples that we treat as physical drift. Anything larger
|
||||
// (e.g. a GPS sync that jumps the clock by minutes/days) is rejected
|
||||
// as an outlier when computing drift. Real microcontroller drift is
|
||||
// fractions of a second per advert; 60s is a generous safety factor.
|
||||
maxPlausibleSkewJumpSec = 60.0
|
||||
|
||||
// theilSenMaxPoints caps the number of points fed to Theil-Sen
|
||||
// regression (O(n²) in pairs).
|
||||
// regression (O(n²) in pairs). For nodes with thousands of samples we
|
||||
// keep the most-recent points, which are also the most relevant for
|
||||
// current drift.
|
||||
theilSenMaxPoints = 200
|
||||
)
|
||||
|
||||
// isDefaultEpoch returns true if the raw advert timestamp falls within
|
||||
// [epoch, epoch + maxPlausibleUptimeSec] for any known firmware default.
|
||||
// If matched, returns the matched epoch; otherwise returns 0.
|
||||
func isDefaultEpoch(advertTS int64) (bool, int64) {
|
||||
// Find the largest epoch <= advertTS (closest match). Since ranges
|
||||
// overlap, picking the closest avoids attributing a 2023-firmware
|
||||
// node's timestamp to the 2024 epoch.
|
||||
bestEpoch := int64(-1)
|
||||
for _, epoch := range defaultEpochs {
|
||||
if epoch <= advertTS && epoch > bestEpoch {
|
||||
bestEpoch = epoch
|
||||
}
|
||||
}
|
||||
if bestEpoch >= 0 && advertTS <= bestEpoch+maxPlausibleUptimeSec {
|
||||
return true, bestEpoch
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// classifySkew maps a raw advert timestamp and corrected skew (signed)
|
||||
// to a severity level. Takes math.Abs internally so callers may pass
|
||||
// signed values. Default detection runs on the raw advert_ts
|
||||
// (independent of observer calibration).
|
||||
func classifySkew(advertTS int64, skewSec float64) (SkewSeverity, int64) {
|
||||
if ok, epoch := isDefaultEpoch(advertTS); ok {
|
||||
return SkewDefault, epoch
|
||||
}
|
||||
abs := math.Abs(skewSec)
|
||||
// classifySkew maps absolute skew (seconds) to a severity level.
|
||||
// Float64 comparison is safe: inputs are rounded to 1 decimal via round(),
|
||||
// and thresholds are integer multiples of 60 — no rounding artifacts.
|
||||
func classifySkew(absSkewSec float64) SkewSeverity {
|
||||
switch {
|
||||
case abs <= skewThresholdOKSec:
|
||||
return SkewOK, 0
|
||||
case abs <= skewThresholdDegradingSec:
|
||||
return SkewDegrading, 0
|
||||
case abs <= skewThresholdDegradedSec:
|
||||
return SkewDegraded, 0
|
||||
case absSkewSec >= skewThresholdNoClockSec:
|
||||
return SkewNoClock
|
||||
case absSkewSec >= skewThresholdAbsurdSec:
|
||||
return SkewAbsurd
|
||||
case absSkewSec >= skewThresholdCriticalSec:
|
||||
return SkewCritical
|
||||
case absSkewSec >= skewThresholdWarnSec:
|
||||
return SkewWarning
|
||||
default:
|
||||
return SkewWrong, 0
|
||||
return SkewOK
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,35 +90,38 @@ func classifySkew(advertTS int64, skewSec float64) (SkewSeverity, int64) {
|
||||
|
||||
// skewSample is a single raw skew measurement from one advert observation.
|
||||
type skewSample struct {
|
||||
advertTS int64 // node's advert Unix timestamp
|
||||
observedTS int64 // observation Unix timestamp
|
||||
observerID string // which observer saw this
|
||||
hash string // transmission hash (for multi-observer grouping)
|
||||
advertTS int64 // node's advert Unix timestamp
|
||||
observedTS int64 // observation Unix timestamp
|
||||
observerID string // which observer saw this
|
||||
hash string // transmission hash (for multi-observer grouping)
|
||||
}
|
||||
|
||||
// ObserverCalibration holds the computed clock offset for an observer.
|
||||
type ObserverCalibration struct {
|
||||
ObserverID string `json:"observerID"`
|
||||
OffsetSec float64 `json:"offsetSec"` // positive = observer clock ahead
|
||||
Samples int `json:"samples"` // number of multi-observer packets used
|
||||
OffsetSec float64 `json:"offsetSec"` // positive = observer clock ahead
|
||||
Samples int `json:"samples"` // number of multi-observer packets used
|
||||
}
|
||||
|
||||
// NodeClockSkew is the API response for a single node's clock skew data.
|
||||
type NodeClockSkew struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
MeanSkewSec float64 `json:"meanSkewSec"` // corrected mean skew (positive = node ahead)
|
||||
MedianSkewSec float64 `json:"medianSkewSec"` // corrected median skew
|
||||
LastSkewSec float64 `json:"lastSkewSec"` // most recent corrected skew
|
||||
DriftPerDaySec float64 `json:"driftPerDaySec"` // linear drift rate (sec/day)
|
||||
Severity SkewSeverity `json:"severity"`
|
||||
SampleCount int `json:"sampleCount"`
|
||||
Calibrated bool `json:"calibrated"` // true if observer calibration was applied
|
||||
LastAdvertTS int64 `json:"lastAdvertTS"` // most recent advert timestamp
|
||||
LastObservedTS int64 `json:"lastObservedTS"` // most recent observation timestamp
|
||||
DefaultEpoch *int64 `json:"defaultEpoch,omitempty"` // matched epoch when severity=default
|
||||
Samples []SkewSample `json:"samples,omitempty"` // time-series for sparklines
|
||||
NodeName string `json:"nodeName,omitempty"` // populated in fleet responses
|
||||
NodeRole string `json:"nodeRole,omitempty"` // populated in fleet responses
|
||||
Pubkey string `json:"pubkey"`
|
||||
MeanSkewSec float64 `json:"meanSkewSec"` // corrected mean skew (positive = node ahead)
|
||||
MedianSkewSec float64 `json:"medianSkewSec"` // corrected median skew
|
||||
LastSkewSec float64 `json:"lastSkewSec"` // most recent corrected skew
|
||||
RecentMedianSkewSec float64 `json:"recentMedianSkewSec"` // median across most-recent samples (drives severity, see #789)
|
||||
DriftPerDaySec float64 `json:"driftPerDaySec"` // linear drift rate (sec/day)
|
||||
Severity SkewSeverity `json:"severity"`
|
||||
SampleCount int `json:"sampleCount"`
|
||||
Calibrated bool `json:"calibrated"` // true if observer calibration was applied
|
||||
LastAdvertTS int64 `json:"lastAdvertTS"` // most recent advert timestamp
|
||||
LastObservedTS int64 `json:"lastObservedTS"` // most recent observation timestamp
|
||||
Samples []SkewSample `json:"samples,omitempty"` // time-series for sparklines
|
||||
GoodFraction float64 `json:"goodFraction"` // fraction of recent samples with |skew| <= 1h
|
||||
RecentBadSampleCount int `json:"recentBadSampleCount"` // count of recent samples with |skew| > 1h
|
||||
RecentSampleCount int `json:"recentSampleCount"` // total recent samples in window
|
||||
NodeName string `json:"nodeName,omitempty"` // populated in fleet responses
|
||||
NodeRole string `json:"nodeRole,omitempty"` // populated in fleet responses
|
||||
}
|
||||
|
||||
// SkewSample is a single (timestamp, skew) point for sparkline rendering.
|
||||
@@ -133,26 +130,28 @@ type SkewSample struct {
|
||||
SkewSec float64 `json:"skew"` // corrected skew in seconds
|
||||
}
|
||||
|
||||
// txSkewResult maps tx hash → per-transmission skew stats.
|
||||
// txSkewResult maps tx hash → per-transmission skew stats. This is an
|
||||
// intermediate result keyed by hash (not pubkey); the store maps hash → pubkey
|
||||
// when building the final per-node view.
|
||||
type txSkewResult = map[string]*NodeClockSkew
|
||||
|
||||
// ── Clock Skew Engine ──────────────────────────────────────────────────────────
|
||||
|
||||
// ClockSkewEngine computes and caches clock skew data for nodes and observers.
|
||||
type ClockSkewEngine struct {
|
||||
mu sync.RWMutex
|
||||
observerOffsets map[string]float64 // observerID → calibrated offset (seconds)
|
||||
observerSamples map[string]int // observerID → number of multi-observer packets used
|
||||
nodeSkew txSkewResult
|
||||
lastComputed time.Time
|
||||
computeInterval time.Duration
|
||||
mu sync.RWMutex
|
||||
observerOffsets map[string]float64 // observerID → calibrated offset (seconds)
|
||||
observerSamples map[string]int // observerID → number of multi-observer packets used
|
||||
nodeSkew txSkewResult
|
||||
lastComputed time.Time
|
||||
computeInterval time.Duration
|
||||
}
|
||||
|
||||
func NewClockSkewEngine() *ClockSkewEngine {
|
||||
return &ClockSkewEngine{
|
||||
observerOffsets: make(map[string]float64),
|
||||
observerOffsets: make(map[string]float64),
|
||||
observerSamples: make(map[string]int),
|
||||
nodeSkew: make(txSkewResult),
|
||||
nodeSkew: make(txSkewResult),
|
||||
computeInterval: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
@@ -189,6 +188,7 @@ func (e *ClockSkewEngine) Recompute(store *PacketStore) {
|
||||
|
||||
// Swap results under brief write lock.
|
||||
e.mu.Lock()
|
||||
// Re-check: another goroutine may have computed while we were working.
|
||||
if time.Since(e.lastComputed) < e.computeInterval {
|
||||
e.mu.Unlock()
|
||||
return
|
||||
@@ -214,13 +214,13 @@ func collectSamples(store *PacketStore) []skewSample {
|
||||
if decoded == nil {
|
||||
continue
|
||||
}
|
||||
// Extract advert timestamp from decoded JSON.
|
||||
advertTS := extractTimestamp(decoded)
|
||||
if advertTS < 0 {
|
||||
if advertTS <= 0 {
|
||||
continue
|
||||
}
|
||||
// Allow epoch 0 and above (needed for default-epoch detection).
|
||||
// Upper bound: year 2100.
|
||||
if advertTS > 4102444800 {
|
||||
// Sanity: skip timestamps before year 2020 or after year 2100.
|
||||
if advertTS < 1577836800 || advertTS > 4102444800 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -240,43 +240,21 @@ func collectSamples(store *PacketStore) []skewSample {
|
||||
return samples
|
||||
}
|
||||
|
||||
// timestampMissing is the sentinel returned by extractTimestamp when no
|
||||
// timestamp field is present in the decoded advert. Using -1 lets us
|
||||
// distinguish "field absent" from a real epoch-0 timestamp (ts == 0).
|
||||
const timestampMissing int64 = -1
|
||||
|
||||
// extractTimestamp gets the Unix timestamp from a decoded ADVERT payload.
|
||||
// Returns timestampMissing (-1) if no timestamp field is found.
|
||||
func extractTimestamp(decoded map[string]interface{}) int64 {
|
||||
// Try payload.timestamp first (nested in "payload" key).
|
||||
if payload, ok := decoded["payload"]; ok {
|
||||
if pm, ok := payload.(map[string]interface{}); ok {
|
||||
if ts, ok := jsonNumberOk(pm, "timestamp"); ok {
|
||||
if ts := jsonNumber(pm, "timestamp"); ts > 0 {
|
||||
return ts
|
||||
}
|
||||
}
|
||||
}
|
||||
if ts, ok := jsonNumberOk(decoded, "timestamp"); ok {
|
||||
// Fallback: top-level timestamp.
|
||||
if ts := jsonNumber(decoded, "timestamp"); ts > 0 {
|
||||
return ts
|
||||
}
|
||||
return timestampMissing
|
||||
}
|
||||
|
||||
// jsonNumberOk extracts an int64 from a JSON-parsed map, returning (value, true)
|
||||
// if the key exists and is numeric, or (0, false) otherwise.
|
||||
func jsonNumberOk(m map[string]interface{}, key string) (int64, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n), true
|
||||
case int64:
|
||||
return n, true
|
||||
case int:
|
||||
return int64(n), true
|
||||
}
|
||||
return 0, false
|
||||
return 0
|
||||
}
|
||||
|
||||
// jsonNumber extracts an int64 from a JSON-parsed map (handles float64 and json.Number).
|
||||
@@ -303,6 +281,7 @@ func parseISO(s string) int64 {
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
// Try with fractional seconds.
|
||||
t, err = time.Parse("2006-01-02T15:04:05.999999999Z07:00", s)
|
||||
if err != nil {
|
||||
return 0
|
||||
@@ -316,16 +295,19 @@ func parseISO(s string) int64 {
|
||||
// calibrateObservers computes each observer's clock offset using multi-observer
|
||||
// packets. Returns offset map and sample count map.
|
||||
func calibrateObservers(samples []skewSample) (map[string]float64, map[string]int) {
|
||||
// Group observations by packet hash.
|
||||
byHash := make(map[string][]skewSample)
|
||||
for _, s := range samples {
|
||||
byHash[s.hash] = append(byHash[s.hash], s)
|
||||
}
|
||||
|
||||
deviations := make(map[string][]float64)
|
||||
// For each multi-observer packet, compute per-observer deviation from median.
|
||||
deviations := make(map[string][]float64) // observerID → list of deviations
|
||||
for _, group := range byHash {
|
||||
if len(group) < 2 {
|
||||
continue
|
||||
continue // single-observer packet, can't calibrate
|
||||
}
|
||||
// Compute median observation timestamp for this packet.
|
||||
obsTimes := make([]float64, len(group))
|
||||
for i, s := range group {
|
||||
obsTimes[i] = float64(s.observedTS)
|
||||
@@ -337,6 +319,7 @@ func calibrateObservers(samples []skewSample) (map[string]float64, map[string]in
|
||||
}
|
||||
}
|
||||
|
||||
// Each observer's offset = median of its deviations.
|
||||
offsets := make(map[string]float64, len(deviations))
|
||||
counts := make(map[string]int, len(deviations))
|
||||
for obsID, devs := range deviations {
|
||||
@@ -350,6 +333,8 @@ func calibrateObservers(samples []skewSample) (map[string]float64, map[string]in
|
||||
|
||||
// computeNodeSkew calculates corrected skew statistics for each node.
|
||||
func computeNodeSkew(samples []skewSample, obsOffsets map[string]float64) txSkewResult {
|
||||
// Compute corrected skew per sample, grouped by hash (each hash = one
|
||||
// node's advert transmission). The caller maps hash → pubkey via byNode.
|
||||
type correctedSample struct {
|
||||
skew float64
|
||||
observedTS int64
|
||||
@@ -364,6 +349,8 @@ func computeNodeSkew(samples []skewSample, obsOffsets map[string]float64) txSkew
|
||||
rawSkew := float64(s.advertTS - s.observedTS)
|
||||
corrected := rawSkew
|
||||
if hasCal {
|
||||
// Observer offset = obs_ts - median(all_obs_ts). If observer is ahead,
|
||||
// its obs_ts is inflated, making raw_skew too low. Add offset to correct.
|
||||
corrected = rawSkew + obsOffset
|
||||
}
|
||||
byHash[s.hash] = append(byHash[s.hash], correctedSample{
|
||||
@@ -374,7 +361,10 @@ func computeNodeSkew(samples []skewSample, obsOffsets map[string]float64) txSkew
|
||||
hashAdvertTS[s.hash] = s.advertTS
|
||||
}
|
||||
|
||||
result := make(map[string]*NodeClockSkew)
|
||||
// Each hash represents one advert from one node. Compute median corrected
|
||||
// skew per hash (across multiple observers).
|
||||
|
||||
result := make(map[string]*NodeClockSkew) // keyed by hash for now
|
||||
for hash, cs := range byHash {
|
||||
skews := make([]float64, len(cs))
|
||||
for i, c := range cs {
|
||||
@@ -383,37 +373,29 @@ func computeNodeSkew(samples []skewSample, obsOffsets map[string]float64) txSkew
|
||||
medSkew := median(skews)
|
||||
meanSkew := mean(skews)
|
||||
|
||||
// Pick the skew from the most recent observation (max observedTS),
|
||||
// not the last-appended sample which may be non-chronological.
|
||||
var latest correctedSample
|
||||
// Find latest observation.
|
||||
var latestObsTS int64
|
||||
var anyCal bool
|
||||
for _, c := range cs {
|
||||
if c.observedTS > latest.observedTS {
|
||||
latest = c
|
||||
if c.observedTS > latestObsTS {
|
||||
latestObsTS = c.observedTS
|
||||
}
|
||||
if c.calibrated {
|
||||
anyCal = true
|
||||
}
|
||||
}
|
||||
lastCorrectedSkew := latest.skew
|
||||
advTS := hashAdvertTS[hash]
|
||||
severity, matchedEpoch := classifySkew(advTS, lastCorrectedSkew)
|
||||
|
||||
ncs := &NodeClockSkew{
|
||||
absMedian := math.Abs(medSkew)
|
||||
result[hash] = &NodeClockSkew{
|
||||
MeanSkewSec: round(meanSkew, 1),
|
||||
MedianSkewSec: round(medSkew, 1),
|
||||
LastSkewSec: round(lastCorrectedSkew, 1),
|
||||
Severity: severity,
|
||||
LastSkewSec: round(cs[len(cs)-1].skew, 1),
|
||||
Severity: classifySkew(absMedian),
|
||||
SampleCount: len(cs),
|
||||
Calibrated: anyCal,
|
||||
LastAdvertTS: advTS,
|
||||
LastObservedTS: latest.observedTS,
|
||||
LastAdvertTS: hashAdvertTS[hash],
|
||||
LastObservedTS: latestObsTS,
|
||||
}
|
||||
if severity == SkewDefault {
|
||||
ep := matchedEpoch
|
||||
ncs.DefaultEpoch = &ep
|
||||
}
|
||||
result[hash] = ncs
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -475,45 +457,124 @@ func (s *PacketStore) getNodeClockSkewLocked(pubkey string) *NodeClockSkew {
|
||||
medSkew := median(allSkews)
|
||||
meanSkew := mean(allSkews)
|
||||
|
||||
// Classify using the most recent advert's raw timestamp and
|
||||
// the most recent corrected skew. No windowing or median-driven
|
||||
// severity — per-advert classification per the spec.
|
||||
severity, matchedEpoch := classifySkew(lastAdvTS, lastSkew)
|
||||
// Severity is derived from RECENT samples only (issue #789). The
|
||||
// all-time median is poisoned by historical bad data — a node that
|
||||
// was off for hours and then GPS-corrected can have median = -59M sec
|
||||
// while its current skew is -0.8s. Operators need severity to reflect
|
||||
// current health, so they trust the dashboard.
|
||||
//
|
||||
// Sort tsSkews by time and take the last recentSkewWindowCount samples
|
||||
// (or all samples within recentSkewWindowSec of the latest, whichever
|
||||
// gives FEWER samples — we want the more-current view; a chatty node
|
||||
// can fit dozens of samples in 1h, in which case the count cap wins).
|
||||
sort.Slice(tsSkews, func(i, j int) bool { return tsSkews[i].ts < tsSkews[j].ts })
|
||||
|
||||
// Drift: display only, not a classifier input.
|
||||
recentSkew := lastSkew
|
||||
var recentVals []float64
|
||||
if n := len(tsSkews); n > 0 {
|
||||
latestTS := tsSkews[n-1].ts
|
||||
// Index-based window: last K samples.
|
||||
startByCount := n - recentSkewWindowCount
|
||||
if startByCount < 0 {
|
||||
startByCount = 0
|
||||
}
|
||||
// Time-based window: samples newer than latestTS - windowSec.
|
||||
startByTime := n - 1
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if latestTS-tsSkews[i].ts <= recentSkewWindowSec {
|
||||
startByTime = i
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Pick the narrower (larger-index) of the two windows — the most
|
||||
// current view of the node's clock health.
|
||||
start := startByCount
|
||||
if startByTime > start {
|
||||
start = startByTime
|
||||
}
|
||||
recentVals = make([]float64, 0, n-start)
|
||||
for i := start; i < n; i++ {
|
||||
recentVals = append(recentVals, tsSkews[i].skew)
|
||||
}
|
||||
if len(recentVals) > 0 {
|
||||
recentSkew = median(recentVals)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bimodal detection (#845) ─────────────────────────────────────────
|
||||
// Split recent samples into "good" (|skew| <= 1h, real clock) and
|
||||
// "bad" (|skew| > 1h, firmware nonsense from uninitialized RTC).
|
||||
// Classification order (first match wins):
|
||||
// no_clock — goodFraction < 0.10 (essentially no real clock)
|
||||
// bimodal_clock — 0.10 <= goodFraction < 0.80 AND badCount > 0
|
||||
// ok/warn/etc. — goodFraction >= 0.80 (normal, outliers filtered)
|
||||
var goodSamples []float64
|
||||
for _, v := range recentVals {
|
||||
if math.Abs(v) <= bimodalSkewThresholdSec {
|
||||
goodSamples = append(goodSamples, v)
|
||||
}
|
||||
}
|
||||
recentSampleCount := len(recentVals)
|
||||
recentBadCount := recentSampleCount - len(goodSamples)
|
||||
var goodFraction float64
|
||||
if recentSampleCount > 0 {
|
||||
goodFraction = float64(len(goodSamples)) / float64(recentSampleCount)
|
||||
}
|
||||
|
||||
var severity SkewSeverity
|
||||
if goodFraction < 0.10 {
|
||||
// Essentially no real clock — classify as no_clock regardless
|
||||
// of the raw skew magnitude.
|
||||
severity = SkewNoClock
|
||||
} else if goodFraction < 0.80 && recentBadCount > 0 {
|
||||
// Bimodal: use median of GOOD samples as the "real" skew.
|
||||
severity = SkewBimodalClock
|
||||
if len(goodSamples) > 0 {
|
||||
recentSkew = median(goodSamples)
|
||||
}
|
||||
} else {
|
||||
// Normal path: if there are good samples, use their median
|
||||
// (filters out rare outliers in ≥80% good case).
|
||||
if len(goodSamples) > 0 && recentBadCount > 0 {
|
||||
recentSkew = median(goodSamples)
|
||||
}
|
||||
severity = classifySkew(math.Abs(recentSkew))
|
||||
}
|
||||
|
||||
// For no_clock / bimodal_clock nodes, skip drift when data is unreliable.
|
||||
var drift float64
|
||||
if severity != SkewDefault && len(tsSkews) >= minDriftSamples {
|
||||
if severity != SkewNoClock && severity != SkewBimodalClock && len(tsSkews) >= minDriftSamples {
|
||||
drift = computeDrift(tsSkews)
|
||||
// Cap physically impossible drift rates.
|
||||
if math.Abs(drift) > maxReasonableDriftPerDay {
|
||||
drift = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Build sparkline samples.
|
||||
sort.Slice(tsSkews, func(i, j int) bool { return tsSkews[i].ts < tsSkews[j].ts })
|
||||
// Build sparkline samples from tsSkews (already sorted by time above).
|
||||
samples := make([]SkewSample, len(tsSkews))
|
||||
for i, p := range tsSkews {
|
||||
samples[i] = SkewSample{Timestamp: p.ts, SkewSec: round(p.skew, 1)}
|
||||
}
|
||||
|
||||
result := &NodeClockSkew{
|
||||
Pubkey: pubkey,
|
||||
MeanSkewSec: round(meanSkew, 1),
|
||||
MedianSkewSec: round(medSkew, 1),
|
||||
LastSkewSec: round(lastSkew, 1),
|
||||
DriftPerDaySec: round(drift, 2),
|
||||
Severity: severity,
|
||||
SampleCount: totalSamples,
|
||||
Calibrated: anyCal,
|
||||
LastAdvertTS: lastAdvTS,
|
||||
LastObservedTS: lastObsTS,
|
||||
Samples: samples,
|
||||
return &NodeClockSkew{
|
||||
Pubkey: pubkey,
|
||||
MeanSkewSec: round(meanSkew, 1),
|
||||
MedianSkewSec: round(medSkew, 1),
|
||||
LastSkewSec: round(lastSkew, 1),
|
||||
RecentMedianSkewSec: round(recentSkew, 1),
|
||||
DriftPerDaySec: round(drift, 2),
|
||||
Severity: severity,
|
||||
SampleCount: totalSamples,
|
||||
Calibrated: anyCal,
|
||||
LastAdvertTS: lastAdvTS,
|
||||
LastObservedTS: lastObsTS,
|
||||
Samples: samples,
|
||||
GoodFraction: round(goodFraction, 2),
|
||||
RecentBadSampleCount: recentBadCount,
|
||||
RecentSampleCount: recentSampleCount,
|
||||
}
|
||||
if severity == SkewDefault {
|
||||
ep := matchedEpoch
|
||||
result.DefaultEpoch = &ep
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetFleetClockSkew returns clock skew data for all nodes that have skew data.
|
||||
@@ -522,6 +583,7 @@ func (s *PacketStore) GetFleetClockSkew() []*NodeClockSkew {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// Build name/role lookup from DB cache (requires s.mu held).
|
||||
allNodes, _ := s.getCachedNodesAndPM()
|
||||
nameMap := make(map[string]nodeInfo, len(allNodes))
|
||||
for _, ni := range allNodes {
|
||||
@@ -534,10 +596,12 @@ func (s *PacketStore) GetFleetClockSkew() []*NodeClockSkew {
|
||||
if cs == nil {
|
||||
continue
|
||||
}
|
||||
// Enrich with node name/role.
|
||||
if ni, ok := nameMap[pubkey]; ok {
|
||||
cs.NodeName = ni.Name
|
||||
cs.NodeRole = ni.Role
|
||||
}
|
||||
// Omit samples in fleet response (too much data).
|
||||
cs.Samples = nil
|
||||
results = append(results, cs)
|
||||
}
|
||||
@@ -562,6 +626,7 @@ func (s *PacketStore) GetObserverCalibrations() []ObserverCalibration {
|
||||
Samples: s.clockSkew.observerSamples[obsID],
|
||||
})
|
||||
}
|
||||
// Sort by absolute offset descending.
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return math.Abs(result[i].OffsetSec) > math.Abs(result[j].OffsetSec)
|
||||
})
|
||||
@@ -602,20 +667,38 @@ type tsSkewPair struct {
|
||||
}
|
||||
|
||||
// computeDrift estimates linear drift in seconds per day from time-ordered
|
||||
// (timestamp, skew) pairs using Theil-Sen regression with outlier filtering.
|
||||
// (timestamp, skew) pairs. Issue #789: a single GPS-correction event (huge
|
||||
// skew jump in seconds) used to dominate ordinary least squares and produce
|
||||
// absurd drift like 1.7M sec/day. We now:
|
||||
//
|
||||
// 1. Drop pairs whose consecutive skew jump exceeds maxPlausibleSkewJumpSec
|
||||
// (clock corrections, not physical drift). This protects both OLS-style
|
||||
// consumers and Theil-Sen.
|
||||
// 2. Use Theil-Sen regression — the slope is the median of all pairwise
|
||||
// slopes, naturally robust to remaining outliers (breakdown point ~29%).
|
||||
//
|
||||
// For very small samples after filtering we fall back to a simple slope
|
||||
// between first and last calibrated samples.
|
||||
func computeDrift(pairs []tsSkewPair) float64 {
|
||||
if len(pairs) < 2 {
|
||||
return 0
|
||||
}
|
||||
// Sort by timestamp.
|
||||
sort.Slice(pairs, func(i, j int) bool {
|
||||
return pairs[i].ts < pairs[j].ts
|
||||
})
|
||||
|
||||
// Time span too short? Skip.
|
||||
spanSec := float64(pairs[len(pairs)-1].ts - pairs[0].ts)
|
||||
if spanSec < 3600 {
|
||||
if spanSec < 3600 { // need at least 1 hour of data
|
||||
return 0
|
||||
}
|
||||
|
||||
// Outlier filter: drop samples where the skew jumps more than
|
||||
// maxPlausibleSkewJumpSec from the running "stable" baseline.
|
||||
// We anchor on the first sample, then accept each subsequent point
|
||||
// that's within the threshold of the most recent accepted point —
|
||||
// this preserves a slow drift while rejecting correction events.
|
||||
filtered := make([]tsSkewPair, 0, len(pairs))
|
||||
filtered = append(filtered, pairs[0])
|
||||
for i := 1; i < len(pairs); i++ {
|
||||
@@ -624,23 +707,30 @@ func computeDrift(pairs []tsSkewPair) float64 {
|
||||
filtered = append(filtered, pairs[i])
|
||||
}
|
||||
}
|
||||
// If the filter killed too much (e.g. unstable node), fall back to the
|
||||
// raw series so we at least produce *something* — it'll be capped by
|
||||
// maxReasonableDriftPerDay downstream.
|
||||
if len(filtered) < 2 || float64(filtered[len(filtered)-1].ts-filtered[0].ts) < 3600 {
|
||||
filtered = pairs
|
||||
}
|
||||
|
||||
// Cap point count for Theil-Sen (O(n²) on pairs). Keep most-recent.
|
||||
if len(filtered) > theilSenMaxPoints {
|
||||
filtered = filtered[len(filtered)-theilSenMaxPoints:]
|
||||
}
|
||||
|
||||
return theilSenSlope(filtered) * 86400
|
||||
return theilSenSlope(filtered) * 86400 // sec/sec → sec/day
|
||||
}
|
||||
|
||||
// theilSenSlope returns the Theil-Sen estimator: median of all pairwise slopes.
|
||||
// theilSenSlope returns the Theil-Sen estimator: median of all pairwise
|
||||
// slopes (yj - yi) / (tj - ti) for i < j. Naturally robust to outliers.
|
||||
// Pairs must be sorted by timestamp ascending.
|
||||
func theilSenSlope(pairs []tsSkewPair) float64 {
|
||||
n := len(pairs)
|
||||
if n < 2 {
|
||||
return 0
|
||||
}
|
||||
// Pre-allocate: n*(n-1)/2 pairs.
|
||||
slopes := make([]float64, 0, n*(n-1)/2)
|
||||
for i := 0; i < n; i++ {
|
||||
for j := i + 1; j < n; j++ {
|
||||
|
||||
+603
-321
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestConcurrentIngestAndEviction exercises the race between IngestNewFromDB
|
||||
// adding packets (via direct store manipulation simulating the locked section)
|
||||
// and RunEviction removing packets. Without proper locking this would trigger
|
||||
// the race detector and produce inconsistent index state.
|
||||
func TestConcurrentIngestAndEviction(t *testing.T) {
|
||||
// Seed store with 200 old packets that are eligible for eviction
|
||||
startTime := time.Now().UTC().Add(-48 * time.Hour)
|
||||
store := makeTestStore(200, startTime, 1)
|
||||
store.retentionHours = 24 // everything older than 24h is evictable
|
||||
store.loaded = true
|
||||
|
||||
// Track bytes for all seeded packets
|
||||
for _, tx := range store.packets {
|
||||
store.trackedBytes += estimateStoreTxBytes(tx)
|
||||
for _, obs := range tx.Observations {
|
||||
store.trackedBytes += estimateStoreObsBytes(obs)
|
||||
}
|
||||
}
|
||||
|
||||
const numIngestGoroutines = 5
|
||||
const packetsPerGoroutine = 50
|
||||
const numEvictionGoroutines = 3
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var ingestedCount int64
|
||||
|
||||
// Concurrent ingest: simulate what IngestNewFromDB does under the lock
|
||||
for g := 0; g < numIngestGoroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < packetsPerGoroutine; i++ {
|
||||
txID := 1000 + goroutineID*1000 + i
|
||||
hash := fmt.Sprintf("new_hash_%d_%04d", goroutineID, i)
|
||||
pt := 5 // GRP_TXT
|
||||
ts := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
tx := &StoreTx{
|
||||
ID: txID,
|
||||
Hash: hash,
|
||||
FirstSeen: ts,
|
||||
LatestSeen: ts,
|
||||
PayloadType: &pt,
|
||||
DecodedJSON: fmt.Sprintf(`{"pubKey":"newpk_%d_%04d"}`, goroutineID, i),
|
||||
obsKeys: make(map[string]bool),
|
||||
observerSet: make(map[string]bool),
|
||||
}
|
||||
|
||||
obs := &StoreObs{
|
||||
ID: txID*10 + 1,
|
||||
TransmissionID: txID,
|
||||
ObserverID: fmt.Sprintf("obs_g%d", goroutineID),
|
||||
ObserverName: fmt.Sprintf("Observer_g%d", goroutineID),
|
||||
Timestamp: ts,
|
||||
}
|
||||
tx.Observations = append(tx.Observations, obs)
|
||||
tx.ObservationCount = 1
|
||||
|
||||
// Acquire write lock (same as IngestNewFromDB)
|
||||
store.mu.Lock()
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byHash[hash] = tx
|
||||
store.byTxID[txID] = tx
|
||||
store.byObsID[obs.ID] = obs
|
||||
store.byObserver[obs.ObserverID] = append(store.byObserver[obs.ObserverID], obs)
|
||||
store.byPayloadType[pt] = append(store.byPayloadType[pt], tx)
|
||||
pk := fmt.Sprintf("newpk_%d_%04d", goroutineID, i)
|
||||
if store.nodeHashes[pk] == nil {
|
||||
store.nodeHashes[pk] = make(map[string]bool)
|
||||
}
|
||||
store.nodeHashes[pk][hash] = true
|
||||
store.byNode[pk] = append(store.byNode[pk], tx)
|
||||
store.trackedBytes += estimateStoreTxBytes(tx)
|
||||
store.trackedBytes += estimateStoreObsBytes(obs)
|
||||
store.totalObs++
|
||||
store.mu.Unlock()
|
||||
|
||||
atomic.AddInt64(&ingestedCount, 1)
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
// Concurrent eviction goroutines
|
||||
var evictedTotal int64
|
||||
for g := 0; g < numEvictionGoroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 10; i++ {
|
||||
store.mu.Lock()
|
||||
n := store.EvictStale()
|
||||
store.mu.Unlock()
|
||||
atomic.AddInt64(&evictedTotal, int64(n))
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Concurrent readers (QueryPackets uses RLock)
|
||||
for g := 0; g < 3; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 20; i++ {
|
||||
store.mu.RLock()
|
||||
_ = len(store.packets)
|
||||
_ = len(store.byHash)
|
||||
store.mu.RUnlock()
|
||||
time.Sleep(500 * time.Microsecond)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// --- Post-state assertions ---
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
|
||||
totalIngested := int(atomic.LoadInt64(&ingestedCount))
|
||||
totalEvicted := int(atomic.LoadInt64(&evictedTotal))
|
||||
|
||||
if totalIngested != numIngestGoroutines*packetsPerGoroutine {
|
||||
t.Fatalf("expected %d ingested, got %d", numIngestGoroutines*packetsPerGoroutine, totalIngested)
|
||||
}
|
||||
|
||||
// Invariant: packets remaining = initial(200) + ingested - evicted
|
||||
expectedRemaining := 200 + totalIngested - totalEvicted
|
||||
if len(store.packets) != expectedRemaining {
|
||||
t.Fatalf("packets count mismatch: got %d, expected %d (200 + %d ingested - %d evicted)",
|
||||
len(store.packets), expectedRemaining, totalIngested, totalEvicted)
|
||||
}
|
||||
|
||||
// Invariant: byHash must be consistent with packets slice
|
||||
if len(store.byHash) != len(store.packets) {
|
||||
t.Fatalf("byHash size %d != packets len %d", len(store.byHash), len(store.packets))
|
||||
}
|
||||
|
||||
// Invariant: every packet in the slice must be in byHash
|
||||
for _, tx := range store.packets {
|
||||
if store.byHash[tx.Hash] != tx {
|
||||
t.Fatalf("packet %s in slice but not in byHash (or points to different tx)", tx.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Invariant: byTxID must map to packets in the slice
|
||||
byTxIDCount := 0
|
||||
for _, tx := range store.packets {
|
||||
if store.byTxID[tx.ID] == tx {
|
||||
byTxIDCount++
|
||||
}
|
||||
}
|
||||
if byTxIDCount != len(store.packets) {
|
||||
t.Fatalf("byTxID consistency: %d/%d packets found", byTxIDCount, len(store.packets))
|
||||
}
|
||||
|
||||
// Invariant: trackedBytes must be non-negative
|
||||
if store.trackedBytes < 0 {
|
||||
t.Fatalf("trackedBytes went negative: %d", store.trackedBytes)
|
||||
}
|
||||
|
||||
// Verify eviction actually happened (old packets were eligible)
|
||||
if totalEvicted == 0 {
|
||||
t.Fatal("expected some evictions to occur but got 0")
|
||||
}
|
||||
|
||||
t.Logf("OK: ingested=%d, evicted=%d, remaining=%d, trackedBytes=%d",
|
||||
totalIngested, totalEvicted, len(store.packets), store.trackedBytes)
|
||||
}
|
||||
|
||||
// TestConcurrentIngestNewObservationsAndEviction exercises the race between
|
||||
// adding new observations to existing transmissions and eviction removing those
|
||||
// same transmissions. This targets the IngestNewObservations path.
|
||||
func TestConcurrentIngestNewObservationsAndEviction(t *testing.T) {
|
||||
// Create store with 100 packets, half old (evictable), half recent
|
||||
now := time.Now().UTC()
|
||||
store := makeTestStore(0, now, 1) // empty, we'll add manually
|
||||
store.retentionHours = 1
|
||||
|
||||
// Add 50 old packets (2h ago) and 50 recent packets
|
||||
for i := 0; i < 100; i++ {
|
||||
var ts time.Time
|
||||
if i < 50 {
|
||||
ts = now.Add(-2 * time.Hour).Add(time.Duration(i) * time.Second)
|
||||
} else {
|
||||
ts = now.Add(-time.Duration(100-i) * time.Second)
|
||||
}
|
||||
hash := fmt.Sprintf("obs_hash_%04d", i)
|
||||
txID := i + 1
|
||||
pt := 4
|
||||
tx := &StoreTx{
|
||||
ID: txID,
|
||||
Hash: hash,
|
||||
FirstSeen: ts.UTC().Format(time.RFC3339),
|
||||
LatestSeen: ts.UTC().Format(time.RFC3339),
|
||||
PayloadType: &pt,
|
||||
DecodedJSON: fmt.Sprintf(`{"pubKey":"pk%04d"}`, i),
|
||||
obsKeys: make(map[string]bool),
|
||||
observerSet: make(map[string]bool),
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byHash[hash] = tx
|
||||
store.byTxID[txID] = tx
|
||||
store.byPayloadType[pt] = append(store.byPayloadType[pt], tx)
|
||||
store.trackedBytes += estimateStoreTxBytes(tx)
|
||||
}
|
||||
store.loaded = true
|
||||
|
||||
const numObsGoroutines = 4
|
||||
const obsPerGoroutine = 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var addedObs int64
|
||||
|
||||
// Goroutines adding observations to RECENT packets (index 50-99)
|
||||
for g := 0; g < numObsGoroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(gID int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < obsPerGoroutine; i++ {
|
||||
targetIdx := 50 + (i % 50) // only target recent packets
|
||||
hash := fmt.Sprintf("obs_hash_%04d", targetIdx)
|
||||
|
||||
store.mu.Lock()
|
||||
tx := store.byHash[hash]
|
||||
if tx != nil {
|
||||
obsID := 50000 + gID*10000 + i
|
||||
obs := &StoreObs{
|
||||
ID: obsID,
|
||||
TransmissionID: tx.ID,
|
||||
ObserverID: fmt.Sprintf("obs_new_%d", gID),
|
||||
ObserverName: fmt.Sprintf("NewObs_%d", gID),
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
dk := obs.ObserverID + "|"
|
||||
if !tx.obsKeys[dk] || true { // allow duplicates for stress
|
||||
tx.Observations = append(tx.Observations, obs)
|
||||
tx.ObservationCount++
|
||||
store.byObsID[obsID] = obs
|
||||
store.byObserver[obs.ObserverID] = append(store.byObserver[obs.ObserverID], obs)
|
||||
store.trackedBytes += estimateStoreObsBytes(obs)
|
||||
store.totalObs++
|
||||
atomic.AddInt64(&addedObs, 1)
|
||||
}
|
||||
}
|
||||
store.mu.Unlock()
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
// Concurrent eviction
|
||||
var evictedTotal int64
|
||||
for g := 0; g < 2; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 15; i++ {
|
||||
store.mu.Lock()
|
||||
n := store.EvictStale()
|
||||
store.mu.Unlock()
|
||||
atomic.AddInt64(&evictedTotal, int64(n))
|
||||
time.Sleep(500 * time.Microsecond)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// --- Assertions ---
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
|
||||
totalEvicted := int(atomic.LoadInt64(&evictedTotal))
|
||||
totalAdded := int(atomic.LoadInt64(&addedObs))
|
||||
|
||||
// All 50 old packets should have been evicted
|
||||
if totalEvicted < 50 {
|
||||
t.Fatalf("expected at least 50 evictions (old packets), got %d", totalEvicted)
|
||||
}
|
||||
|
||||
// Recent packets (50) should survive
|
||||
if len(store.packets) < 50 {
|
||||
t.Fatalf("expected at least 50 remaining packets (recent ones), got %d", len(store.packets))
|
||||
}
|
||||
|
||||
// byHash consistency
|
||||
for _, tx := range store.packets {
|
||||
if store.byHash[tx.Hash] != tx {
|
||||
t.Fatalf("byHash inconsistency for %s", tx.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
// No evicted packet should remain in byHash
|
||||
for i := 0; i < 50; i++ {
|
||||
hash := fmt.Sprintf("obs_hash_%04d", i)
|
||||
if store.byHash[hash] != nil {
|
||||
t.Fatalf("evicted packet %s still in byHash", hash)
|
||||
}
|
||||
}
|
||||
|
||||
// byObsID should not reference observations from evicted packets
|
||||
for obsID, obs := range store.byObsID {
|
||||
if store.byTxID[obs.TransmissionID] == nil {
|
||||
t.Fatalf("byObsID[%d] references evicted transmission %d", obsID, obs.TransmissionID)
|
||||
}
|
||||
}
|
||||
|
||||
// trackedBytes non-negative
|
||||
if store.trackedBytes < 0 {
|
||||
t.Fatalf("trackedBytes negative: %d", store.trackedBytes)
|
||||
}
|
||||
|
||||
t.Logf("OK: evicted=%d, added_obs=%d, remaining=%d, trackedBytes=%d",
|
||||
totalEvicted, totalAdded, len(store.packets), store.trackedBytes)
|
||||
}
|
||||
|
||||
// TestConcurrentRunEvictionWithReads exercises RunEviction's two-phase locking
|
||||
// against concurrent read operations (simulating QueryPackets / GetStoreStats).
|
||||
// Without proper RWMutex usage, this would race on slice/map reads.
|
||||
func TestConcurrentRunEvictionWithReads(t *testing.T) {
|
||||
startTime := time.Now().UTC().Add(-3 * time.Hour)
|
||||
store := makeTestStore(500, startTime, 1)
|
||||
store.retentionHours = 1
|
||||
store.loaded = true
|
||||
|
||||
for _, tx := range store.packets {
|
||||
store.trackedBytes += estimateStoreTxBytes(tx)
|
||||
for _, obs := range tx.Observations {
|
||||
store.trackedBytes += estimateStoreObsBytes(obs)
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Multiple RunEviction calls (uses its own locking)
|
||||
var evicted int64
|
||||
for g := 0; g < 3; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
n := store.RunEviction()
|
||||
atomic.AddInt64(&evicted, int64(n))
|
||||
}()
|
||||
}
|
||||
|
||||
// Concurrent readers using the public read-lock pattern
|
||||
var readCount int64
|
||||
for g := 0; g < 5; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 50; i++ {
|
||||
store.mu.RLock()
|
||||
count := len(store.packets)
|
||||
_ = count
|
||||
// Iterate a portion of byHash (simulating query)
|
||||
for hash, tx := range store.byHash {
|
||||
_ = hash
|
||||
_ = tx.ObservationCount
|
||||
break // just access one
|
||||
}
|
||||
store.mu.RUnlock()
|
||||
atomic.AddInt64(&readCount, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
|
||||
totalEvicted := int(atomic.LoadInt64(&evicted))
|
||||
|
||||
// Must have evicted packets older than 1h (most of the 500 are 1-3h old)
|
||||
if totalEvicted == 0 {
|
||||
t.Fatal("expected evictions but got 0")
|
||||
}
|
||||
|
||||
// Consistency: byHash == packets len
|
||||
if len(store.byHash) != len(store.packets) {
|
||||
t.Fatalf("byHash %d != packets %d after concurrent RunEviction+reads",
|
||||
len(store.byHash), len(store.packets))
|
||||
}
|
||||
|
||||
// All reads completed without panic
|
||||
if atomic.LoadInt64(&readCount) != 250 {
|
||||
t.Fatalf("not all reads completed: %d/250", atomic.LoadInt64(&readCount))
|
||||
}
|
||||
|
||||
t.Logf("OK: evicted=%d, remaining=%d, reads=%d",
|
||||
totalEvicted, len(store.packets), atomic.LoadInt64(&readCount))
|
||||
}
|
||||
@@ -62,8 +62,6 @@ type Config struct {
|
||||
|
||||
Retention *RetentionConfig `json:"retention,omitempty"`
|
||||
|
||||
DB *DBConfig `json:"db,omitempty"`
|
||||
|
||||
PacketStore *PacketStoreConfig `json:"packetStore,omitempty"`
|
||||
|
||||
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
|
||||
@@ -131,20 +129,6 @@ type RetentionConfig struct {
|
||||
MetricsDays int `json:"metricsDays"`
|
||||
}
|
||||
|
||||
// DBConfig controls SQLite vacuum and maintenance behavior (#919).
|
||||
type DBConfig struct {
|
||||
VacuumOnStartup bool `json:"vacuumOnStartup"` // one-time full VACUUM on startup if auto_vacuum is not INCREMENTAL
|
||||
IncrementalVacuumPages int `json:"incrementalVacuumPages"` // pages returned to OS per reaper cycle (default 1024)
|
||||
}
|
||||
|
||||
// IncrementalVacuumPages returns the configured pages per vacuum or 1024 default.
|
||||
func (c *Config) IncrementalVacuumPages() int {
|
||||
if c.DB != nil && c.DB.IncrementalVacuumPages > 0 {
|
||||
return c.DB.IncrementalVacuumPages
|
||||
}
|
||||
return 1024
|
||||
}
|
||||
|
||||
// MetricsRetentionDays returns configured metrics retention or 30 days default.
|
||||
func (c *Config) MetricsRetentionDays() int {
|
||||
if c.Retention != nil && c.Retention.MetricsDays > 0 {
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// createFreshIngestorDB creates a SQLite DB using the ingestor's applySchema logic
|
||||
// (simulated here) with auto_vacuum=INCREMENTAL set before tables.
|
||||
func createFreshDBWithAutoVacuum(t *testing.T, path string) *sql.DB {
|
||||
t.Helper()
|
||||
// auto_vacuum must be set via DSN before journal_mode creates the DB file
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=auto_vacuum(INCREMENTAL)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
// Create minimal schema
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT NOT NULL,
|
||||
hash TEXT NOT NULL UNIQUE,
|
||||
first_seen TEXT NOT NULL,
|
||||
route_type INTEGER,
|
||||
payload_type INTEGER,
|
||||
payload_version INTEGER,
|
||||
decoded_json TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
channel_hash TEXT
|
||||
);
|
||||
CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
observer_idx INTEGER,
|
||||
direction TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
path_json TEXT,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewDBHasIncrementalAutoVacuum(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
db := createFreshDBWithAutoVacuum(t, path)
|
||||
defer db.Close()
|
||||
|
||||
var autoVacuum int
|
||||
if err := db.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if autoVacuum != 2 {
|
||||
t.Fatalf("expected auto_vacuum=2 (INCREMENTAL), got %d", autoVacuum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingDBHasAutoVacuumNone(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create DB WITHOUT setting auto_vacuum (simulates old DB)
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
_, err = db.Exec("CREATE TABLE dummy (id INTEGER PRIMARY KEY)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var autoVacuum int
|
||||
if err := db.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
if autoVacuum != 0 {
|
||||
t.Fatalf("expected auto_vacuum=0 (NONE) for old DB, got %d", autoVacuum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuumOnStartupMigratesDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create DB without auto_vacuum (old DB)
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
_, err = db.Exec("CREATE TABLE dummy (id INTEGER PRIMARY KEY)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var before int
|
||||
db.QueryRow("PRAGMA auto_vacuum").Scan(&before)
|
||||
if before != 0 {
|
||||
t.Fatalf("precondition: expected auto_vacuum=0, got %d", before)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// Simulate vacuumOnStartup migration using openRW
|
||||
rw, err := openRW(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rw.Exec("PRAGMA auto_vacuum = INCREMENTAL"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rw.Exec("VACUUM"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rw.Close()
|
||||
|
||||
// Verify migration
|
||||
db2, err := sql.Open("sqlite", path+"?mode=ro")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db2.Close()
|
||||
|
||||
var after int
|
||||
if err := db2.QueryRow("PRAGMA auto_vacuum").Scan(&after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after != 2 {
|
||||
t.Fatalf("expected auto_vacuum=2 after VACUUM migration, got %d", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrementalVacuumReducesFreelist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
db := createFreshDBWithAutoVacuum(t, path)
|
||||
|
||||
// Insert a bunch of data
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
for i := 0; i < 500; i++ {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO transmissions (raw_hex, hash, first_seen) VALUES (?, ?, ?)",
|
||||
strings.Repeat("AA", 200), // ~400 bytes each
|
||||
"hash_"+string(rune('A'+i%26))+string(rune('0'+i/26)),
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get file size before delete
|
||||
db.Close()
|
||||
infoBefore, _ := os.Stat(path)
|
||||
sizeBefore := infoBefore.Size()
|
||||
|
||||
// Reopen and delete all
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec("DELETE FROM transmissions")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Check freelist before vacuum
|
||||
var freelistBefore int64
|
||||
db.QueryRow("PRAGMA freelist_count").Scan(&freelistBefore)
|
||||
if freelistBefore == 0 {
|
||||
t.Fatal("expected non-zero freelist after DELETE")
|
||||
}
|
||||
|
||||
// Run incremental vacuum
|
||||
_, err = db.Exec("PRAGMA incremental_vacuum(10000)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Check freelist after vacuum
|
||||
var freelistAfter int64
|
||||
db.QueryRow("PRAGMA freelist_count").Scan(&freelistAfter)
|
||||
if freelistAfter >= freelistBefore {
|
||||
t.Fatalf("expected freelist to shrink: before=%d after=%d", freelistBefore, freelistAfter)
|
||||
}
|
||||
|
||||
// Checkpoint WAL and check file size shrunk
|
||||
db.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
db.Close()
|
||||
infoAfter, _ := os.Stat(path)
|
||||
sizeAfter := infoAfter.Size()
|
||||
if sizeAfter >= sizeBefore {
|
||||
t.Logf("warning: file did not shrink (before=%d after=%d) — may depend on page reuse", sizeBefore, sizeAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAutoVacuumLogs(t *testing.T) {
|
||||
// This test verifies checkAutoVacuum doesn't panic on various configs
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create a fresh DB with auto_vacuum=INCREMENTAL
|
||||
dbConn := createFreshDBWithAutoVacuum(t, path)
|
||||
db := &DB{conn: dbConn, path: path}
|
||||
cfg := &Config{}
|
||||
|
||||
// Should not panic
|
||||
checkAutoVacuum(db, cfg, path)
|
||||
dbConn.Close()
|
||||
|
||||
// Create a DB without auto_vacuum
|
||||
path2 := filepath.Join(dir, "test2.db")
|
||||
dbConn2, _ := sql.Open("sqlite", path2+"?_pragma=journal_mode(WAL)")
|
||||
dbConn2.SetMaxOpenConns(1)
|
||||
dbConn2.Exec("CREATE TABLE dummy (id INTEGER PRIMARY KEY)")
|
||||
db2 := &DB{conn: dbConn2, path: path2}
|
||||
|
||||
// Should log warning but not panic
|
||||
checkAutoVacuum(db2, cfg, path2)
|
||||
dbConn2.Close()
|
||||
}
|
||||
|
||||
func TestConfigIncrementalVacuumPages(t *testing.T) {
|
||||
// Default
|
||||
cfg := &Config{}
|
||||
if cfg.IncrementalVacuumPages() != 1024 {
|
||||
t.Fatalf("expected default 1024, got %d", cfg.IncrementalVacuumPages())
|
||||
}
|
||||
|
||||
// Custom
|
||||
cfg.DB = &DBConfig{IncrementalVacuumPages: 512}
|
||||
if cfg.IncrementalVacuumPages() != 512 {
|
||||
t.Fatalf("expected 512, got %d", cfg.IncrementalVacuumPages())
|
||||
}
|
||||
|
||||
// Zero should return default
|
||||
cfg.DB.IncrementalVacuumPages = 0
|
||||
if cfg.IncrementalVacuumPages() != 1024 {
|
||||
t.Fatalf("expected default 1024 for zero, got %d", cfg.IncrementalVacuumPages())
|
||||
}
|
||||
}
|
||||
@@ -148,9 +148,6 @@ func main() {
|
||||
stats.TotalTransmissions, stats.TotalObservations, stats.TotalNodes, stats.TotalObservers)
|
||||
}
|
||||
|
||||
// Check auto_vacuum mode and optionally migrate (#919)
|
||||
checkAutoVacuum(database, cfg, resolvedDB)
|
||||
|
||||
// In-memory packet store
|
||||
store := NewPacketStore(database, cfg.PacketStore, cfg.CacheTTL)
|
||||
if err := store.Load(); err != nil {
|
||||
@@ -269,7 +266,6 @@ func main() {
|
||||
defer stopEviction()
|
||||
|
||||
// Auto-prune old packets if retention.packetDays is configured
|
||||
vacuumPages := cfg.IncrementalVacuumPages()
|
||||
var stopPrune func()
|
||||
if cfg.Retention != nil && cfg.Retention.PacketDays > 0 {
|
||||
days := cfg.Retention.PacketDays
|
||||
@@ -290,9 +286,6 @@ func main() {
|
||||
log.Printf("[prune] error: %v", err)
|
||||
} else {
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
if n > 0 {
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
}
|
||||
}
|
||||
for {
|
||||
select {
|
||||
@@ -301,9 +294,6 @@ func main() {
|
||||
log.Printf("[prune] error: %v", err)
|
||||
} else {
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
if n > 0 {
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
}
|
||||
}
|
||||
case <-pruneDone:
|
||||
return
|
||||
@@ -331,12 +321,10 @@ func main() {
|
||||
}()
|
||||
time.Sleep(2 * time.Minute) // stagger after packet prune
|
||||
database.PruneOldMetrics(metricsDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
for {
|
||||
select {
|
||||
case <-metricsPruneTicker.C:
|
||||
database.PruneOldMetrics(metricsDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
case <-metricsPruneDone:
|
||||
return
|
||||
}
|
||||
@@ -366,12 +354,10 @@ func main() {
|
||||
}()
|
||||
time.Sleep(3 * time.Minute) // stagger after metrics prune
|
||||
database.RemoveStaleObservers(observerDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
for {
|
||||
select {
|
||||
case <-observerPruneTicker.C:
|
||||
database.RemoveStaleObservers(observerDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
case <-observerPruneDone:
|
||||
return
|
||||
}
|
||||
@@ -402,7 +388,6 @@ func main() {
|
||||
g := store.graph
|
||||
store.mu.RUnlock()
|
||||
PruneNeighborEdges(dbPath, g, maxAgeDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
for {
|
||||
select {
|
||||
case <-edgePruneTicker.C:
|
||||
@@ -410,7 +395,6 @@ func main() {
|
||||
g := store.graph
|
||||
store.mu.RUnlock()
|
||||
PruneNeighborEdges(dbPath, g, maxAgeDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
case <-edgePruneDone:
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── Path Inspector ────────────────────────────────────────────────────────────
|
||||
// POST /api/paths/inspect — beam-search scorer for prefix path candidates.
|
||||
// Spec: issue #944 §2.1–2.5.
|
||||
|
||||
// pathInspectRequest is the JSON body for the inspect endpoint.
|
||||
type pathInspectRequest struct {
|
||||
Prefixes []string `json:"prefixes"`
|
||||
Context *pathInspectContext `json:"context,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type pathInspectContext struct {
|
||||
ObserverID string `json:"observerId,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
Until string `json:"until,omitempty"`
|
||||
}
|
||||
|
||||
// pathCandidate is one scored candidate path in the response.
|
||||
type pathCandidate struct {
|
||||
Path []string `json:"path"`
|
||||
Names []string `json:"names"`
|
||||
Score float64 `json:"score"`
|
||||
Speculative bool `json:"speculative"`
|
||||
Evidence pathEvidence `json:"evidence"`
|
||||
}
|
||||
|
||||
type pathEvidence struct {
|
||||
PerHop []hopEvidence `json:"perHop"`
|
||||
}
|
||||
|
||||
type hopEvidence struct {
|
||||
Prefix string `json:"prefix"`
|
||||
CandidatesConsidered int `json:"candidatesConsidered"`
|
||||
Chosen string `json:"chosen"`
|
||||
EdgeWeight float64 `json:"edgeWeight"`
|
||||
Alternatives []hopAlternative `json:"alternatives,omitempty"`
|
||||
}
|
||||
|
||||
// hopAlternative shows a candidate that was considered but not chosen for this hop.
|
||||
type hopAlternative struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
Name string `json:"name"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type pathInspectResponse struct {
|
||||
Candidates []pathCandidate `json:"candidates"`
|
||||
Input map[string]interface{} `json:"input"`
|
||||
Stats map[string]interface{} `json:"stats"`
|
||||
}
|
||||
|
||||
// beamEntry represents a partial path being extended during beam search.
|
||||
type beamEntry struct {
|
||||
pubkeys []string
|
||||
names []string
|
||||
evidence []hopEvidence
|
||||
score float64 // product of per-hop scores (pre-geometric-mean)
|
||||
}
|
||||
|
||||
const (
|
||||
beamWidth = 20
|
||||
maxInputHops = 64
|
||||
maxPrefixBytes = 3
|
||||
maxRequestItems = 64
|
||||
geoMaxKm = 50.0
|
||||
hopScoreFloor = 0.05
|
||||
speculativeThreshold = 0.7
|
||||
inspectCacheTTL = 30 * time.Second
|
||||
inspectBodyLimit = 4096
|
||||
)
|
||||
|
||||
// Weights per spec §2.3.
|
||||
const (
|
||||
wEdge = 0.35
|
||||
wGeo = 0.20
|
||||
wRecency = 0.15
|
||||
wSelectivity = 0.30
|
||||
)
|
||||
|
||||
func (s *Server) handlePathInspect(w http.ResponseWriter, r *http.Request) {
|
||||
// Body limit per spec §2.1.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, inspectBodyLimit)
|
||||
|
||||
var req pathInspectRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate prefixes.
|
||||
if len(req.Prefixes) == 0 {
|
||||
http.Error(w, `{"error":"prefixes required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Prefixes) > maxRequestItems {
|
||||
http.Error(w, `{"error":"too many prefixes (max 64)"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize + validate each prefix.
|
||||
prefixByteLen := -1
|
||||
for i, p := range req.Prefixes {
|
||||
p = strings.ToLower(strings.TrimSpace(p))
|
||||
req.Prefixes[i] = p
|
||||
if len(p) == 0 || len(p)%2 != 0 {
|
||||
http.Error(w, `{"error":"prefixes must be even-length hex"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := hex.DecodeString(p); err != nil {
|
||||
http.Error(w, `{"error":"prefixes must be valid hex"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
byteLen := len(p) / 2
|
||||
if byteLen > maxPrefixBytes {
|
||||
http.Error(w, `{"error":"prefix exceeds 3 bytes"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if prefixByteLen == -1 {
|
||||
prefixByteLen = byteLen
|
||||
} else if byteLen != prefixByteLen {
|
||||
http.Error(w, `{"error":"mixed prefix lengths not allowed"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
limit := req.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
// Check cache.
|
||||
cacheKey := s.store.inspectCacheKey(req)
|
||||
s.store.inspectMu.RLock()
|
||||
if cached, ok := s.store.inspectCache[cacheKey]; ok && time.Now().Before(cached.expiresAt) {
|
||||
s.store.inspectMu.RUnlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(cached.data)
|
||||
return
|
||||
}
|
||||
s.store.inspectMu.RUnlock()
|
||||
|
||||
// Snapshot data under read lock.
|
||||
nodes, pm := s.store.getCachedNodesAndPM()
|
||||
|
||||
// Build pubkey→nodeInfo map for O(1) geo lookup in scorer.
|
||||
nodeByPK := make(map[string]*nodeInfo, len(nodes))
|
||||
for i := range nodes {
|
||||
nodeByPK[strings.ToLower(nodes[i].PublicKey)] = &nodes[i]
|
||||
}
|
||||
|
||||
// Get neighbor graph; handle cold start.
|
||||
graph := s.store.graph
|
||||
if graph == nil || graph.IsStale() {
|
||||
rebuilt := make(chan struct{})
|
||||
go func() {
|
||||
s.store.ensureNeighborGraph()
|
||||
close(rebuilt)
|
||||
}()
|
||||
select {
|
||||
case <-rebuilt:
|
||||
graph = s.store.graph
|
||||
case <-time.After(2 * time.Second):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"retry": true})
|
||||
return
|
||||
}
|
||||
if graph == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"retry": true})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
start := now
|
||||
|
||||
// Beam search.
|
||||
beam := s.store.beamSearch(req.Prefixes, pm, graph, nodeByPK, now)
|
||||
|
||||
// Sort by score descending, take top limit.
|
||||
sortBeam(beam)
|
||||
if len(beam) > limit {
|
||||
beam = beam[:limit]
|
||||
}
|
||||
|
||||
// Build response with per-hop alternatives (spec §2.7, M2 fix).
|
||||
candidates := make([]pathCandidate, 0, len(beam))
|
||||
for _, entry := range beam {
|
||||
nHops := len(entry.pubkeys)
|
||||
var score float64
|
||||
if nHops > 0 {
|
||||
score = math.Pow(entry.score, 1.0/float64(nHops))
|
||||
}
|
||||
|
||||
// Populate per-hop alternatives: other candidates at each hop that weren't chosen.
|
||||
evidence := make([]hopEvidence, len(entry.evidence))
|
||||
copy(evidence, entry.evidence)
|
||||
for hi, ev := range evidence {
|
||||
if hi >= len(req.Prefixes) {
|
||||
break
|
||||
}
|
||||
prefix := req.Prefixes[hi]
|
||||
allCands := pm.m[prefix]
|
||||
var alts []hopAlternative
|
||||
for _, c := range allCands {
|
||||
if !canAppearInPath(c.Role) || c.PublicKey == ev.Chosen {
|
||||
continue
|
||||
}
|
||||
// Score this alternative in context of the partial path up to this hop.
|
||||
var partialEntry beamEntry
|
||||
if hi > 0 {
|
||||
partialEntry = beamEntry{pubkeys: entry.pubkeys[:hi], names: entry.names[:hi], score: 1.0}
|
||||
}
|
||||
altScore := s.store.scoreHop(partialEntry, c, ev.CandidatesConsidered, graph, nodeByPK, now, hi)
|
||||
alts = append(alts, hopAlternative{PublicKey: c.PublicKey, Name: c.Name, Score: math.Round(altScore*1000) / 1000})
|
||||
}
|
||||
// Sort alts by score desc, cap at 5.
|
||||
sort.Slice(alts, func(i, j int) bool { return alts[i].Score > alts[j].Score })
|
||||
if len(alts) > 5 {
|
||||
alts = alts[:5]
|
||||
}
|
||||
evidence[hi] = hopEvidence{
|
||||
Prefix: ev.Prefix,
|
||||
CandidatesConsidered: ev.CandidatesConsidered,
|
||||
Chosen: ev.Chosen,
|
||||
EdgeWeight: ev.EdgeWeight,
|
||||
Alternatives: alts,
|
||||
}
|
||||
}
|
||||
|
||||
candidates = append(candidates, pathCandidate{
|
||||
Path: entry.pubkeys,
|
||||
Names: entry.names,
|
||||
Score: math.Round(score*1000) / 1000,
|
||||
Speculative: score < speculativeThreshold,
|
||||
Evidence: pathEvidence{PerHop: evidence},
|
||||
})
|
||||
}
|
||||
|
||||
elapsed := time.Since(start).Milliseconds()
|
||||
resp := pathInspectResponse{
|
||||
Candidates: candidates,
|
||||
Input: map[string]interface{}{
|
||||
"prefixes": req.Prefixes,
|
||||
"hops": len(req.Prefixes),
|
||||
},
|
||||
Stats: map[string]interface{}{
|
||||
"beamWidth": beamWidth,
|
||||
"expansionsRun": len(req.Prefixes) * beamWidth,
|
||||
"elapsedMs": elapsed,
|
||||
},
|
||||
}
|
||||
|
||||
// Cache result (and evict stale entries).
|
||||
s.store.inspectMu.Lock()
|
||||
if s.store.inspectCache == nil {
|
||||
s.store.inspectCache = make(map[string]*inspectCachedResult)
|
||||
}
|
||||
now2 := time.Now()
|
||||
for k, v := range s.store.inspectCache {
|
||||
if now2.After(v.expiresAt) {
|
||||
delete(s.store.inspectCache, k)
|
||||
}
|
||||
}
|
||||
s.store.inspectCache[cacheKey] = &inspectCachedResult{
|
||||
data: resp,
|
||||
expiresAt: now2.Add(inspectCacheTTL),
|
||||
}
|
||||
s.store.inspectMu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
type inspectCachedResult struct {
|
||||
data pathInspectResponse
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func (s *PacketStore) inspectCacheKey(req pathInspectRequest) string {
|
||||
key := strings.Join(req.Prefixes, ",")
|
||||
if req.Context != nil {
|
||||
key += "|" + req.Context.ObserverID + "|" + req.Context.Since + "|" + req.Context.Until
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func (s *PacketStore) beamSearch(prefixes []string, pm *prefixMap, graph *NeighborGraph, nodeByPK map[string]*nodeInfo, now time.Time) []beamEntry {
|
||||
// Start with empty beam.
|
||||
beam := []beamEntry{{pubkeys: nil, names: nil, evidence: nil, score: 1.0}}
|
||||
|
||||
for hopIdx, prefix := range prefixes {
|
||||
candidates := pm.m[prefix]
|
||||
// Filter by role at lookup time (spec §2.2 step 2).
|
||||
var filtered []nodeInfo
|
||||
for _, c := range candidates {
|
||||
if canAppearInPath(c.Role) {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
|
||||
candidateCount := len(filtered)
|
||||
if candidateCount == 0 {
|
||||
// No candidates for this hop — beam dies.
|
||||
return nil
|
||||
}
|
||||
|
||||
var nextBeam []beamEntry
|
||||
for _, entry := range beam {
|
||||
for _, cand := range filtered {
|
||||
hopScore := s.scoreHop(entry, cand, candidateCount, graph, nodeByPK, now, hopIdx)
|
||||
if hopScore < hopScoreFloor {
|
||||
hopScore = hopScoreFloor
|
||||
}
|
||||
|
||||
newEntry := beamEntry{
|
||||
pubkeys: append(append([]string{}, entry.pubkeys...), cand.PublicKey),
|
||||
names: append(append([]string{}, entry.names...), cand.Name),
|
||||
evidence: append(append([]hopEvidence{}, entry.evidence...), hopEvidence{
|
||||
Prefix: prefix,
|
||||
CandidatesConsidered: candidateCount,
|
||||
Chosen: cand.PublicKey,
|
||||
EdgeWeight: hopScore,
|
||||
}),
|
||||
score: entry.score * hopScore,
|
||||
}
|
||||
nextBeam = append(nextBeam, newEntry)
|
||||
}
|
||||
}
|
||||
|
||||
// Prune to beam width.
|
||||
sortBeam(nextBeam)
|
||||
if len(nextBeam) > beamWidth {
|
||||
nextBeam = nextBeam[:beamWidth]
|
||||
}
|
||||
beam = nextBeam
|
||||
}
|
||||
|
||||
return beam
|
||||
}
|
||||
|
||||
func (s *PacketStore) scoreHop(entry beamEntry, cand nodeInfo, candidateCount int, graph *NeighborGraph, nodeByPK map[string]*nodeInfo, now time.Time, hopIdx int) float64 {
|
||||
var edgeScore float64
|
||||
var geoScore float64 = 1.0
|
||||
var recencyScore float64 = 1.0
|
||||
|
||||
if hopIdx == 0 || len(entry.pubkeys) == 0 {
|
||||
// First hop: no prior node to compare against.
|
||||
edgeScore = 1.0
|
||||
} else {
|
||||
lastPK := entry.pubkeys[len(entry.pubkeys)-1]
|
||||
|
||||
// Single scan over neighbors for both edge weight and recency.
|
||||
edges := graph.Neighbors(lastPK)
|
||||
var foundEdge *NeighborEdge
|
||||
for _, e := range edges {
|
||||
peer := e.NodeA
|
||||
if strings.EqualFold(peer, lastPK) {
|
||||
peer = e.NodeB
|
||||
}
|
||||
if strings.EqualFold(peer, cand.PublicKey) {
|
||||
foundEdge = e
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if foundEdge != nil {
|
||||
edgeScore = foundEdge.Score(now)
|
||||
hoursSince := now.Sub(foundEdge.LastSeen).Hours()
|
||||
if hoursSince <= 24 {
|
||||
recencyScore = 1.0
|
||||
} else {
|
||||
recencyScore = math.Max(0.1, 24.0/hoursSince)
|
||||
}
|
||||
} else {
|
||||
edgeScore = 0
|
||||
recencyScore = 0
|
||||
}
|
||||
|
||||
// Geographic plausibility.
|
||||
prevNode := nodeByPK[strings.ToLower(lastPK)]
|
||||
if prevNode != nil && prevNode.HasGPS && cand.HasGPS {
|
||||
dist := haversineKm(prevNode.Lat, prevNode.Lon, cand.Lat, cand.Lon)
|
||||
if dist > geoMaxKm {
|
||||
geoScore = math.Max(0.1, geoMaxKm/dist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix selectivity.
|
||||
selectivityScore := 1.0 / float64(candidateCount)
|
||||
|
||||
return wEdge*edgeScore + wGeo*geoScore + wRecency*recencyScore + wSelectivity*selectivityScore
|
||||
}
|
||||
|
||||
|
||||
func sortBeam(beam []beamEntry) {
|
||||
sort.Slice(beam, func(i, j int) bool {
|
||||
return beam[i].score > beam[j].score
|
||||
})
|
||||
}
|
||||
|
||||
// ensureNeighborGraph triggers a graph rebuild if nil or stale.
|
||||
func (s *PacketStore) ensureNeighborGraph() {
|
||||
if s.graph != nil && !s.graph.IsStale() {
|
||||
return
|
||||
}
|
||||
g := BuildFromStore(s)
|
||||
s.graph = g
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── Unit tests for path inspector (issue #944) ────────────────────────────────
|
||||
|
||||
func TestScoreHop_EdgeWeight(t *testing.T) {
|
||||
store := &PacketStore{}
|
||||
graph := NewNeighborGraph()
|
||||
now := time.Now()
|
||||
|
||||
// Add an edge between A and B.
|
||||
graph.mu.Lock()
|
||||
edge := &NeighborEdge{
|
||||
NodeA: "aaaa", NodeB: "bbbb",
|
||||
Count: 50, LastSeen: now.Add(-1 * time.Hour),
|
||||
Observers: map[string]bool{"obs1": true},
|
||||
}
|
||||
key := edgeKey{"aaaa", "bbbb"}
|
||||
graph.edges[key] = edge
|
||||
graph.byNode["aaaa"] = append(graph.byNode["aaaa"], edge)
|
||||
graph.byNode["bbbb"] = append(graph.byNode["bbbb"], edge)
|
||||
graph.mu.Unlock()
|
||||
|
||||
entry := beamEntry{pubkeys: []string{"aaaa"}, names: []string{"NodeA"}}
|
||||
cand := nodeInfo{PublicKey: "bbbb", Name: "NodeB", Role: "repeater"}
|
||||
|
||||
score := store.scoreHop(entry, cand, 2, graph, nil, now, 1)
|
||||
|
||||
// With edge present, edgeScore > 0. With 2 candidates, selectivity = 0.5.
|
||||
// Anti-tautology: if we zero out edge weight constant, score would change.
|
||||
if score <= 0.05 {
|
||||
t.Errorf("expected score > floor, got %f", score)
|
||||
}
|
||||
|
||||
// No edge: score should be lower.
|
||||
candNoEdge := nodeInfo{PublicKey: "cccc", Name: "NodeC", Role: "repeater"}
|
||||
scoreNoEdge := store.scoreHop(entry, candNoEdge, 2, graph, nil, now, 1)
|
||||
if scoreNoEdge >= score {
|
||||
t.Errorf("expected no-edge score (%f) < edge score (%f)", scoreNoEdge, score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreHop_FirstHop(t *testing.T) {
|
||||
store := &PacketStore{}
|
||||
graph := NewNeighborGraph()
|
||||
now := time.Now()
|
||||
|
||||
entry := beamEntry{pubkeys: nil, names: nil}
|
||||
cand := nodeInfo{PublicKey: "aaaa", Name: "NodeA", Role: "repeater"}
|
||||
|
||||
score := store.scoreHop(entry, cand, 3, graph, nil, now, 0)
|
||||
// First hop: edgeScore=1.0, geoScore=1.0, recencyScore=1.0, selectivity=1/3
|
||||
// = 0.35*1 + 0.20*1 + 0.15*1 + 0.30*(1/3) = 0.35+0.20+0.15+0.10 = 0.80
|
||||
expected := 0.35 + 0.20 + 0.15 + 0.30/3.0
|
||||
if score < expected-0.01 || score > expected+0.01 {
|
||||
t.Errorf("expected ~%f, got %f", expected, score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreHop_GeoPlausibility(t *testing.T) {
|
||||
store := &PacketStore{}
|
||||
store.nodeCache = []nodeInfo{
|
||||
{PublicKey: "aaaa", Name: "A", Role: "repeater", Lat: 37.0, Lon: -122.0, HasGPS: true},
|
||||
{PublicKey: "bbbb", Name: "B", Role: "repeater", Lat: 37.01, Lon: -122.01, HasGPS: true}, // ~1.4km
|
||||
{PublicKey: "cccc", Name: "C", Role: "repeater", Lat: 40.0, Lon: -120.0, HasGPS: true}, // ~400km
|
||||
}
|
||||
store.nodePM = buildPrefixMap(store.nodeCache)
|
||||
store.nodeCacheTime = time.Now()
|
||||
|
||||
graph := NewNeighborGraph()
|
||||
now := time.Now()
|
||||
|
||||
nodeByPK := map[string]*nodeInfo{
|
||||
"aaaa": &store.nodeCache[0],
|
||||
"bbbb": &store.nodeCache[1],
|
||||
"cccc": &store.nodeCache[2],
|
||||
}
|
||||
|
||||
entry := beamEntry{pubkeys: []string{"aaaa"}, names: []string{"A"}}
|
||||
|
||||
// Close node should score higher than far node (geo component).
|
||||
scoreClose := store.scoreHop(entry, store.nodeCache[1], 2, graph, nodeByPK, now, 1)
|
||||
scoreFar := store.scoreHop(entry, store.nodeCache[2], 2, graph, nodeByPK, now, 1)
|
||||
if scoreFar >= scoreClose {
|
||||
t.Errorf("expected far node score (%f) < close node score (%f)", scoreFar, scoreClose)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeamSearch_WidthCap(t *testing.T) {
|
||||
store := &PacketStore{}
|
||||
graph := NewNeighborGraph()
|
||||
graph.builtAt = time.Now()
|
||||
now := time.Now()
|
||||
|
||||
// Create 25 nodes that all match prefix "aa".
|
||||
var nodes []nodeInfo
|
||||
for i := 0; i < 25; i++ {
|
||||
// Each node has pubkey starting with "aa" followed by unique hex.
|
||||
pk := "aa" + strings.Repeat("0", 4) + fmt.Sprintf("%02x", i)
|
||||
nodes = append(nodes, nodeInfo{PublicKey: pk, Name: pk, Role: "repeater"})
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
|
||||
// Two hops of "aa" — should produce 25*25=625 combos, pruned to 20.
|
||||
beam := store.beamSearch([]string{"aa", "aa"}, pm, graph, nil, now)
|
||||
if len(beam) > beamWidth {
|
||||
t.Errorf("beam exceeded width: got %d, want <= %d", len(beam), beamWidth)
|
||||
}
|
||||
// Anti-tautology: without beam pruning, we'd have up to 25*min(25,beamWidth)=500 entries.
|
||||
// The test verifies pruning is effective.
|
||||
}
|
||||
|
||||
func TestBeamSearch_Speculative(t *testing.T) {
|
||||
store := &PacketStore{}
|
||||
graph := NewNeighborGraph()
|
||||
graph.builtAt = time.Now()
|
||||
now := time.Now()
|
||||
|
||||
// Create nodes with no edges and multiple candidates — should result in low scores (speculative).
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aabb", Name: "N1", Role: "repeater"},
|
||||
{PublicKey: "aabb22", Name: "N1b", Role: "repeater"},
|
||||
{PublicKey: "ccdd", Name: "N2", Role: "repeater"},
|
||||
{PublicKey: "ccdd22", Name: "N2b", Role: "repeater"},
|
||||
{PublicKey: "ccdd33", Name: "N2c", Role: "repeater"},
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
|
||||
beam := store.beamSearch([]string{"aa", "cc"}, pm, graph, nil, now)
|
||||
if len(beam) == 0 {
|
||||
t.Fatal("expected at least one result")
|
||||
}
|
||||
|
||||
// Score should be < 0.7 since there's no edge and multiple candidates (speculative).
|
||||
nHops := len(beam[0].pubkeys)
|
||||
score := 1.0
|
||||
if nHops > 0 {
|
||||
product := beam[0].score
|
||||
score = pow(product, 1.0/float64(nHops))
|
||||
}
|
||||
if score >= speculativeThreshold {
|
||||
t.Errorf("expected speculative score (< %f), got %f", speculativeThreshold, score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePathInspect_EmptyPrefixes(t *testing.T) {
|
||||
srv := newTestServerForInspect(t)
|
||||
body := `{"prefixes":[]}`
|
||||
rr := doInspectRequest(srv, body)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePathInspect_OddLengthPrefix(t *testing.T) {
|
||||
srv := newTestServerForInspect(t)
|
||||
body := `{"prefixes":["abc"]}`
|
||||
rr := doInspectRequest(srv, body)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for odd-length prefix, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePathInspect_MixedLengths(t *testing.T) {
|
||||
srv := newTestServerForInspect(t)
|
||||
body := `{"prefixes":["aa","bbcc"]}`
|
||||
rr := doInspectRequest(srv, body)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for mixed lengths, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePathInspect_TooLongPrefix(t *testing.T) {
|
||||
srv := newTestServerForInspect(t)
|
||||
body := `{"prefixes":["aabbccdd"]}`
|
||||
rr := doInspectRequest(srv, body)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for >3-byte prefix, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePathInspect_TooManyPrefixes(t *testing.T) {
|
||||
srv := newTestServerForInspect(t)
|
||||
prefixes := make([]string, 65)
|
||||
for i := range prefixes {
|
||||
prefixes[i] = "aa"
|
||||
}
|
||||
b, _ := json.Marshal(map[string]interface{}{"prefixes": prefixes})
|
||||
rr := doInspectRequest(srv, string(b))
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for >64 prefixes, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePathInspect_ValidRequest(t *testing.T) {
|
||||
srv := newTestServerForInspect(t)
|
||||
|
||||
// Seed nodes in the store — multiple candidates per prefix to lower selectivity.
|
||||
srv.store.nodeCache = []nodeInfo{
|
||||
{PublicKey: "aabb1234", Name: "NodeA", Role: "repeater", Lat: 37.0, Lon: -122.0, HasGPS: true},
|
||||
{PublicKey: "aabb5678", Name: "NodeA2", Role: "repeater"},
|
||||
{PublicKey: "ccdd5678", Name: "NodeB", Role: "repeater", Lat: 37.01, Lon: -122.01, HasGPS: true},
|
||||
{PublicKey: "ccdd9999", Name: "NodeB2", Role: "repeater"},
|
||||
{PublicKey: "ccdd1111", Name: "NodeB3", Role: "repeater"},
|
||||
}
|
||||
srv.store.nodePM = buildPrefixMap(srv.store.nodeCache)
|
||||
srv.store.nodeCacheTime = time.Now()
|
||||
srv.store.graph = NewNeighborGraph()
|
||||
srv.store.graph.builtAt = time.Now()
|
||||
|
||||
body := `{"prefixes":["aa","cc"]}`
|
||||
rr := doInspectRequest(srv, body)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp pathInspectResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON response: %v", err)
|
||||
}
|
||||
if len(resp.Candidates) == 0 {
|
||||
t.Error("expected at least one candidate")
|
||||
}
|
||||
if resp.Candidates[0].Speculative != true {
|
||||
// No edge between nodes, so score should be < 0.7.
|
||||
t.Error("expected speculative=true for no-edge path")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func newTestServerForInspect(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
store := &PacketStore{
|
||||
inspectCache: make(map[string]*inspectCachedResult),
|
||||
}
|
||||
store.graph = NewNeighborGraph()
|
||||
store.graph.builtAt = time.Now()
|
||||
return &Server{store: store}
|
||||
}
|
||||
|
||||
func doInspectRequest(srv *Server, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("POST", "/api/paths/inspect", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
srv.handlePathInspect(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func pow(base, exp float64) float64 {
|
||||
return math.Pow(base, exp)
|
||||
}
|
||||
|
||||
// BenchmarkBeamSearch — performance proof for spec §2.5 (<100ms p99 for ≤64 hops).
|
||||
// Anti-tautology: removing beam pruning makes this ~625x slower; timing assertion catches it.
|
||||
func BenchmarkBeamSearch(b *testing.B) {
|
||||
// Setup: 100 nodes, 10-hop prefix input, realistic neighbor graph.
|
||||
// Anti-tautology: removing beam pruning makes this ~625x slower.
|
||||
store := &PacketStore{}
|
||||
pm := &prefixMap{m: make(map[string][]nodeInfo)}
|
||||
graph := NewNeighborGraph()
|
||||
nodes := make([]nodeInfo, 100)
|
||||
|
||||
now := time.Now()
|
||||
for i := 0; i < 100; i++ {
|
||||
pk := fmt.Sprintf("%064x", i)
|
||||
prefix := fmt.Sprintf("%02x", i%256)
|
||||
node := nodeInfo{PublicKey: pk, Name: fmt.Sprintf("Node%d", i), Role: "repeater", Lat: 37.0 + float64(i)*0.01, Lon: -122.0 + float64(i)*0.01}
|
||||
nodes[i] = node
|
||||
pm.m[prefix] = append(pm.m[prefix], node)
|
||||
// Add neighbor edges to create a connected graph.
|
||||
if i > 0 {
|
||||
prevPK := fmt.Sprintf("%064x", i-1)
|
||||
key := makeEdgeKey(prevPK, pk)
|
||||
edge := &NeighborEdge{NodeA: prevPK, NodeB: pk, LastSeen: now, Count: 10}
|
||||
graph.edges[key] = edge
|
||||
graph.byNode[prevPK] = append(graph.byNode[prevPK], edge)
|
||||
graph.byNode[pk] = append(graph.byNode[pk], edge)
|
||||
}
|
||||
}
|
||||
|
||||
// 10-hop input using prefixes that map to multiple candidates.
|
||||
prefixes := make([]string, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
prefixes[i] = fmt.Sprintf("%02x", (i*3)%256)
|
||||
}
|
||||
|
||||
nodeByPK := make(map[string]*nodeInfo)
|
||||
for idx := range nodes {
|
||||
nodeByPK[nodes[idx].PublicKey] = &nodes[idx]
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
store.beamSearch(prefixes, pm, graph, nodeByPK, now)
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,6 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/observers/{id}", s.handleObserverDetail).Methods("GET")
|
||||
r.HandleFunc("/api/observers", s.handleObservers).Methods("GET")
|
||||
r.HandleFunc("/api/traces/{hash}", s.handleTraces).Methods("GET")
|
||||
r.HandleFunc("/api/paths/inspect", s.handlePathInspect).Methods("POST")
|
||||
r.HandleFunc("/api/iata-coords", s.handleIATACoords).Methods("GET")
|
||||
r.HandleFunc("/api/audio-lab/buckets", s.handleAudioLabBuckets).Methods("GET")
|
||||
|
||||
|
||||
+8
-40
@@ -209,10 +209,6 @@ type PacketStore struct {
|
||||
// Persisted neighbor graph for hop resolution at ingest time.
|
||||
graph *NeighborGraph
|
||||
|
||||
// Path inspector score cache (issue #944).
|
||||
inspectMu sync.RWMutex
|
||||
inspectCache map[string]*inspectCachedResult
|
||||
|
||||
// Clock skew detection engine.
|
||||
clockSkew *ClockSkewEngine
|
||||
|
||||
@@ -468,19 +464,10 @@ func (s *PacketStore) Load() error {
|
||||
obsRawHexCol = ", o.raw_hex"
|
||||
}
|
||||
|
||||
// Build WHERE conditions: retention cutoff (mirrors Evict logic) + optional memory-cap limit.
|
||||
var loadConditions []string
|
||||
if s.retentionHours > 0 {
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(s.retentionHours*3600) * time.Second).Format(time.RFC3339)
|
||||
loadConditions = append(loadConditions, fmt.Sprintf("t.first_seen >= '%s'", cutoff))
|
||||
}
|
||||
limitClause := ""
|
||||
if maxPackets > 0 {
|
||||
loadConditions = append(loadConditions, fmt.Sprintf(
|
||||
"t.id IN (SELECT id FROM transmissions ORDER BY first_seen DESC LIMIT %d)", maxPackets))
|
||||
}
|
||||
filterClause := ""
|
||||
if len(loadConditions) > 0 {
|
||||
filterClause = "\n\t\t\tWHERE " + strings.Join(loadConditions, "\n\t\t\t AND ")
|
||||
limitClause = fmt.Sprintf(
|
||||
"\n\t\t\tWHERE t.id IN (SELECT id FROM transmissions ORDER BY first_seen DESC LIMIT %d)", maxPackets)
|
||||
}
|
||||
|
||||
if s.db.isV3 {
|
||||
@@ -490,7 +477,7 @@ func (s *PacketStore) Load() error {
|
||||
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + `
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx` + filterClause + `
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx` + limitClause + `
|
||||
ORDER BY t.first_seen ASC, o.timestamp DESC`
|
||||
} else {
|
||||
loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||
@@ -498,7 +485,7 @@ func (s *PacketStore) Load() error {
|
||||
o.id, o.observer_id, o.observer_name, o.direction,
|
||||
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + `
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id` + filterClause + `
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id` + limitClause + `
|
||||
ORDER BY t.first_seen ASC, o.timestamp DESC`
|
||||
}
|
||||
|
||||
@@ -4530,19 +4517,12 @@ type nodeInfo struct {
|
||||
Lat float64
|
||||
Lon float64
|
||||
HasGPS bool
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
func (s *PacketStore) getAllNodes() []nodeInfo {
|
||||
// Try with last_seen first; fall back to without if column doesn't exist.
|
||||
rows, err := s.db.conn.Query("SELECT public_key, name, role, lat, lon, last_seen FROM nodes")
|
||||
hasLastSeen := true
|
||||
rows, err := s.db.conn.Query("SELECT public_key, name, role, lat, lon FROM nodes")
|
||||
if err != nil {
|
||||
rows, err = s.db.conn.Query("SELECT public_key, name, role, lat, lon FROM nodes")
|
||||
hasLastSeen = false
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var nodes []nodeInfo
|
||||
@@ -4550,25 +4530,13 @@ func (s *PacketStore) getAllNodes() []nodeInfo {
|
||||
var pk string
|
||||
var name, role sql.NullString
|
||||
var lat, lon sql.NullFloat64
|
||||
var lastSeen sql.NullString
|
||||
if hasLastSeen {
|
||||
rows.Scan(&pk, &name, &role, &lat, &lon, &lastSeen)
|
||||
} else {
|
||||
rows.Scan(&pk, &name, &role, &lat, &lon)
|
||||
}
|
||||
rows.Scan(&pk, &name, &role, &lat, &lon)
|
||||
n := nodeInfo{PublicKey: pk, Name: nullStrVal(name), Role: nullStrVal(role)}
|
||||
if lat.Valid && lon.Valid {
|
||||
n.Lat = lat.Float64
|
||||
n.Lon = lon.Float64
|
||||
n.HasGPS = !(n.Lat == 0 && n.Lon == 0)
|
||||
}
|
||||
if hasLastSeen && lastSeen.Valid && lastSeen.String != "" {
|
||||
if t, err := time.Parse(time.RFC3339, lastSeen.String); err == nil {
|
||||
n.LastSeen = t
|
||||
} else if t, err := time.Parse("2006-01-02 15:04:05", lastSeen.String); err == nil {
|
||||
n.LastSeen = t
|
||||
}
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
return nodes
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// checkAutoVacuum inspects the current auto_vacuum mode and logs a warning
|
||||
// if it's not INCREMENTAL. Optionally performs a one-time full VACUUM if
|
||||
// the operator has set db.vacuumOnStartup: true in config (#919).
|
||||
func checkAutoVacuum(db *DB, cfg *Config, dbPath string) {
|
||||
var autoVacuum int
|
||||
if err := db.conn.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum); err != nil {
|
||||
log.Printf("[db] warning: could not read auto_vacuum: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if autoVacuum == 2 {
|
||||
log.Printf("[db] auto_vacuum=INCREMENTAL")
|
||||
return
|
||||
}
|
||||
|
||||
modes := map[int]string{0: "NONE", 1: "FULL", 2: "INCREMENTAL"}
|
||||
mode := modes[autoVacuum]
|
||||
if mode == "" {
|
||||
mode = fmt.Sprintf("UNKNOWN(%d)", autoVacuum)
|
||||
}
|
||||
|
||||
log.Printf("[db] auto_vacuum=%s — DB needs one-time VACUUM to enable incremental auto-vacuum. "+
|
||||
"Set db.vacuumOnStartup: true in config to migrate (will block startup for several minutes on large DBs). "+
|
||||
"See https://github.com/Kpa-clawbot/CoreScope/issues/919", mode)
|
||||
|
||||
if cfg.DB != nil && cfg.DB.VacuumOnStartup {
|
||||
// WARNING: Full VACUUM creates a temporary copy of the entire DB file.
|
||||
// Requires ~2× the DB file size in free disk space or it will fail.
|
||||
log.Printf("[db] vacuumOnStartup=true — starting one-time full VACUUM (ensure 2x DB size free disk space)...")
|
||||
start := time.Now()
|
||||
|
||||
rw, err := openRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[db] VACUUM failed: could not open RW connection: %v", err)
|
||||
return
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
if _, err := rw.Exec("PRAGMA auto_vacuum = INCREMENTAL"); err != nil {
|
||||
log.Printf("[db] VACUUM failed: could not set auto_vacuum: %v", err)
|
||||
return
|
||||
}
|
||||
if _, err := rw.Exec("VACUUM"); err != nil {
|
||||
log.Printf("[db] VACUUM failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
log.Printf("[db] VACUUM complete in %v — auto_vacuum is now INCREMENTAL", elapsed.Round(time.Millisecond))
|
||||
|
||||
// Re-check
|
||||
var newMode int
|
||||
if err := db.conn.QueryRow("PRAGMA auto_vacuum").Scan(&newMode); err == nil {
|
||||
if newMode == 2 {
|
||||
log.Printf("[db] auto_vacuum=INCREMENTAL (confirmed after VACUUM)")
|
||||
} else {
|
||||
log.Printf("[db] warning: auto_vacuum=%d after VACUUM — expected 2", newMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runIncrementalVacuum runs PRAGMA incremental_vacuum(N) on a read-write
|
||||
// connection. Safe to call on auto_vacuum=NONE databases (noop).
|
||||
func runIncrementalVacuum(dbPath string, pages int) {
|
||||
rw, err := openRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[vacuum] could not open RW connection: %v", err)
|
||||
return
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
if _, err := rw.Exec(fmt.Sprintf("PRAGMA incremental_vacuum(%d)", pages)); err != nil {
|
||||
log.Printf("[vacuum] incremental_vacuum error: %v", err)
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -9,11 +9,6 @@
|
||||
"packetDays": 30,
|
||||
"_comment": "nodeDays: nodes not seen in N days moved to inactive_nodes (default 7). observerDays: observers not sending data in N days are removed (-1 = keep forever, default 14). packetDays: transmissions older than N days are deleted (0 = disabled)."
|
||||
},
|
||||
"db": {
|
||||
"vacuumOnStartup": false,
|
||||
"incrementalVacuumPages": 1024,
|
||||
"_comment": "vacuumOnStartup: run one-time full VACUUM to enable incremental auto-vacuum on existing DBs (blocks startup for minutes on large DBs; requires 2x DB file size in free disk space). incrementalVacuumPages: free pages returned to OS after each retention reaper cycle (default 1024). See #919."
|
||||
},
|
||||
"https": {
|
||||
"cert": "/path/to/cert.pem",
|
||||
"key": "/path/to/key.pem",
|
||||
@@ -213,8 +208,7 @@
|
||||
"packetStore": {
|
||||
"maxMemoryMB": 1024,
|
||||
"estimatedPacketBytes": 450,
|
||||
"retentionHours": 168,
|
||||
"_comment": "In-memory packet store. maxMemoryMB caps RAM usage. retentionHours: only packets younger than this are loaded on startup and kept in memory (0 = unlimited, not recommended for large DBs — causes OOM on cold start). 168 = 7 days. Must be ≤ retention.packetDays * 24."
|
||||
"_comment": "In-memory packet store. maxMemoryMB caps RAM usage. All packets loaded on startup, served from RAM."
|
||||
},
|
||||
"resolvedPath": {
|
||||
"backfillHours": 24,
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# Clock Skew Classifier — Redesign
|
||||
|
||||
**Status:** spec, pre-implementation
|
||||
**Supersedes:** parts of #690 / #789 / #845 / PR #894
|
||||
**Date drafted:** 2026-04-24
|
||||
|
||||
## Problem
|
||||
|
||||
The current classifier (`cmd/server/clock_skew.go`) uses windowed medians, hysteresis, "good fraction" floors, and a 365-day `no_clock` threshold. It produces:
|
||||
|
||||
- False `no_clock` flags on nodes whose clocks are working today but had garbage timestamps in recent samples.
|
||||
- Symmetric severity bands that conflate "clock at firmware default" with "operator set the clock wrong by a year" — completely different operator actions required.
|
||||
- Compounding over-engineering as each operator complaint added a new tier or window.
|
||||
|
||||
The actual physical reality of these devices is much simpler than the classifier assumes.
|
||||
|
||||
## Hardware reality
|
||||
|
||||
Most MeshCore nodes have **no auto-updating RTC**. There are two hardware paths:
|
||||
|
||||
1. **Volatile RTC nodes** (`firmware/src/helpers/ArduinoHelpers.h:11` — `VolatileRTCClock`):
|
||||
- On boot, `base_time` is hardcoded to a firmware-build constant (currently `1715770351` = 2024-05-15 20:52:31 UTC).
|
||||
- `getCurrentTime()` returns `base_time + millis()/1000`.
|
||||
- On reboot the value snaps back to the constant.
|
||||
- User must manually sync via companion app (`set time` CLI invokes `setCurrentTime(...)`) to set a real wall-clock time, which then ticks until the next reboot.
|
||||
|
||||
2. **Hardware-RTC nodes** (`firmware/src/helpers/AutoDiscoverRTCClock.cpp` — DS3231 / RV3028 / PCF8563):
|
||||
- Real-time chip with battery backup. Holds the time across reboots.
|
||||
- Behaves correctly once set; no default-snap behavior.
|
||||
|
||||
The `set time RESET` CLI command (`firmware/src/helpers/CommonCLI.cpp:215`) explicitly calls `setCurrentTime(1715770351)` regardless of hardware — so even hardware-RTC nodes can be deliberately reset to the default epoch.
|
||||
|
||||
**Therefore every node is in exactly one of these states:**
|
||||
|
||||
| State | Description |
|
||||
|---|---|
|
||||
| **Default / never set** | RTC is at a firmware-default epoch + ticking up since the last boot. |
|
||||
| **Set, drifting normally** | RTC was synced; small skew accumulating at ~0.8s/day per #789 reports. |
|
||||
| **Set, drifted past tolerance** | Like above but skew has grown beyond what's useful. |
|
||||
| **Wrong** | Operator-set incorrect time, or genuine RTC malfunction not matching any known default. |
|
||||
|
||||
There is no "bimodal RTC bug" — what looked bimodal in #845 is just a sequence of `defaulted → user sync → reboot → defaulted again`. The "bad" timestamps are not noise; they're a constant (the default epoch + a small uptime).
|
||||
|
||||
## Production data analysis (2026-04-24)
|
||||
|
||||
### 00id.net (this deployment, 416 nodes, commit `abd9c46`)
|
||||
|
||||
`lastSkewSec` (advert_ts − observed_ts) distribution:
|
||||
|
||||
| Bucket | Count | Pct |
|
||||
|---|---:|---:|
|
||||
| OK ≤15s | 90 | 22% |
|
||||
| Degrading ≤60s | 93 | 22% |
|
||||
| Degraded ≤10m | 13 | 3% |
|
||||
| off ≤1d | 5 | 1% |
|
||||
| off ≤1y | 110 | 26% |
|
||||
| absurd >1y | 105 | 25% |
|
||||
|
||||
Per-node `lastAdvertTS` raw timestamp distribution shows a sharp default cluster:
|
||||
|
||||
```
|
||||
+0 days count=19 samples=114969 ← exactly at 1715770351 (just rebooted)
|
||||
+1d count=9 samples=24766
|
||||
+2d count=7 samples=58101
|
||||
+3d count=2 samples=360
|
||||
... ← decay through ~110 days
|
||||
+113d count=2 samples=53776
|
||||
```
|
||||
|
||||
103 of 416 nodes (25%) have `lastAdvertTS` between `1715770351` and `1715770351 + 1095 days`, consistent with the volatile-RTC-default-ticking-up pattern.
|
||||
|
||||
A second cluster of 5 nodes has `lastAdvertTS = 1672531542 ≈ 1672531200 + 5min` = **2023-01-01 00:00:00 UTC** + small uptime. This is a *different* firmware-default epoch from an older firmware version.
|
||||
|
||||
### Cascadia (analyzer.cascadiamesh.org, 433 nodes in 5000-packet sample, commit `111b03c` v3.5.0)
|
||||
|
||||
ADVERT timestamp by year-month:
|
||||
|
||||
```
|
||||
1970-01 1 ← epoch zero (ESP32 native fallback OR ancient firmware)
|
||||
2021-01 1 ← possible third default epoch
|
||||
2023-01 2 ← old firmware default (matches 00id)
|
||||
2024-05 60 ← current VolatileRTCClock + days uptime
|
||||
2024-06 39 ← same default + weeks uptime
|
||||
2024-07 21
|
||||
2024-08 10
|
||||
2024-09 2
|
||||
2024-10 1
|
||||
2024-11 2 ← decays out as fewer nodes have multi-month uptime since reboot
|
||||
2025-10 1 ← pre-current-now miscellany
|
||||
2025-11 2
|
||||
2026-03 4
|
||||
2026-04 285 ← currently set clocks (this is "now-ish")
|
||||
2027-04 1 ← operator set wrong by ~1 year (typo?)
|
||||
2067-12 1 ← operator set wildly wrong / corrupted RTC
|
||||
```
|
||||
|
||||
Confirms the model: ~67% of nodes have a current clock, ~32% are at known firmware defaults at varying uptime offsets, ~3 outliers represent genuine misconfigurations.
|
||||
|
||||
## Known firmware default epochs
|
||||
|
||||
These are the values discovered in production data so far:
|
||||
|
||||
| Epoch (unix) | UTC | Source |
|
||||
|---:|---|---|
|
||||
| `0` | 1970-01-01 | Likely ESP32 boot when no RTC initialization runs (`time(NULL)` returns 0). |
|
||||
| `1609459200` | 2021-01-01 | Speculation — single-sample evidence, validate as more data arrives. |
|
||||
| `1672531200` | 2023-01-01 | Older firmware `VolatileRTCClock::base_time` value. |
|
||||
| `1715770351` | 2024-05-15 20:52:31 | **Current** `VolatileRTCClock` constructor + `set time RESET` CLI. |
|
||||
|
||||
Treat the table as data, not fixed code. New firmware versions will introduce new defaults; expect to add to the list over time.
|
||||
|
||||
## Reconciliation with #690 — the four timestamps
|
||||
|
||||
#690 lists three timestamps; in practice there are four signals worth distinguishing:
|
||||
|
||||
| Signal | Source | Used for |
|
||||
|---|---|---|
|
||||
| `advert_ts` | Inside MeshCore packet, set by sending node | Per-node classification (THE signal). |
|
||||
| `mqtt_envelope_ts` | Set by observer when it forwards via MQTT | Observer-side calibration only — *not* a direct node-skew signal because observer clock can itself be wrong. |
|
||||
| `corescope_received_ts` | Wall clock when CoreScope ingested the message | Reference "now"; calibration cross-check. |
|
||||
| `same_packet_across_observers` | Multiple observers seeing the same hash | Phase 2 calibration (triangulation). |
|
||||
|
||||
**Inputs flow:**
|
||||
|
||||
1. **Phase 2 (existing, kept):** for each packet hash seen by ≥2 observers, compute each observer's deviation from the per-packet median observed_ts → `observerOffset`. This is the triangulation #690 calls for ("Same packet observed by more than one (ideally 3+) observers gives good indication if one observer is off"). Observer offsets are the calibration table.
|
||||
2. **Per-advert correction (existing, kept):** `correctedSkew = (advert_ts - observed_ts) + observerOffset[observer_id]`. If no calibration exists for an observer, fall back to raw skew with `calibrated: false`.
|
||||
3. **Default detection (new):** runs on RAW `advert_ts`, not corrected. The firmware default is a fixed wall-clock value; observer offsets are seconds-to-minutes scale and cannot move `advert_ts` from 2024 to 2026. Default check is independent of calibration.
|
||||
4. **Severity classification (new):** if `is_default(advert_ts)` → `default`; else classify by `|correctedSkew|` band.
|
||||
|
||||
This keeps everything #690 asks for (observer detection, bias subtraction, triangulation), and adds the firmware-default cluster as a new pre-empting tier.
|
||||
|
||||
## UI: explain WHY (#690 requirement)
|
||||
|
||||
The classifier alone doesn't satisfy #690's "present on the UI why clock skew is obvious or suspected." The evidence panel from PR #906 (per-hash observer breakdown showing raw vs corrected skew per observer) is the WHY.
|
||||
|
||||
For each per-node clock card the UI must show:
|
||||
|
||||
- **Tier badge** (default / ok / degrading / degraded / wrong) + magnitude.
|
||||
- **Plain-English reason line**: e.g. "Last advert at 2024-05-15 + 3.2 days uptime — matches firmware default (volatile RTC, not yet user-set)" or "Last advert −12s vs wall clock — within OK tolerance."
|
||||
- **Calibration footnote**: "Skew corrected using observer X offset +1.7s (computed from 412 multi-observer packets)" or "Single-observer measurement, no calibration available."
|
||||
- **Evidence accordion** (PR #906 shape, retained): for the most recent N hashes, each observer's raw vs corrected skew + the observer's offset.
|
||||
|
||||
For the per-observer page (also from PR #906): show the observer's offset, the multi-observer sample count, and a tier badge using the same scale (treating `|observerOffset|` as the skew).
|
||||
|
||||
## Proposed classifier
|
||||
|
||||
Per-advert classification, no windowing:
|
||||
|
||||
```python
|
||||
DEFAULT_EPOCHS = [0, 1609459200, 1672531200, 1715770351]
|
||||
MAX_PLAUSIBLE_UPTIME_SEC = 1095 * 86400 # 3 years
|
||||
|
||||
def is_default(ts):
|
||||
return any(d <= ts <= d + MAX_PLAUSIBLE_UPTIME_SEC for d in DEFAULT_EPOCHS)
|
||||
|
||||
def classify(advert_ts, corrected_skew_sec):
|
||||
if is_default(advert_ts):
|
||||
return "default" # gray
|
||||
abs_skew = abs(corrected_skew_sec)
|
||||
if abs_skew <= 15: return "ok" # green
|
||||
if abs_skew <= 60: return "degrading" # yellow
|
||||
if abs_skew <= 600: return "degraded" # orange
|
||||
return "wrong" # red
|
||||
```
|
||||
|
||||
`corrected_skew_sec` is the observer-bias-subtracted skew per Phase 2 calibration. Default detection is independent of calibration (runs on raw `advert_ts`).
|
||||
|
||||
Per-node state = classification of the node's most-recent advert (per hash, picking the most recent observation across all observers). No medians, no good-fraction, no hysteresis.
|
||||
|
||||
## Severity tier definitions
|
||||
|
||||
| Tier | Condition | Color | UI label | Meaning |
|
||||
|---|---|---|---|---|
|
||||
| `default` | Advert ts within `[default, default + 3y]` of any known epoch | Gray | "Default" | Volatile RTC at firmware boot constant; never set or rebooted and not re-synced. |
|
||||
| `ok` | abs(skew) ≤ 15s | Green | "OK" | Working clock. |
|
||||
| `degrading` | 15s < abs(skew) ≤ 60s | Yellow | "Degrading" | Real but accumulating drift. |
|
||||
| `degraded` | 60s < abs(skew) ≤ 600s | Orange | "Degraded" | Off by minutes — needs re-sync. |
|
||||
| `wrong` | abs(skew) > 600s and not `default` | Red | "Wrong" | Operator-set error or RTC malfunction. |
|
||||
|
||||
## What this kills
|
||||
|
||||
- The 365-day `no_clock` threshold and the entire `recentSkewWindow{Count,Sec}` machinery.
|
||||
- The hysteresis / `goodFraction` / `longTermGoodFraction` logic from PR #894.
|
||||
- The proposed `bimodal_clock` tier from #845 — the pattern is not bimodal, it's defaulted vs set.
|
||||
- All Theil-Sen drift calculations as classifier inputs (drift remains a derived display value).
|
||||
|
||||
## What this preserves
|
||||
|
||||
- **Phase 2 observer calibration** (`calibrateObservers()`) — kept verbatim. It's what powers the "subtract observer bias" requirement from #690 and provides the triangulation evidence the UI needs.
|
||||
- **Drift display** (computed but not classifying).
|
||||
- **PR #906 evidence UI** — orthogonal to the classifier; it is in fact the implementation of #690's "explain WHY" requirement. Only label strings change to match the new tier names.
|
||||
- **`/api/observers/clock-skew`** — unchanged shape.
|
||||
|
||||
## API impact
|
||||
|
||||
`/api/nodes/{pubkey}/clock-skew` response changes:
|
||||
|
||||
- `severity` enum: `default | ok | degrading | degraded | wrong` (no more `no_clock | severe | warn | absurd`).
|
||||
- New field `defaultEpoch` (int, optional): if `severity == "default"`, the matched epoch.
|
||||
- Drop fields: `recentMedianSkewSec`, `goodFraction`, `recentBadSampleCount`, `longTermGoodFraction`.
|
||||
- Keep: `lastSkewSec`, `medianSkewSec`, `meanSkewSec`, `driftPerDaySec`, `sampleCount`, `calibrated`, `lastAdvertTS`, `lastObservedTS`, `nodeName`, `nodeRole`.
|
||||
|
||||
`/api/nodes/clock-skew` (fleet) shape unchanged except severity enum values.
|
||||
|
||||
## UI impact
|
||||
|
||||
- New CSS classes `skew-badge--default`, `skew-badge--degrading`, `skew-badge--degraded`, `skew-badge--wrong`. Drop `--no_clock`, `--severe`, `--warn`, `--absurd`, `--bimodal_clock`.
|
||||
- Tooltip text updated per tier.
|
||||
- "Default" badge tooltip should explain the clock is at firmware default plus uptime since boot, and the operator hasn't set it yet (or hasn't re-set it since the last reboot).
|
||||
|
||||
## Migration
|
||||
|
||||
Single PR replaces the classifier in `clock_skew.go` and updates the frontend badges/labels. No database schema change, no data migration — all per-call computation.
|
||||
|
||||
## Open issues to close
|
||||
|
||||
- **#789** (median hides corrected clocks) — resolved by per-advert classification.
|
||||
- **#845** (bimodal_clock tier) — replaced by `default` tier; the pattern that motivated it is correctly captured.
|
||||
- **PR #894** — close without merging; this design supersedes Option C entirely.
|
||||
- **#690** UI completion (PR #906) — keeps moving in parallel; only label updates needed.
|
||||
|
||||
## Validation plan
|
||||
|
||||
1. Hand-run the classifier against a snapshot of `/api/nodes/clock-skew` from 00id and cascadia. Confirm:
|
||||
- All 103 00id "absurd" nodes reclassify as `default`.
|
||||
- All 5 cascadia 2023-01 nodes reclassify as `default`.
|
||||
- The 2027 / 2067 cascadia outliers reclassify as `wrong`.
|
||||
- The 285 cascadia 2026-04 nodes reclassify as `ok` (or `degrading` if drift exceeds 15s).
|
||||
2. Add per-tier unit tests in `cmd/server/clock_skew_test.go`.
|
||||
3. Add a regression test for each known default epoch (synthesize advert at `default + 0s`, `default + 1d`, `default + 3y - 1s` → all classify as `default`).
|
||||
4. Edge cases:
|
||||
- `advert_ts == 0` → matches default epoch 0.
|
||||
- `advert_ts == 1715770351 + 731 days` → no longer matches (uptime cap exceeded) — should fall through to time-based classification, likely `wrong`.
|
||||
- Future timestamps beyond `now + 600s` → `wrong`.
|
||||
|
||||
## Out of scope (follow-ups)
|
||||
|
||||
- Per-firmware-version known-default lookup (when `firmware_version` field becomes reliable on adverts).
|
||||
- Reboot-count / flakiness indicator ("this node has hit default N times in last 30d").
|
||||
- Auto-discovery of new default epochs from clustering analysis (could detect a 4th default emerging in the wild).
|
||||
- Filtering defaulted-clock adverts out of time-windowed analytics queries (separate spec — affects path attribution).
|
||||
@@ -98,22 +98,6 @@ How long (in hours) before a node is marked degraded or silent:
|
||||
| `retention.nodeDays` | `7` | Nodes not seen in N days move to inactive |
|
||||
| `retention.packetDays` | `30` | Packets older than N days are deleted daily |
|
||||
|
||||
> **Note:** Lowering retention does **not** immediately shrink the database file.
|
||||
> SQLite marks deleted pages as free but does not return them to the filesystem
|
||||
> unless [incremental auto-vacuum](database.md) is enabled. New databases created
|
||||
> after v0.x.x have auto-vacuum enabled automatically. Existing databases require
|
||||
> a one-time migration — see the [Database](database.md) guide.
|
||||
|
||||
## Database
|
||||
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `db.vacuumOnStartup` | `false` | Run a one-time full `VACUUM` on startup to enable incremental auto-vacuum (blocks for minutes on large DBs) |
|
||||
| `db.incrementalVacuumPages` | `1024` | Free pages returned to the OS after each retention reaper cycle |
|
||||
|
||||
See [Database](database.md) for details on SQLite auto-vacuum, WAL, and manual maintenance.
|
||||
See [#919](https://github.com/Kpa-clawbot/CoreScope/issues/919) for background.
|
||||
|
||||
## Channel decryption
|
||||
|
||||
| Field | Description |
|
||||
@@ -166,9 +150,6 @@ Lower values = fresher data but more server load.
|
||||
|-------|---------|-------------|
|
||||
| `packetStore.maxMemoryMB` | `1024` | Maximum RAM for in-memory packet store |
|
||||
| `packetStore.estimatedPacketBytes` | `450` | Estimated bytes per packet (for memory budgeting) |
|
||||
| `packetStore.retentionHours` | `0` | Only load packets younger than N hours on startup and keep them in memory. **Set this on any instance with a large DB.** `0` = unlimited (loads full DB history — causes OOM on cold start when the DB has hundreds of thousands of paths). Recommended: same as `retention.packetDays × 24` (e.g. `168` for 7 days). |
|
||||
|
||||
> **Warning:** Leaving `retentionHours` at `0` on a large database will cause the server to OOM-kill itself on every cold start. The full packet history is loaded into the subpath index at startup; a DB with ~280K paths produces ~13M index entries before the process is killed.
|
||||
|
||||
## Timestamps
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# Database
|
||||
|
||||
CoreScope uses SQLite in WAL (Write-Ahead Log) mode for both the server
|
||||
(read-only) and ingestor (read-write).
|
||||
|
||||
## WAL mode
|
||||
|
||||
WAL mode allows concurrent reads while writes happen. It is set automatically
|
||||
at connection time via `PRAGMA journal_mode=WAL`. No operator action needed.
|
||||
|
||||
The WAL file (`meshcore.db-wal`) grows during writes and is checkpointed
|
||||
(merged back into the main DB) periodically and at clean shutdown.
|
||||
|
||||
## Auto-vacuum
|
||||
|
||||
By default, SQLite does not shrink the database file after `DELETE` operations.
|
||||
Deleted pages are marked free and reused by future writes, but the file size
|
||||
on disk stays the same. This is surprising when lowering retention settings.
|
||||
|
||||
### New databases
|
||||
|
||||
Databases created after this feature was added automatically have
|
||||
`PRAGMA auto_vacuum = INCREMENTAL`. After each retention reaper cycle,
|
||||
CoreScope runs `PRAGMA incremental_vacuum(N)` to return free pages to the OS.
|
||||
|
||||
### Existing databases
|
||||
|
||||
The `auto_vacuum` mode is stored in the database header and can only be changed
|
||||
by rewriting the entire file with `VACUUM`. CoreScope will **not** do this
|
||||
automatically — on large databases (5+ GB seen in the wild) it takes minutes
|
||||
and holds an exclusive lock.
|
||||
|
||||
**To migrate an existing database:**
|
||||
|
||||
1. At startup, CoreScope logs a warning:
|
||||
```
|
||||
[db] auto_vacuum=NONE — DB needs one-time VACUUM to enable incremental auto-vacuum.
|
||||
```
|
||||
2. **Ensure at least 2× the database file size in free disk space.** Full VACUUM
|
||||
creates a temporary copy of the entire file — on a near-full disk it will fail.
|
||||
3. Set `db.vacuumOnStartup: true` in your `config.json`:
|
||||
```json
|
||||
{
|
||||
"db": {
|
||||
"vacuumOnStartup": true
|
||||
}
|
||||
}
|
||||
```
|
||||
4. Restart CoreScope. The one-time `VACUUM` will run and block startup.
|
||||
5. After migration, remove or set `vacuumOnStartup: false` — it's not needed again.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `db.vacuumOnStartup` | `false` | One-time full VACUUM to enable incremental auto-vacuum |
|
||||
| `db.incrementalVacuumPages` | `1024` | Pages returned to OS per reaper cycle |
|
||||
|
||||
## Manual VACUUM
|
||||
|
||||
You can also run a manual vacuum from the SQLite CLI:
|
||||
|
||||
```bash
|
||||
sqlite3 data/meshcore.db "PRAGMA auto_vacuum = INCREMENTAL; VACUUM;"
|
||||
```
|
||||
|
||||
This is equivalent to `vacuumOnStartup: true` but can be done offline.
|
||||
|
||||
> ⚠️ Full VACUUM requires **2× the database file size** in free disk space (it
|
||||
> creates a temporary copy). Check with `ls -lh data/meshcore.db` before running.
|
||||
|
||||
## Checking current mode
|
||||
|
||||
```bash
|
||||
sqlite3 data/meshcore.db "PRAGMA auto_vacuum;"
|
||||
```
|
||||
|
||||
- `0` = NONE (default for old databases)
|
||||
- `1` = FULL (automatic, but slower writes)
|
||||
- `2` = INCREMENTAL (recommended — CoreScope triggers vacuum after deletes)
|
||||
|
||||
See [#919](https://github.com/Kpa-clawbot/CoreScope/issues/919) for background on this feature.
|
||||
+5
-5
@@ -3495,12 +3495,12 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
});
|
||||
|
||||
// Summary
|
||||
var counts = { ok: 0, degrading: 0, degraded: 0, wrong: 0, default: 0 };
|
||||
var counts = { ok: 0, warning: 0, critical: 0, absurd: 0 };
|
||||
data.forEach(function(n) { if (counts[n.severity] !== undefined) counts[n.severity]++; });
|
||||
|
||||
// Filter buttons (also serve as summary — no separate stats pills needed)
|
||||
var filterColors = { ok: 'var(--status-green)', degrading: 'var(--status-yellow)', degraded: 'var(--status-orange)', wrong: 'var(--status-red)', default: 'var(--text-muted)' };
|
||||
var filters = ['all', 'ok', 'degrading', 'degraded', 'wrong', 'default'];
|
||||
var filterColors = { ok: 'var(--status-green)', warning: 'var(--status-yellow)', critical: 'var(--status-orange)', absurd: 'var(--status-purple)', no_clock: 'var(--text-muted)' };
|
||||
var filters = ['all', 'ok', 'warning', 'critical', 'absurd', 'no_clock'];
|
||||
var filterHtml = '<div style="margin-bottom:10px">' + filters.map(function(f) {
|
||||
var dot = f !== 'all' ? '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + filterColors[f] + ';margin-right:4px;vertical-align:middle"></span>' : '';
|
||||
return '<button class="clock-filter-btn' + (activeFilter === f ? ' active' : '') + '" data-filter="' + f + '">' +
|
||||
@@ -3513,8 +3513,8 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
var rowClass = 'clock-fleet-row--' + (n.severity || 'ok');
|
||||
var lastAdv = n.lastObservedTS ? new Date(n.lastObservedTS * 1000).toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC') : '—';
|
||||
var skewVal = window.currentSkewValue(n);
|
||||
var skewText = n.severity === 'default' ? 'Default' : formatSkew(skewVal);
|
||||
var driftText = n.severity === 'default' || !n.driftPerDaySec ? '–' : formatDrift(n.driftPerDaySec);
|
||||
var skewText = n.severity === 'no_clock' ? 'No Clock' : formatSkew(skewVal);
|
||||
var driftText = n.severity === 'no_clock' || !n.driftPerDaySec ? '–' : formatDrift(n.driftPerDaySec);
|
||||
return '<tr class="' + rowClass + '" data-pubkey="' + esc(n.pubkey) + '" style="cursor:pointer">' +
|
||||
'<td><strong>' + esc(n.nodeName || n.pubkey.slice(0, 12)) + '</strong></td>' +
|
||||
'<td style="font-family:var(--mono,monospace)">' + skewText + '</td>' +
|
||||
|
||||
+1
-40
@@ -505,21 +505,6 @@ const pages = {};
|
||||
|
||||
function registerPage(name, mod) { pages[name] = mod; }
|
||||
|
||||
// Tools landing page — shows sub-menu with Trace and Path Inspector (spec §2.8, M1 fix).
|
||||
registerPage('tools-landing', {
|
||||
init: function (container) {
|
||||
container.innerHTML =
|
||||
'<div class="tools-landing">' +
|
||||
'<h2>Tools</h2>' +
|
||||
'<div class="tools-menu">' +
|
||||
'<a href="#/tools/path-inspector" class="tools-card"><h3>🔍 Path Inspector</h3><p>Resolve prefix paths to candidate full-pubkey routes with confidence scoring.</p></a>' +
|
||||
'<a href="#/tools/trace/" class="tools-card"><h3>📡 Trace Viewer</h3><p>View detailed packet traces by hash.</p></a>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
},
|
||||
destroy: function () {}
|
||||
});
|
||||
|
||||
let currentPage = null;
|
||||
|
||||
function closeNav() {
|
||||
@@ -540,12 +525,6 @@ function closeMoreMenu() {
|
||||
function navigate() {
|
||||
closeNav();
|
||||
|
||||
// Backward-compat redirect: #/traces/<hash> → #/tools/trace/<hash> (issue #944).
|
||||
if (location.hash.startsWith('#/traces/')) {
|
||||
location.hash = location.hash.replace('#/traces/', '#/tools/trace/');
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = location.hash.replace('#/', '') || 'packets';
|
||||
const route = hash.split('?')[0];
|
||||
|
||||
@@ -573,27 +552,9 @@ function navigate() {
|
||||
basePage = 'observer-detail';
|
||||
}
|
||||
|
||||
// Tools sub-routing (issue #944): tools/trace/<hash>, tools/path-inspector
|
||||
if (basePage === 'tools') {
|
||||
if (routeParam && routeParam.startsWith('trace/')) {
|
||||
basePage = 'traces';
|
||||
routeParam = routeParam.substring(6); // strip "trace/"
|
||||
} else if (routeParam === 'path-inspector' || (routeParam && routeParam.startsWith('path-inspector'))) {
|
||||
basePage = 'path-inspector';
|
||||
routeParam = null;
|
||||
} else if (!routeParam) {
|
||||
// Default tools landing shows menu with both entries.
|
||||
basePage = 'tools-landing';
|
||||
}
|
||||
}
|
||||
// Also support old #/traces (no sub-path) → traces page.
|
||||
if (basePage === 'traces' && !routeParam) {
|
||||
basePage = 'traces';
|
||||
}
|
||||
|
||||
// Update nav active state
|
||||
document.querySelectorAll('.nav-link[data-route]').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.route === basePage || (el.dataset.route === 'tools' && (basePage === 'traces' || basePage === 'path-inspector' || basePage === 'tools-landing')));
|
||||
el.classList.toggle('active', el.dataset.route === basePage);
|
||||
});
|
||||
// Update "More" button to show active state if a low-priority page is selected
|
||||
var moreBtn = document.getElementById('navMoreBtn');
|
||||
|
||||
@@ -629,11 +629,7 @@
|
||||
}
|
||||
writeOverrides(delta);
|
||||
_runPipeline();
|
||||
// Skip re-render while the user is typing inside the panel — setting
|
||||
// innerHTML would destroy the focused input and collapse the mobile keyboard.
|
||||
if (!(_panelEl && _panelEl.contains(document.activeElement))) {
|
||||
_refreshPanel();
|
||||
}
|
||||
_refreshPanel();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
|
||||
@@ -87,8 +87,7 @@ let polygon = null;
|
||||
let closingLine = null;
|
||||
|
||||
function latLonPair(latlng) {
|
||||
const w = latlng.wrap();
|
||||
return [parseFloat(w.lat.toFixed(6)), parseFloat(w.lng.toFixed(6))];
|
||||
return [parseFloat(latlng.lat.toFixed(6)), parseFloat(latlng.lng.toFixed(6))];
|
||||
}
|
||||
|
||||
function render() {
|
||||
|
||||
+1
-2
@@ -50,7 +50,7 @@
|
||||
<a href="#/live" class="nav-link" data-route="live" data-priority="high">🔴 Live</a>
|
||||
<a href="#/channels" class="nav-link" data-route="channels">Channels</a>
|
||||
<a href="#/nodes" class="nav-link" data-route="nodes" data-priority="high">Nodes</a>
|
||||
<a href="#/tools" class="nav-link" data-route="tools">Tools</a>
|
||||
<a href="#/traces" class="nav-link" data-route="traces">Traces</a>
|
||||
<a href="#/observers" class="nav-link" data-route="observers">Observers</a>
|
||||
<a href="#/analytics" class="nav-link" data-route="analytics">Analytics</a>
|
||||
<a href="#/perf" class="nav-link" data-route="perf">⚡ Perf</a>
|
||||
@@ -105,7 +105,6 @@
|
||||
<script src="table-sort.js?v=__BUST__"></script>
|
||||
<script src="nodes.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="path-inspector.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
|
||||
+2
-144
@@ -102,21 +102,8 @@
|
||||
|
||||
async function init(container) {
|
||||
container.innerHTML = `
|
||||
<div id="map-wrap" style="position:relative;width:100%;height:100%;display:flex;">
|
||||
<div id="leaflet-map" style="flex:1 1 0%;height:100%;"></div>
|
||||
<div class="map-side-pane" id="mapSidePane">
|
||||
<div class="pane-toggle" id="mapPaneToggle" title="Path Inspector">◀</div>
|
||||
<div class="pane-content">
|
||||
<h3 style="margin:0 0 8px 0;font-size:14px;">Path Inspector</h3>
|
||||
<p style="font-size:11px;color:var(--text-muted);margin:0 0 8px 0;">Hex prefixes (1-3 bytes), comma or space separated.</p>
|
||||
<div style="display:flex;gap:4px;margin-bottom:8px;">
|
||||
<input type="text" id="mapPiInput" class="input" placeholder="2C,A1,F4" style="flex:1;">
|
||||
<button id="mapPiSubmit" class="btn btn-primary btn-sm">Go</button>
|
||||
</div>
|
||||
<div id="mapPiError" class="path-inspector-error"></div>
|
||||
<div id="mapPiResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="map-wrap" style="position:relative;width:100%;height:100%;">
|
||||
<div id="leaflet-map" style="width:100%;height:100%;"></div>
|
||||
<button class="map-controls-toggle" id="mapControlsToggle" aria-label="Toggle map controls" aria-expanded="true">⚙️</button>
|
||||
<div class="map-controls" id="mapControls" role="region" aria-label="Map controls">
|
||||
<h3>🗺️ Map Controls</h3>
|
||||
@@ -566,19 +553,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Check for pending path inspector route (cross-page navigation from Path Inspector).
|
||||
if (window._pendingPathInspectorRoute) {
|
||||
var pending = window._pendingPathInspectorRoute;
|
||||
delete window._pendingPathInspectorRoute;
|
||||
if (pending.path && pending.path.length > 0) {
|
||||
if (window.routeLayer) window.routeLayer.clearLayers();
|
||||
drawPacketRoute(pending.path.slice(1), pending.path[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up map side pane (Path Inspector embedded - spec §2.7).
|
||||
initMapSidePane();
|
||||
|
||||
// Don't fitBounds on initial load — respect the Bay Area default or saved view
|
||||
// Only fitBounds on subsequent data refreshes if user hasn't manually panned
|
||||
} catch (e) {
|
||||
@@ -1007,122 +981,6 @@
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
|
||||
}
|
||||
|
||||
// === Map Side Pane — Path Inspector (spec §2.7) ===
|
||||
function initMapSidePane() {
|
||||
var pane = document.getElementById('mapSidePane');
|
||||
var toggle = document.getElementById('mapPaneToggle');
|
||||
var input = document.getElementById('mapPiInput');
|
||||
var btn = document.getElementById('mapPiSubmit');
|
||||
if (!pane || !toggle) return;
|
||||
|
||||
toggle.addEventListener('click', function () {
|
||||
pane.classList.toggle('expanded');
|
||||
toggle.textContent = pane.classList.contains('expanded') ? '▶' : '◀';
|
||||
// Invalidate map size after transition.
|
||||
setTimeout(function () { if (map) map.invalidateSize(); }, 220);
|
||||
});
|
||||
|
||||
if (btn && input) {
|
||||
btn.addEventListener('click', function () { mapPiSubmit(input.value); });
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') mapPiSubmit(input.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-open if URL has prefixes param while on map.
|
||||
var params = new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
var prefixParam = params.get('prefixes');
|
||||
if (prefixParam && input) {
|
||||
pane.classList.add('expanded');
|
||||
toggle.textContent = '▶';
|
||||
input.value = prefixParam;
|
||||
setTimeout(function () { if (map) map.invalidateSize(); }, 220);
|
||||
mapPiSubmit(prefixParam);
|
||||
}
|
||||
}
|
||||
|
||||
function mapPiSubmit(raw) {
|
||||
var errDiv = document.getElementById('mapPiError');
|
||||
var resultsDiv = document.getElementById('mapPiResults');
|
||||
if (!errDiv || !resultsDiv) return;
|
||||
errDiv.textContent = '';
|
||||
resultsDiv.innerHTML = '';
|
||||
|
||||
// Reuse PathInspector validation if available.
|
||||
var prefixes = raw.trim().split(/[\s,]+/).filter(function (s) { return s.length > 0; }).map(function (s) { return s.toLowerCase(); });
|
||||
var err = (window.PathInspector && window.PathInspector.validatePrefixes) ? window.PathInspector.validatePrefixes(prefixes) : null;
|
||||
if (!err && prefixes.length === 0) err = 'Enter at least one prefix.';
|
||||
if (err) { errDiv.textContent = err; return; }
|
||||
|
||||
resultsDiv.innerHTML = '<p style="font-size:12px;">Loading...</p>';
|
||||
fetch('/api/paths/inspect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prefixes: prefixes })
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.status === 503) return r.json().then(function () { throw new Error('Service warming up, retry shortly.'); });
|
||||
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Request failed'); });
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) { renderMapPiResults(data, resultsDiv); })
|
||||
.catch(function (e) { resultsDiv.innerHTML = ''; errDiv.textContent = e.message; });
|
||||
}
|
||||
|
||||
function renderMapPiResults(data, div) {
|
||||
if (!data.candidates || data.candidates.length === 0) {
|
||||
div.innerHTML = '<p style="font-size:12px;color:var(--text-muted);">No candidates found.</p>';
|
||||
return;
|
||||
}
|
||||
var html = '<table class="path-inspector-table" style="font-size:11px;width:100%;"><thead><tr><th>#</th><th>Score</th><th>Path</th><th></th></tr></thead><tbody>';
|
||||
for (var i = 0; i < data.candidates.length; i++) {
|
||||
var c = data.candidates[i];
|
||||
var rowClass = c.speculative ? 'speculative-row' : '';
|
||||
html += '<tr class="' + rowClass + '">';
|
||||
html += '<td>' + (i + 1) + '</td>';
|
||||
html += '<td class="' + (c.speculative ? 'speculative-warning' : '') + '">' + c.score.toFixed(2) + (c.speculative ? ' ⚠' : '') + '</td>';
|
||||
html += '<td title="' + safeEsc(c.names.join(' → ')) + '">' + safeEsc(c.names.slice(0, 3).join('→')) + (c.names.length > 3 ? '…' : '') + '</td>';
|
||||
html += '<td><button class="btn btn-sm" data-idx="' + i + '" title="Show on Map">📍</button></td>';
|
||||
html += '</tr>';
|
||||
// Per-hop evidence (collapsed).
|
||||
html += '<tr class="evidence-row collapsed" data-evidence="' + i + '"><td colspan="4"><div class="evidence-detail" style="font-size:10px;">';
|
||||
if (c.evidence && c.evidence.perHop) {
|
||||
for (var j = 0; j < c.evidence.perHop.length; j++) {
|
||||
var h = c.evidence.perHop[j];
|
||||
html += '<div>Hop ' + (j+1) + ': ' + h.prefix + ' (×' + h.candidatesConsidered + ') w=' + h.edgeWeight.toFixed(2);
|
||||
if (h.alternatives && h.alternatives.length > 0) {
|
||||
html += ' <span style="color:var(--text-muted);">[+' + h.alternatives.length + ' alt]</span>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
html += '</div></td></tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
div.innerHTML = html;
|
||||
|
||||
// Wire buttons.
|
||||
div.querySelectorAll('button[data-idx]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var idx = parseInt(btn.dataset.idx);
|
||||
var cand = data.candidates[idx];
|
||||
if (routeLayer) routeLayer.clearLayers();
|
||||
drawPacketRoute(cand.path.slice(1), cand.path[0]);
|
||||
});
|
||||
});
|
||||
// Expand evidence on row click.
|
||||
div.querySelectorAll('.path-inspector-table tbody tr:not(.evidence-row)').forEach(function (row) {
|
||||
row.style.cursor = 'pointer';
|
||||
row.addEventListener('click', function (e) {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
var b = row.querySelector('button[data-idx]');
|
||||
if (!b) return;
|
||||
var ev = div.querySelector('tr[data-evidence="' + b.dataset.idx + '"]');
|
||||
if (ev) ev.classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (wsHandler) offWS(wsHandler);
|
||||
wsHandler = null;
|
||||
|
||||
+9
-23
@@ -808,7 +808,7 @@
|
||||
let _themeRefreshHandler = null;
|
||||
|
||||
let _allNodes = null; // cached full node list
|
||||
let _fleetSkew = null; // cached clock skew map: pubkey → {severity, medianSkewSec, ...}
|
||||
let _fleetSkew = null; // cached clock skew map: pubkey → {severity, recentMedianSkewSec, medianSkewSec, ...}
|
||||
|
||||
/**
|
||||
* Fetch per-node clock skew and render into the given container element.
|
||||
@@ -824,28 +824,14 @@
|
||||
var driftHtml = cs.driftPerDaySec ? '<div style="font-size:12px;color:var(--text-muted);margin-top:2px">Drift: ' + formatDrift(cs.driftPerDaySec) + '</div>' : '';
|
||||
var sparkHtml = renderSkewSparkline(cs.samples, 200, 32);
|
||||
var skewVal = window.currentSkewValue(cs);
|
||||
var skewDisplay = cs.severity === 'default'
|
||||
? '<span style="font-size:18px;font-weight:700;color:var(--text-muted)">Default</span>'
|
||||
var skewDisplay = cs.severity === 'no_clock'
|
||||
? '<span style="font-size:18px;font-weight:700;color:var(--text-muted)">No Clock</span>'
|
||||
: '<span style="font-size:18px;font-weight:700;font-family:var(--mono)">' + formatSkew(skewVal) + '</span>';
|
||||
|
||||
// Per-tier explainer line (plain English reason).
|
||||
var explainer = '';
|
||||
var absSkew = Math.abs(cs.lastSkewSec || 0);
|
||||
var skewStr = Math.round(absSkew) + 's';
|
||||
if (cs.severity === 'default') {
|
||||
var isoAdv = cs.lastAdvertTS ? new Date(cs.lastAdvertTS * 1000).toISOString() : '?';
|
||||
explainer = 'Last advert at ' + isoAdv + ' — matches firmware default (volatile RTC, not user-set since boot)';
|
||||
} else if (cs.severity === 'ok') {
|
||||
explainer = 'Last advert ' + skewStr + ' vs wall clock — within OK tolerance (≤15s)';
|
||||
} else if (cs.severity === 'degrading') {
|
||||
explainer = 'Last advert ' + skewStr + ' vs wall clock — drift accumulating (≤60s)';
|
||||
} else if (cs.severity === 'degraded') {
|
||||
explainer = 'Last advert ' + skewStr + ' vs wall clock — significantly off (≤10m)';
|
||||
} else if (cs.severity === 'wrong') {
|
||||
explainer = 'Last advert ' + skewStr + ' vs wall clock — clock incorrect (operator-set or RTC failure)';
|
||||
var bimodalWarning = '';
|
||||
if (cs.severity === 'bimodal_clock') {
|
||||
var totalRecent = cs.recentSampleCount || 0;
|
||||
bimodalWarning = '<div style="font-size:12px;color:var(--status-amber-text);margin-top:4px">⚠️ ' + (cs.recentBadSampleCount || '?') + ' of last ' + (totalRecent || '?') + ' adverts had nonsense timestamps (likely RTC reset)</div>';
|
||||
}
|
||||
var explainerHtml = explainer ? '<div style="font-size:12px;color:var(--text-muted);margin-top:4px">' + explainer + '</div>' : '';
|
||||
|
||||
container.innerHTML =
|
||||
'<h4 style="margin:0 0 6px">⏰ Clock Skew</h4>' +
|
||||
'<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">' +
|
||||
@@ -853,9 +839,9 @@
|
||||
renderSkewBadge(cs.severity, skewVal, cs) +
|
||||
(cs.calibrated ? ' <span style="font-size:10px;color:var(--text-muted)" title="Observer-calibrated">✓ calibrated</span>' : '') +
|
||||
'</div>' +
|
||||
explainerHtml +
|
||||
driftHtml +
|
||||
(sparkHtml ? '<div class="skew-sparkline-wrap" style="margin-top:8px">' + sparkHtml + '<div style="font-size:10px;color:var(--text-muted)">Skew over time (' + (cs.samples || []).length + ' samples)</div></div>' : '');
|
||||
(sparkHtml ? '<div class="skew-sparkline-wrap" style="margin-top:8px">' + sparkHtml + '<div style="font-size:10px;color:var(--text-muted)">Skew over time (' + (cs.samples || []).length + ' samples)</div></div>' : '') +
|
||||
bimodalWarning;
|
||||
} catch (e) {
|
||||
// Non-fatal — section stays hidden
|
||||
}
|
||||
|
||||
+21
-56
@@ -468,9 +468,6 @@
|
||||
|
||||
// Check if new packets pass current filters
|
||||
const filtered = newPkts.filter(p => {
|
||||
// When user pinned a hash, accept ONLY that exact packet — bypass all
|
||||
// other filters (window/region/type/observer/node).
|
||||
if (filters.hash) return p.hash === filters.hash;
|
||||
// Respect time window filter — drop packets outside the selected window
|
||||
const windowMin = savedTimeWindowMin;
|
||||
if (windowMin > 0) {
|
||||
@@ -480,6 +477,7 @@
|
||||
}
|
||||
if (filters.type) { const types = filters.type.split(',').map(Number); if (!types.includes(p.payload_type)) return false; }
|
||||
if (filters.observer) { const obsSet = new Set(filters.observer.split(',')); if (!obsSet.has(p.observer_id) && !(p._children && p._children.some(c => obsSet.has(String(c.observer_id))))) return false; }
|
||||
if (filters.hash && p.hash !== filters.hash) return false;
|
||||
if (RegionFilter.getRegionParam()) {
|
||||
const selectedRegions = RegionFilter.getRegionParam().split(',');
|
||||
const obs = observerMap.get(p.observer_id);
|
||||
@@ -612,52 +610,27 @@
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Build URLSearchParams for /api/packets given UI state. Pure function for
|
||||
// testability — returns the params object the next call to /api/packets
|
||||
// would use. The hash filter is an exact identifier: when present it
|
||||
// suppresses ALL other filters (region, time window, observer, node,
|
||||
// channel). The user is asking for THAT packet regardless of saved
|
||||
// selections.
|
||||
function buildPacketsParams({ filters, regionParam, windowMin, groupByHash, limit }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.hash) {
|
||||
params.set('hash', filters.hash);
|
||||
params.set('limit', String(limit));
|
||||
async function loadPackets() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
const selectedWindow = Number(document.getElementById('fTimeWindow')?.value);
|
||||
const windowMin = Number.isFinite(selectedWindow) ? selectedWindow : savedTimeWindowMin;
|
||||
if (windowMin > 0 && !filters.hash) {
|
||||
const since = new Date(Date.now() - windowMin * 60000).toISOString();
|
||||
params.set('since', since);
|
||||
}
|
||||
params.set('limit', String(PACKET_LIMIT));
|
||||
const regionParam = RegionFilter.getRegionParam();
|
||||
if (regionParam) params.set('region', regionParam);
|
||||
if (filters.hash) params.set('hash', filters.hash);
|
||||
if (filters.node) params.set('node', filters.node);
|
||||
if (filters.observer) params.set('observer', filters.observer);
|
||||
if (filters.channel) params.set('channel', filters.channel);
|
||||
if (groupByHash) {
|
||||
params.set('groupByHash', 'true');
|
||||
} else {
|
||||
params.set('expand', 'observations');
|
||||
}
|
||||
return params;
|
||||
}
|
||||
if (windowMin > 0) {
|
||||
const since = new Date(Date.now() - windowMin * 60000).toISOString();
|
||||
params.set('since', since);
|
||||
}
|
||||
params.set('limit', String(limit));
|
||||
if (regionParam) params.set('region', regionParam);
|
||||
if (filters.node) params.set('node', filters.node);
|
||||
if (filters.observer) params.set('observer', filters.observer);
|
||||
if (filters.channel) params.set('channel', filters.channel);
|
||||
if (groupByHash) {
|
||||
params.set('groupByHash', 'true');
|
||||
} else {
|
||||
params.set('expand', 'observations');
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
async function loadPackets() {
|
||||
try {
|
||||
const selectedWindow = Number(document.getElementById('fTimeWindow')?.value);
|
||||
const windowMin = Number.isFinite(selectedWindow) ? selectedWindow : savedTimeWindowMin;
|
||||
const params = buildPacketsParams({
|
||||
filters,
|
||||
regionParam: RegionFilter.getRegionParam(),
|
||||
windowMin,
|
||||
groupByHash,
|
||||
limit: PACKET_LIMIT,
|
||||
});
|
||||
|
||||
const data = await api('/packets?' + params.toString());
|
||||
packets = data.packets || [];
|
||||
@@ -1674,14 +1647,7 @@
|
||||
|
||||
// Filter to claimed/favorited nodes — pure client-side filter (no server round-trip)
|
||||
let displayPackets = packets;
|
||||
|
||||
// When loading a specific packet by hash, bypass ALL client-side filters
|
||||
// (myNodes, type, observer, packet-filter-expression). The user is asking
|
||||
// for THAT exact packet — saved type/observer/expression filters must not
|
||||
// hide it. Hash filter is the exact identifier; nothing else applies.
|
||||
const hashOnly = !!filters.hash;
|
||||
|
||||
if (!hashOnly && filters.myNodes) {
|
||||
if (filters.myNodes) {
|
||||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||||
const myKeys = myNodes.map(n => n.pubkey).filter(Boolean);
|
||||
const favs = getFavorites();
|
||||
@@ -1697,11 +1663,11 @@
|
||||
}
|
||||
|
||||
// Client-side type/observer filtering
|
||||
if (!hashOnly && filters.type) {
|
||||
if (filters.type) {
|
||||
const types = filters.type.split(',').map(Number);
|
||||
displayPackets = displayPackets.filter(p => types.includes(p.payload_type));
|
||||
}
|
||||
if (!hashOnly && filters.observer) {
|
||||
if (filters.observer) {
|
||||
const obsIds = new Set(filters.observer.split(','));
|
||||
displayPackets = displayPackets.filter(p => {
|
||||
if (obsIds.has(p.observer_id)) return true;
|
||||
@@ -1712,7 +1678,7 @@
|
||||
|
||||
// Packet Filter Language
|
||||
const pfCount = document.getElementById('packetFilterCount');
|
||||
if (!hashOnly && filters._packetFilter) {
|
||||
if (filters._packetFilter) {
|
||||
const beforeCount = displayPackets.length;
|
||||
displayPackets = displayPackets.filter(filters._packetFilter);
|
||||
if (pfCount) {
|
||||
@@ -2597,7 +2563,6 @@
|
||||
buildGroupRowHtml,
|
||||
buildFlatRowHtml,
|
||||
_calcVisibleRange,
|
||||
buildPacketsParams,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
// Path Inspector — prefix candidate scoring with map overlay (issue #944).
|
||||
// IIFE; exports window.PathInspector for testability.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var container = null;
|
||||
var currentResults = null;
|
||||
|
||||
function init(app) {
|
||||
container = app;
|
||||
var params = new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
var prefixParam = params.get('prefixes') || '';
|
||||
|
||||
container.innerHTML =
|
||||
'<div class="path-inspector-page">' +
|
||||
'<h2>Path Inspector</h2>' +
|
||||
'<p class="help-text">Enter comma or space-separated hex prefixes (1-3 bytes each, e.g. <code>2C,A1,F4</code> or <code>2C A1 F4</code>).</p>' +
|
||||
'<div class="path-inspector-input-row">' +
|
||||
'<input type="text" id="path-inspector-input" class="input" placeholder="2C,A1,F4 or 2C A1 F4" value="' + escapeAttr(prefixParam) + '">' +
|
||||
'<button id="path-inspector-submit" class="btn btn-primary">Inspect</button>' +
|
||||
'</div>' +
|
||||
'<div id="path-inspector-error" class="path-inspector-error"></div>' +
|
||||
'<div id="path-inspector-results"></div>' +
|
||||
'</div>';
|
||||
|
||||
var input = document.getElementById('path-inspector-input');
|
||||
var btn = document.getElementById('path-inspector-submit');
|
||||
btn.addEventListener('click', function () { submit(input.value); });
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') submit(input.value);
|
||||
});
|
||||
|
||||
// Auto-run if prefixes in URL.
|
||||
if (prefixParam) submit(prefixParam);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
container = null;
|
||||
currentResults = null;
|
||||
}
|
||||
|
||||
function parsePrefixes(raw) {
|
||||
// Accept comma or space separated.
|
||||
var parts = raw.trim().split(/[\s,]+/).filter(function (s) { return s.length > 0; });
|
||||
return parts.map(function (p) { return p.toLowerCase(); });
|
||||
}
|
||||
|
||||
function validatePrefixes(prefixes) {
|
||||
if (prefixes.length === 0) return 'Enter at least one prefix.';
|
||||
if (prefixes.length > 64) return 'Too many prefixes (max 64).';
|
||||
var hexRe = /^[0-9a-f]+$/;
|
||||
var byteLen = -1;
|
||||
for (var i = 0; i < prefixes.length; i++) {
|
||||
var p = prefixes[i];
|
||||
if (!hexRe.test(p)) return 'Invalid hex: ' + p;
|
||||
if (p.length % 2 !== 0) return 'Odd-length prefix: ' + p;
|
||||
var bl = p.length / 2;
|
||||
if (bl > 3) return 'Prefix too long (max 3 bytes): ' + p;
|
||||
if (byteLen === -1) byteLen = bl;
|
||||
else if (bl !== byteLen) return 'Mixed prefix lengths not allowed.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function submit(raw) {
|
||||
var errDiv = document.getElementById('path-inspector-error');
|
||||
var resultsDiv = document.getElementById('path-inspector-results');
|
||||
errDiv.textContent = '';
|
||||
resultsDiv.innerHTML = '';
|
||||
|
||||
var prefixes = parsePrefixes(raw);
|
||||
var err = validatePrefixes(prefixes);
|
||||
if (err) {
|
||||
errDiv.textContent = err;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update URL.
|
||||
var base = '#/tools/path-inspector';
|
||||
if (location.hash.indexOf(base) === 0) {
|
||||
history.replaceState(null, '', base + '?prefixes=' + prefixes.join(','));
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = '<p>Loading...</p>';
|
||||
fetch('/api/paths/inspect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prefixes: prefixes })
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.status === 503) return r.json().then(function (d) { throw new Error('Service warming up, retry in a few seconds.'); });
|
||||
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Request failed'); });
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
currentResults = data;
|
||||
renderResults(data, resultsDiv);
|
||||
})
|
||||
.catch(function (e) {
|
||||
resultsDiv.innerHTML = '';
|
||||
errDiv.textContent = e.message;
|
||||
});
|
||||
}
|
||||
|
||||
function renderResults(data, div) {
|
||||
if (!data.candidates || data.candidates.length === 0) {
|
||||
div.innerHTML = '<p class="no-results">No candidates found. The prefixes may not match any known path-eligible nodes.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<table class="path-inspector-table"><thead><tr>' +
|
||||
'<th>#</th><th>Score</th><th>Path</th><th>Action</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
|
||||
for (var i = 0; i < data.candidates.length; i++) {
|
||||
var c = data.candidates[i];
|
||||
var rowClass = c.speculative ? 'speculative-row' : '';
|
||||
html += '<tr class="' + rowClass + '">';
|
||||
html += '<td>' + (i + 1) + '</td>';
|
||||
html += '<td class="' + (c.speculative ? 'speculative-warning' : '') + '">' +
|
||||
c.score.toFixed(3) +
|
||||
(c.speculative ? ' <span class="speculative-badge" title="Low evidence; may be wrong">⚠</span>' : '') +
|
||||
'</td>';
|
||||
html += '<td>' + escapeHtml(c.names.join(' → ')) + '</td>';
|
||||
html += '<td><button class="btn btn-sm" data-idx="' + i + '">Show on Map</button></td>';
|
||||
html += '</tr>';
|
||||
|
||||
// Per-hop evidence (collapsed).
|
||||
html += '<tr class="evidence-row collapsed" data-evidence="' + i + '"><td colspan="4"><div class="evidence-detail">';
|
||||
for (var j = 0; j < c.evidence.perHop.length; j++) {
|
||||
var h = c.evidence.perHop[j];
|
||||
html += '<div class="hop-evidence">Hop ' + (j + 1) + ': prefix=' + h.prefix +
|
||||
', candidates=' + h.candidatesConsidered +
|
||||
', edge=' + h.edgeWeight.toFixed(3);
|
||||
if (h.alternatives && h.alternatives.length > 0) {
|
||||
html += '<div class="hop-alternatives" style="margin-left:12px;font-size:12px;color:var(--text-muted);">';
|
||||
for (var k = 0; k < h.alternatives.length; k++) {
|
||||
var alt = h.alternatives[k];
|
||||
html += '<div>↳ ' + escapeHtml(alt.name || alt.publicKey.substring(0, 8)) + ' (score=' + alt.score.toFixed(3) + ')</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div></td></tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
html += '<div class="path-inspector-stats">Beam width: ' + data.stats.beamWidth +
|
||||
' | Expansions: ' + data.stats.expansionsRun +
|
||||
' | Elapsed: ' + data.stats.elapsedMs + 'ms</div>';
|
||||
|
||||
div.innerHTML = html;
|
||||
|
||||
// Wire up Show on Map buttons.
|
||||
div.querySelectorAll('button[data-idx]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var idx = parseInt(btn.dataset.idx);
|
||||
showOnMap(data.candidates[idx]);
|
||||
});
|
||||
});
|
||||
|
||||
// Wire up row expand for evidence.
|
||||
div.querySelectorAll('.path-inspector-table tbody tr:not(.evidence-row)').forEach(function (row) {
|
||||
row.style.cursor = 'pointer';
|
||||
row.addEventListener('click', function (e) {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
var idx = row.querySelector('button[data-idx]');
|
||||
if (!idx) return;
|
||||
var evidenceRow = div.querySelector('tr[data-evidence="' + idx.dataset.idx + '"]');
|
||||
if (evidenceRow) evidenceRow.classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showOnMap(candidate) {
|
||||
// Store pending route for map init to pick up.
|
||||
window._pendingPathInspectorRoute = candidate;
|
||||
// Switch to map page if not there; map init will draw the route.
|
||||
if (location.hash.indexOf('#/map') !== 0) {
|
||||
location.hash = '#/map';
|
||||
} else {
|
||||
// Already on map — draw directly.
|
||||
delete window._pendingPathInspectorRoute;
|
||||
if (window.routeLayer) window.routeLayer.clearLayers();
|
||||
var hops = candidate.path.slice(1);
|
||||
var origin = candidate.path[0] || null;
|
||||
if (window.drawPacketRoute) window.drawPacketRoute(hops, origin);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
window.PathInspector = { init: init, destroy: destroy, parsePrefixes: parsePrefixes, validatePrefixes: validatePrefixes };
|
||||
if (typeof registerPage === 'function') registerPage('path-inspector', { init: init, destroy: destroy });
|
||||
})();
|
||||
+19
-11
@@ -397,16 +397,17 @@
|
||||
|
||||
// #690 — Clock Skew shared helpers
|
||||
var SKEW_SEVERITY_COLORS = {
|
||||
default: 'var(--text-muted)',
|
||||
ok: 'var(--status-green)',
|
||||
degrading: 'var(--status-yellow)',
|
||||
degraded: 'var(--status-orange)',
|
||||
wrong: 'var(--status-red)'
|
||||
warning: 'var(--status-yellow)',
|
||||
critical: 'var(--status-orange)',
|
||||
absurd: 'var(--status-purple)',
|
||||
bimodal_clock: 'var(--status-amber)',
|
||||
no_clock: 'var(--text-muted)'
|
||||
};
|
||||
var SKEW_SEVERITY_LABELS = {
|
||||
default: 'Default', ok: 'OK', degrading: 'Degrading', degraded: 'Degraded', wrong: 'Wrong'
|
||||
ok: 'OK', warning: 'Warning', critical: 'Critical', absurd: 'Absurd', bimodal_clock: 'Bimodal', no_clock: 'No Clock'
|
||||
};
|
||||
var SKEW_SEVERITY_ORDER = { default: 0, wrong: 1, degraded: 2, degrading: 3, ok: 4 };
|
||||
var SKEW_SEVERITY_ORDER = { no_clock: 0, bimodal_clock: 1, absurd: 2, critical: 3, warning: 4, ok: 5 };
|
||||
|
||||
window.SKEW_SEVERITY_COLORS = SKEW_SEVERITY_COLORS;
|
||||
window.SKEW_SEVERITY_LABELS = SKEW_SEVERITY_LABELS;
|
||||
@@ -429,19 +430,26 @@
|
||||
return (secPerDay >= 0 ? '+' : '') + secPerDay.toFixed(1) + ' s/day';
|
||||
};
|
||||
|
||||
/** Pick the skew value that drives current-health UI. Uses lastSkewSec
|
||||
* (most recent corrected skew) when available, falls back to medianSkewSec. */
|
||||
/** Pick the skew value that drives current-health UI: prefer the
|
||||
* recent-window median (#789, current health) over the all-time median
|
||||
* (poisoned by historical bad samples). Falls back gracefully if the
|
||||
* field isn't present (older API responses). */
|
||||
window.currentSkewValue = function(cs) {
|
||||
if (!cs) return null;
|
||||
return cs.lastSkewSec != null ? cs.lastSkewSec : cs.medianSkewSec;
|
||||
return cs.recentMedianSkewSec != null ? cs.recentMedianSkewSec : cs.medianSkewSec;
|
||||
};
|
||||
|
||||
/** Render a clock skew badge HTML */
|
||||
window.renderSkewBadge = function(severity, skewSec, cs) {
|
||||
if (!severity) return '';
|
||||
var cls = 'skew-badge skew-badge--' + severity;
|
||||
if (severity === 'default') {
|
||||
return '<span class="' + cls + '" title="Firmware default clock — volatile RTC not yet user-set since boot">⏰ Default</span>';
|
||||
if (severity === 'no_clock') {
|
||||
return '<span class="' + cls + '" title="Uninitialized RTC — no valid clock">🚫 No Clock</span>';
|
||||
}
|
||||
if (severity === 'bimodal_clock' && cs) {
|
||||
var badPct = cs.goodFraction != null ? Math.round((1 - cs.goodFraction) * 100) : '?';
|
||||
var label = '⏰ ' + window.formatSkew(skewSec);
|
||||
return '<span class="' + cls + '" title="Clock skew: ' + window.formatSkew(skewSec) + ' (bimodal: ' + badPct + '% of recent adverts have nonsense timestamps)">' + label + '</span>';
|
||||
}
|
||||
var label = severity === 'ok' ? '⏰' : '⏰ ' + window.formatSkew(skewSec);
|
||||
return '<span class="' + cls + '" title="Clock skew: ' + window.formatSkew(skewSec) + ' (' + (SKEW_SEVERITY_LABELS[severity] || severity) + ')">' + label + '</span>';
|
||||
|
||||
+9
-44
@@ -16,7 +16,6 @@
|
||||
--status-amber: #f59e0b;
|
||||
--status-amber-light: #fef3c7;
|
||||
--status-amber-text: #92400e;
|
||||
--path-inspector-speculative: #d97706;
|
||||
--role-observer: #8b5cf6;
|
||||
--accent-hover: #6db3ff;
|
||||
--text: #1a1a2e;
|
||||
@@ -53,7 +52,6 @@
|
||||
--status-amber: #f59e0b;
|
||||
--status-amber-light: #422006;
|
||||
--status-amber-text: #fcd34d;
|
||||
--path-inspector-speculative: #f59e0b;
|
||||
--surface-0: #0f0f23;
|
||||
--surface-1: #1a1a2e;
|
||||
--surface-2: #232340;
|
||||
@@ -2293,55 +2291,22 @@ th.sort-active { color: var(--accent, #60a5fa); }
|
||||
|
||||
/* #690 — Clock Skew badges & fleet table */
|
||||
.skew-badge { display: inline-block; font-size: 10px; padding: 1px 5px; border-radius: 3px; margin-left: 4px; font-weight: 600; white-space: nowrap; }
|
||||
.skew-badge--default { background: var(--text-muted); color: #fff; }
|
||||
.skew-badge--ok { background: var(--status-green); color: #fff; }
|
||||
.skew-badge--degrading { background: var(--status-yellow); color: #000; }
|
||||
.skew-badge--degraded { background: var(--status-orange); color: #fff; }
|
||||
.skew-badge--wrong { background: var(--status-red); color: #fff; }
|
||||
.skew-badge--warning { background: var(--status-yellow); color: #000; }
|
||||
.skew-badge--critical { background: var(--status-orange); color: #fff; }
|
||||
.skew-badge--absurd { background: var(--status-purple); color: #fff; }
|
||||
.skew-badge--no_clock { background: var(--text-muted); color: #fff; }
|
||||
.skew-badge--bimodal_clock { background: var(--status-amber-light); color: var(--status-amber-text); border: 1px solid var(--status-amber); }
|
||||
|
||||
.skew-detail-section { padding: 10px 16px; margin-bottom: 8px; }
|
||||
.skew-sparkline-wrap { margin-top: 6px; }
|
||||
.skew-sparkline-wrap svg { display: block; }
|
||||
|
||||
|
||||
.clock-fleet-row--degrading { background: color-mix(in srgb, var(--status-yellow) 10%, transparent); }
|
||||
.clock-fleet-row--degraded { background: color-mix(in srgb, var(--status-orange) 10%, transparent); }
|
||||
.clock-fleet-row--wrong { background: color-mix(in srgb, var(--status-red) 10%, transparent); }
|
||||
.clock-fleet-row--default { background: color-mix(in srgb, var(--text-muted) 10%, transparent); }
|
||||
.clock-fleet-row--warning { background: color-mix(in srgb, var(--status-yellow) 10%, transparent); }
|
||||
.clock-fleet-row--critical { background: color-mix(in srgb, var(--status-orange) 10%, transparent); }
|
||||
.clock-fleet-row--absurd { background: color-mix(in srgb, var(--status-purple) 10%, transparent); }
|
||||
.clock-fleet-row--no_clock { background: color-mix(in srgb, var(--text-muted) 10%, transparent); }
|
||||
|
||||
.clock-filter-btn { font-size: 12px; padding: 3px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--card-bg, #fff); color: var(--text); cursor: pointer; margin-right: 4px; }
|
||||
.clock-filter-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
|
||||
/* === Path Inspector (issue #944) === */
|
||||
.path-inspector-page { padding: 16px; max-width: 900px; margin: 0 auto; }
|
||||
.path-inspector-input-row { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.path-inspector-input-row .input { flex: 1; }
|
||||
.path-inspector-error { color: var(--status-red, #ef4444); font-size: 13px; margin-bottom: 8px; }
|
||||
.path-inspector-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.path-inspector-table th,
|
||||
.path-inspector-table td { padding: 6px 10px; border-bottom: 1px solid var(--border); text-align: left; }
|
||||
.path-inspector-table th { background: var(--card-bg); font-weight: 600; }
|
||||
.speculative-warning { color: var(--path-inspector-speculative, #d97706); font-weight: 600; }
|
||||
.speculative-badge { cursor: help; }
|
||||
.speculative-row { background: color-mix(in srgb, var(--path-inspector-speculative, #d97706) 8%, transparent); }
|
||||
.evidence-row { font-size: 12px; color: var(--text-muted); }
|
||||
.evidence-row.collapsed { display: none; }
|
||||
.evidence-detail { padding: 4px 10px; }
|
||||
.hop-evidence { margin: 2px 0; }
|
||||
.path-inspector-stats { margin-top: 12px; font-size: 12px; color: var(--text-muted); }
|
||||
.no-results { color: var(--text-muted); font-style: italic; }
|
||||
|
||||
/* Map side pane for path inspector */
|
||||
.map-side-pane { flex: 0 0 32px; overflow: hidden; transition: flex-basis 0.2s; border-left: 1px solid var(--border); background: var(--card-bg); }
|
||||
.map-side-pane.expanded { flex: 0 0 320px; overflow-y: auto; padding: 12px; }
|
||||
.map-side-pane .pane-toggle { cursor: pointer; padding: 8px; font-size: 14px; text-align: center; }
|
||||
.map-side-pane .pane-content { display: none; }
|
||||
.map-side-pane.expanded .pane-content { display: block; }
|
||||
|
||||
/* Tools landing page */
|
||||
.tools-landing { padding: 24px; max-width: 600px; }
|
||||
.tools-menu { display: flex; flex-direction: column; gap: 12px; margin-top: 16px; }
|
||||
.tools-card { display: block; padding: 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--card-bg); color: var(--text); text-decoration: none; transition: border-color 0.2s; }
|
||||
.tools-card:hover { border-color: var(--primary); }
|
||||
.tools-card h3 { margin: 0 0 4px 0; font-size: 16px; }
|
||||
.tools-card p { margin: 0; font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
+111
@@ -59,7 +59,118 @@ test('null lastSeenMs → stale', () => assert.strictEqual(getNodeStatus('repeat
|
||||
test('undefined lastSeenMs → stale', () => assert.strictEqual(getNodeStatus('repeater', undefined), 'stale'));
|
||||
test('0 lastSeenMs → stale', () => assert.strictEqual(getNodeStatus('repeater', 0), 'stale'));
|
||||
|
||||
// === getStatusInfo tests (inline since nodes.js has too many DOM deps) ===
|
||||
console.log('\n=== getStatusInfo (logic validation) ===');
|
||||
|
||||
// Simulate getStatusInfo logic
|
||||
function mockGetStatusInfo(n) {
|
||||
const ROLE_COLORS = ctx.window.ROLE_COLORS;
|
||||
const role = (n.role || '').toLowerCase();
|
||||
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
|
||||
const lastHeardTime = n._lastHeard || n.last_heard || n.last_seen;
|
||||
const lastHeardMs = lastHeardTime ? new Date(lastHeardTime).getTime() : 0;
|
||||
const status = getNodeStatus(role, lastHeardMs);
|
||||
const statusLabel = status === 'active' ? '🟢 Active' : '⚪ Stale';
|
||||
const isInfra = role === 'repeater' || role === 'room';
|
||||
|
||||
let explanation = '';
|
||||
if (status === 'active') {
|
||||
explanation = 'Last heard recently';
|
||||
} else {
|
||||
const reason = isInfra
|
||||
? 'repeaters typically advertise every 12-24h'
|
||||
: 'companions only advertise when user initiates, this may be normal';
|
||||
explanation = 'Not heard — ' + reason;
|
||||
}
|
||||
return { status, statusLabel, roleColor, explanation, role };
|
||||
}
|
||||
|
||||
test('active repeater → 🟢 Active, red color', () => {
|
||||
const info = mockGetStatusInfo({ role: 'repeater', last_seen: new Date(now - 1*h).toISOString() });
|
||||
assert.strictEqual(info.status, 'active');
|
||||
assert.strictEqual(info.statusLabel, '🟢 Active');
|
||||
assert.strictEqual(info.roleColor, '#dc2626');
|
||||
});
|
||||
|
||||
test('stale companion → ⚪ Stale, explanation mentions "this may be normal"', () => {
|
||||
const info = mockGetStatusInfo({ role: 'companion', last_seen: new Date(now - 25*h).toISOString() });
|
||||
assert.strictEqual(info.status, 'stale');
|
||||
assert.strictEqual(info.statusLabel, '⚪ Stale');
|
||||
assert(info.explanation.includes('this may be normal'), 'should mention "this may be normal"');
|
||||
});
|
||||
|
||||
test('missing last_seen → stale', () => {
|
||||
const info = mockGetStatusInfo({ role: 'repeater' });
|
||||
assert.strictEqual(info.status, 'stale');
|
||||
});
|
||||
|
||||
test('missing role → defaults to empty string, uses node threshold', () => {
|
||||
const info = mockGetStatusInfo({ last_seen: new Date(now - 25*h).toISOString() });
|
||||
assert.strictEqual(info.status, 'stale');
|
||||
assert.strictEqual(info.roleColor, '#6b7280');
|
||||
});
|
||||
|
||||
test('prefers last_heard over last_seen', () => {
|
||||
// last_seen is stale, but last_heard is recent
|
||||
const info = mockGetStatusInfo({
|
||||
role: 'companion',
|
||||
last_seen: new Date(now - 48*h).toISOString(),
|
||||
last_heard: new Date(now - 1*h).toISOString()
|
||||
});
|
||||
assert.strictEqual(info.status, 'active');
|
||||
});
|
||||
|
||||
// === getStatusTooltip tests ===
|
||||
console.log('\n=== getStatusTooltip ===');
|
||||
|
||||
// Load from nodes.js by extracting the function
|
||||
// Since nodes.js is complex, I'll re-implement the tooltip function for testing
|
||||
function getStatusTooltip(role, status) {
|
||||
const isInfra = role === 'repeater' || role === 'room';
|
||||
const threshold = isInfra ? '72h' : '24h';
|
||||
if (status === 'active') {
|
||||
return 'Active — heard within the last ' + threshold + '.' + (isInfra ? ' Repeaters typically advertise every 12-24h.' : '');
|
||||
}
|
||||
if (role === 'companion') {
|
||||
return 'Stale — not heard for over ' + threshold + '. Companions only advertise when the user initiates — this may be normal.';
|
||||
}
|
||||
if (role === 'sensor') {
|
||||
return 'Stale — not heard for over ' + threshold + '. This sensor may be offline.';
|
||||
}
|
||||
return 'Stale — not heard for over ' + threshold + '. This ' + role + ' may be offline or out of range.';
|
||||
}
|
||||
|
||||
test('active repeater mentions "72h" and "advertise every 12-24h"', () => {
|
||||
const tip = getStatusTooltip('repeater', 'active');
|
||||
assert(tip.includes('72h'), 'should mention 72h');
|
||||
assert(tip.includes('advertise every 12-24h'), 'should mention advertise frequency');
|
||||
});
|
||||
|
||||
test('active companion mentions "24h"', () => {
|
||||
const tip = getStatusTooltip('companion', 'active');
|
||||
assert(tip.includes('24h'), 'should mention 24h');
|
||||
});
|
||||
|
||||
test('stale companion mentions "24h" and "user initiates"', () => {
|
||||
const tip = getStatusTooltip('companion', 'stale');
|
||||
assert(tip.includes('24h'), 'should mention 24h');
|
||||
assert(tip.includes('user initiates'), 'should mention user initiates');
|
||||
});
|
||||
|
||||
test('stale repeater mentions "offline or out of range"', () => {
|
||||
const tip = getStatusTooltip('repeater', 'stale');
|
||||
assert(tip.includes('offline or out of range'), 'should mention offline or out of range');
|
||||
});
|
||||
|
||||
test('stale sensor mentions "sensor may be offline"', () => {
|
||||
const tip = getStatusTooltip('sensor', 'stale');
|
||||
assert(tip.includes('sensor may be offline'));
|
||||
});
|
||||
|
||||
test('stale room uses 72h threshold', () => {
|
||||
const tip = getStatusTooltip('room', 'stale');
|
||||
assert(tip.includes('72h'));
|
||||
});
|
||||
|
||||
// === Bug check: renderRows uses last_seen instead of last_heard || last_seen ===
|
||||
console.log('\n=== BUG CHECK ===');
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* test-anim-perf.js — Performance benchmark for animation timer management
|
||||
*
|
||||
* Demonstrates that the rAF + concurrency-cap approach keeps active animation
|
||||
* count bounded, whereas the old setInterval approach accumulated without limit.
|
||||
*
|
||||
* Run: node test-anim-perf.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { console.log(` ✅ ${msg}`); passed++; }
|
||||
else { console.log(` ❌ ${msg}`); failed++; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulate OLD behaviour: setInterval-based, no concurrency cap
|
||||
// ---------------------------------------------------------------------------
|
||||
function simulateOldModel(packetsPerSec, hopsPerPacket, durationSec) {
|
||||
// Each hop spawns 3 intervals (pulse 26ms, line 33ms, fade 52ms).
|
||||
// Pulse lasts ~2s, line ~0.66s, fade ~0.8s+0.4s ≈ 1.2s
|
||||
// At any moment, timers from the last ~2s of packets are still alive.
|
||||
const intervalLifetimes = [2.0, 0.66, 1.2]; // seconds each interval lives
|
||||
let maxConcurrent = 0;
|
||||
// Walk through time in 0.1s steps
|
||||
const dt = 0.1;
|
||||
const spawns = []; // {time, lifetime}
|
||||
for (let t = 0; t < durationSec; t += dt) {
|
||||
// Spawn timers for packets arriving in this window
|
||||
const pktsInWindow = packetsPerSec * dt;
|
||||
for (let p = 0; p < pktsInWindow; p++) {
|
||||
for (let h = 0; h < hopsPerPacket; h++) {
|
||||
for (const lt of intervalLifetimes) {
|
||||
spawns.push({ time: t, lifetime: lt });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Count alive timers
|
||||
const alive = spawns.filter(s => t < s.time + s.lifetime).length;
|
||||
if (alive > maxConcurrent) maxConcurrent = alive;
|
||||
}
|
||||
return maxConcurrent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulate NEW behaviour: rAF + MAX_CONCURRENT_ANIMS cap
|
||||
// ---------------------------------------------------------------------------
|
||||
function simulateNewModel(packetsPerSec, hopsPerPacket, durationSec) {
|
||||
const MAX_CONCURRENT_ANIMS = 20;
|
||||
let activeAnims = 0;
|
||||
let maxConcurrent = 0;
|
||||
const anims = []; // {endTime}
|
||||
const dt = 0.1;
|
||||
for (let t = 0; t < durationSec; t += dt) {
|
||||
// Expire finished animations
|
||||
while (anims.length && anims[0].endTime <= t) {
|
||||
anims.shift();
|
||||
activeAnims--;
|
||||
}
|
||||
// Try to start new animations
|
||||
const pktsInWindow = packetsPerSec * dt;
|
||||
for (let p = 0; p < pktsInWindow; p++) {
|
||||
if (activeAnims >= MAX_CONCURRENT_ANIMS) break; // cap reached — drop
|
||||
activeAnims++;
|
||||
// rAF animation lifetime: longest is pulse ~2s
|
||||
anims.push({ endTime: t + 2.0 });
|
||||
}
|
||||
// Sort by endTime so expiry works
|
||||
anims.sort((a, b) => a.endTime - b.endTime);
|
||||
if (activeAnims > maxConcurrent) maxConcurrent = activeAnims;
|
||||
}
|
||||
return maxConcurrent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log('\n=== Animation timer accumulation: old vs new ===');
|
||||
|
||||
// Scenario: 5 pkts/sec, 3 hops each, 30 seconds
|
||||
const oldPeak30s = simulateOldModel(5, 3, 30);
|
||||
const newPeak30s = simulateNewModel(5, 3, 30);
|
||||
console.log(` Old model (30s @ 5pkt/s×3hops): peak ${oldPeak30s} concurrent timers`);
|
||||
console.log(` New model (30s @ 5pkt/s×3hops): peak ${newPeak30s} concurrent animations`);
|
||||
assert(oldPeak30s > 100, `old model accumulates >100 timers (got ${oldPeak30s})`);
|
||||
assert(newPeak30s <= 20, `new model stays ≤20 (got ${newPeak30s})`);
|
||||
|
||||
// Scenario: 5 minutes sustained
|
||||
const oldPeak5m = simulateOldModel(5, 3, 300);
|
||||
const newPeak5m = simulateNewModel(5, 3, 300);
|
||||
console.log(` Old model (5min @ 5pkt/s×3hops): peak ${oldPeak5m} concurrent timers`);
|
||||
console.log(` New model (5min @ 5pkt/s×3hops): peak ${newPeak5m} concurrent animations`);
|
||||
assert(oldPeak5m > 100, `old model at 5min still unbounded (got ${oldPeak5m})`);
|
||||
assert(newPeak5m <= 20, `new model at 5min still ≤20 (got ${newPeak5m})`);
|
||||
|
||||
// Scenario: burst — 20 pkts/sec for 10s
|
||||
const oldBurst = simulateOldModel(20, 3, 10);
|
||||
const newBurst = simulateNewModel(20, 3, 10);
|
||||
console.log(` Old model (burst 20pkt/s×3hops, 10s): peak ${oldBurst} concurrent timers`);
|
||||
console.log(` New model (burst 20pkt/s×3hops, 10s): peak ${newBurst} concurrent animations`);
|
||||
assert(oldBurst > 200, `old model under burst >200 timers (got ${oldBurst})`);
|
||||
assert(newBurst <= 20, `new model under burst stays ≤20 (got ${newBurst})`);
|
||||
|
||||
console.log('\n=== drawAnimatedLine frame-drop catch-up ===');
|
||||
|
||||
// Read the source and verify catch-up logic exists
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync(__dirname + '/public/live.js', 'utf8');
|
||||
|
||||
// Extract the animateLine function body
|
||||
const lineMatch = src.match(/function animateLine\(now\)\s*\{[\s\S]*?requestAnimationFrame\(animateLine\)/);
|
||||
assert(lineMatch && /Math\.min\(Math\.floor\(elapsed\s*\/\s*33\)/.test(lineMatch[0]),
|
||||
'drawAnimatedLine catches up on frame drops (multi-tick per frame)');
|
||||
|
||||
const fadeMatch = src.match(/function animateFade\(now\)\s*\{[\s\S]*?requestAnimationFrame\(animateFade\)/);
|
||||
assert(fadeMatch && /Math\.min\(Math\.floor\(fadeElapsed\s*\/\s*52\)/.test(fadeMatch[0]),
|
||||
'animateFade catches up on frame drops (multi-tick per frame)');
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed\n`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Tests for #759 — Add channel UX: button, hint, status feedback.
|
||||
* Validates the HTML structure rendered by channels.js init.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
function assertIncludes(html, substr, msg) {
|
||||
assert(html.includes(substr), msg);
|
||||
}
|
||||
|
||||
// Read the channels.js source to extract the HTML template
|
||||
const src = fs.readFileSync(__dirname + '/public/channels.js', 'utf8');
|
||||
|
||||
// Extract the sidebar HTML from the template literal
|
||||
const htmlMatch = src.match(/app\.innerHTML\s*=\s*`([\s\S]*?)`;/);
|
||||
const html = htmlMatch ? htmlMatch[1] : '';
|
||||
|
||||
console.log('Test: Add channel UX (#759)');
|
||||
|
||||
// 1. Button renders in the form
|
||||
assertIncludes(html, 'class="ch-add-btn"', 'Add button has ch-add-btn class');
|
||||
assertIncludes(html, 'type="submit"', 'Button is type=submit');
|
||||
assertIncludes(html, '>+</button>', 'Button shows + text');
|
||||
|
||||
// 2. Form has proper structure
|
||||
assertIncludes(html, 'class="ch-add-form"', 'Form has ch-add-form class');
|
||||
assertIncludes(html, 'class="ch-add-row"', 'Row wrapper present');
|
||||
assert(!html.includes('class="ch-add-label"'), 'Label removed (redundant with hint)');
|
||||
|
||||
// 3. Hint text present
|
||||
assertIncludes(html, 'class="ch-add-hint"', 'Hint div present');
|
||||
assertIncludes(html, 'e.g. #LongFast or 32-char hex key', 'Hint text correct');
|
||||
|
||||
// 4. Status div present
|
||||
assertIncludes(html, 'id="chAddStatus"', 'Status div has correct id');
|
||||
assertIncludes(html, 'class="ch-add-status"', 'Status div has correct class');
|
||||
assertIncludes(html, 'style="display:none"', 'Status div hidden by default');
|
||||
|
||||
// 5. showAddStatus function exists in source
|
||||
assert(src.includes('function showAddStatus('), 'showAddStatus function defined');
|
||||
assert(src.includes("'success'"), 'Success status type referenced');
|
||||
assert(src.includes("'error'"), 'Error status type referenced');
|
||||
|
||||
// 6. CSS classes exist
|
||||
const css = fs.readFileSync(__dirname + '/public/style.css', 'utf8');
|
||||
assert(css.includes('.ch-add-form'), 'CSS: .ch-add-form defined');
|
||||
assert(css.includes('.ch-add-btn'), 'CSS: .ch-add-btn defined');
|
||||
assert(css.includes('.ch-add-hint'), 'CSS: .ch-add-hint defined');
|
||||
assert(css.includes('.ch-add-status'), 'CSS: .ch-add-status defined');
|
||||
assert(css.includes('.ch-add-row'), 'CSS: .ch-add-row defined');
|
||||
// .ch-add-label CSS kept for backward compat but label removed from HTML
|
||||
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
+12
-40
@@ -224,7 +224,10 @@ async function run() {
|
||||
// Test 5: Node detail loads (reuses nodes page from test 2)
|
||||
await test('Node detail loads', async () => {
|
||||
await page.waitForSelector('table tbody tr');
|
||||
await page.click('table tbody tr');
|
||||
// Click first row
|
||||
const firstRow = await page.$('table tbody tr');
|
||||
assert(firstRow, 'No node rows found');
|
||||
await firstRow.click();
|
||||
// Wait for detail pane to appear
|
||||
await page.waitForSelector('.node-detail');
|
||||
const html = await page.content();
|
||||
@@ -237,14 +240,17 @@ async function run() {
|
||||
await test('Node side panel Details link navigates', async () => {
|
||||
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('table tbody tr');
|
||||
await page.click('table tbody tr');
|
||||
// Click first row to open side panel
|
||||
const firstRow = await page.$('table tbody tr');
|
||||
assert(firstRow, 'No node rows found');
|
||||
await firstRow.click();
|
||||
await page.waitForSelector('.node-detail');
|
||||
// Find the Details link in the side panel
|
||||
await page.waitForSelector('#nodesRight a.btn-primary[href^="#/nodes/"]');
|
||||
const href = await page.$eval('#nodesRight a.btn-primary[href^="#/nodes/"]', el => el.getAttribute('href'));
|
||||
assert(href, 'Details link not found in side panel');
|
||||
const detailsLink = await page.$('#nodesRight a.btn-primary[href^="#/nodes/"]');
|
||||
assert(detailsLink, 'Details link not found in side panel');
|
||||
const href = await detailsLink.getAttribute('href');
|
||||
// Click the Details link — this should navigate to the full detail page
|
||||
await page.click('#nodesRight a.btn-primary[href^="#/nodes/"]');
|
||||
await detailsLink.click();
|
||||
// Wait for navigation — the full detail page has sections like neighbors/packets
|
||||
await page.waitForFunction((expectedHash) => {
|
||||
return location.hash === expectedHash;
|
||||
@@ -657,8 +663,6 @@ async function run() {
|
||||
await page.waitForSelector('#ngCanvas', { timeout: 8000 });
|
||||
const hasCanvas = await page.$('#ngCanvas');
|
||||
assert(hasCanvas, 'Neighbor Graph tab should have a canvas element');
|
||||
// Stats are populated after the async API call — wait for at least one card before counting
|
||||
await page.waitForSelector('#ngStats .stat-card', { timeout: 8000 });
|
||||
const hasStats = await page.$$eval('#ngStats .stat-card', els => els.length);
|
||||
assert(hasStats >= 3, `Neighbor Graph stats should have >=3 cards, got ${hasStats}`);
|
||||
// Verify filters exist
|
||||
@@ -1354,38 +1358,6 @@ async function run() {
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: typing in text field does not collapse focus (re-render guard)', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
await page.waitForFunction(() => window._customizerV2 && window._customizerV2.initDone, { timeout: 5000 });
|
||||
const toggleSel = '#customizeToggle, button[title*="ustom" i], [class*="customize"]';
|
||||
const btn = await page.$(toggleSel);
|
||||
if (!btn) { console.log(' ⏭️ Customizer toggle not found'); return; }
|
||||
await btn.click();
|
||||
await page.waitForSelector('.cust-overlay', { timeout: 5000 });
|
||||
const result = await page.evaluate(() => {
|
||||
const input = document.querySelector('.cust-overlay input[type="text"][data-cv2-field]');
|
||||
if (!input) return { skipped: true };
|
||||
input.focus();
|
||||
input.value = 'test';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const inputRef = input;
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
const panel = document.querySelector('.cust-overlay');
|
||||
resolve({
|
||||
inputConnected: inputRef.isConnected,
|
||||
focusInPanel: panel ? panel.contains(document.activeElement) : false,
|
||||
});
|
||||
}, 500);
|
||||
});
|
||||
});
|
||||
if (result.skipped) { console.log(' ⏭️ No text input with data-cv2-field found in panel'); return; }
|
||||
assert(result.inputConnected, 'Input element should remain connected to DOM after debounce fires');
|
||||
assert(result.focusInPanel, 'Focus should remain inside panel after debounce — re-render must not run while typing');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
|
||||
await test('Show Neighbors populates neighborPubkeys from affinity API', async () => {
|
||||
const testPubkey = 'aabbccdd11223344556677889900aabbccddeeff00112233445566778899001122';
|
||||
|
||||
@@ -5904,11 +5904,12 @@ console.log('\n=== channel-decrypt.js: key derivation, MAC, parsing, storage ===
|
||||
assert.strictEqual(ctx.window.renderSkewBadge(null, 0), '');
|
||||
});
|
||||
|
||||
test('renderSkewBadge renders default badge with tooltip', () => {
|
||||
var cs = {};
|
||||
var html = ctx.window.renderSkewBadge('default', 0, cs);
|
||||
assert.ok(html.includes('skew-badge--default'), 'should contain default class');
|
||||
assert.ok(html.toLowerCase().includes('firmware default'), 'tooltip should mention firmware default');
|
||||
test('renderSkewBadge renders bimodal_clock badge with tooltip (#845)', () => {
|
||||
var cs = { goodFraction: 0.6, recentBadSampleCount: 4, recentSampleCount: 10 };
|
||||
var html = ctx.window.renderSkewBadge('bimodal_clock', -5, cs);
|
||||
assert.ok(html.includes('skew-badge--bimodal_clock'), 'should contain bimodal_clock class');
|
||||
assert.ok(html.includes('bimodal'), 'tooltip should mention bimodal');
|
||||
assert.ok(html.includes('40%'), 'tooltip should show bad percentage');
|
||||
assert.ok(html.includes('⏰'), 'should contain clock emoji');
|
||||
});
|
||||
|
||||
@@ -5932,9 +5933,9 @@ console.log('\n=== channel-decrypt.js: key derivation, MAC, parsing, storage ===
|
||||
|
||||
test('SKEW_SEVERITY_ORDER sorts worst first', () => {
|
||||
var order = ctx.window.SKEW_SEVERITY_ORDER;
|
||||
assert.ok(order.wrong < order.degraded, 'wrong should sort before degraded');
|
||||
assert.ok(order.degraded < order.degrading, 'degraded should sort before degrading');
|
||||
assert.ok(order.degrading < order.ok, 'degrading should sort before ok');
|
||||
assert.ok(order.absurd < order.critical, 'absurd should sort before critical');
|
||||
assert.ok(order.critical < order.warning, 'critical should sort before warning');
|
||||
assert.ok(order.warning < order.ok, 'warning should sort before ok');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
-114
@@ -844,120 +844,6 @@ console.log('\n=== packets.js: _invalidateRowCounts / _refreshRowCountsIfDirty (
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: buildPacketsParams ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
assert(typeof api.buildPacketsParams === 'function', 'buildPacketsParams must be exported');
|
||||
|
||||
test('hash filter suppresses region — direct hash links work regardless of saved region', () => {
|
||||
// This is the bug from URL https://analyzer.../#/packets?hash=178525e9f693aa7e
|
||||
// when the user's saved RegionFilter excludes the packet's observer region.
|
||||
// The hash is an exact identifier; ALL other filters must be ignored.
|
||||
const p = api.buildPacketsParams({
|
||||
filters: { hash: 'abc123' },
|
||||
regionParam: 'SJC,SFO,OAK,MRY',
|
||||
windowMin: 60,
|
||||
groupByHash: false,
|
||||
limit: 200,
|
||||
});
|
||||
assert.strictEqual(p.get('hash'), 'abc123');
|
||||
assert.strictEqual(p.get('region'), null, 'region must NOT be set when hash is present');
|
||||
assert.strictEqual(p.get('since'), null, 'since must NOT be set when hash is present');
|
||||
});
|
||||
|
||||
test('hash filter suppresses ALL other filters — observer, node, channel too', () => {
|
||||
const p = api.buildPacketsParams({
|
||||
filters: { hash: 'h', node: 'n', observer: 'o', channel: 'c' },
|
||||
regionParam: 'SJC',
|
||||
windowMin: 60,
|
||||
groupByHash: false,
|
||||
limit: 200,
|
||||
});
|
||||
assert.strictEqual(p.get('hash'), 'h');
|
||||
assert.strictEqual(p.get('node'), null);
|
||||
assert.strictEqual(p.get('observer'), null);
|
||||
assert.strictEqual(p.get('channel'), null);
|
||||
assert.strictEqual(p.get('region'), null);
|
||||
assert.strictEqual(p.get('since'), null);
|
||||
});
|
||||
|
||||
test('hash filter suppresses region with default windowMin=0', () => {
|
||||
const p = api.buildPacketsParams({
|
||||
filters: { hash: 'deadbeef' },
|
||||
regionParam: 'COA',
|
||||
windowMin: 0,
|
||||
groupByHash: false,
|
||||
limit: 50,
|
||||
});
|
||||
assert.strictEqual(p.get('hash'), 'deadbeef');
|
||||
assert.strictEqual(p.get('region'), null);
|
||||
});
|
||||
|
||||
test('region applied normally when hash filter is absent', () => {
|
||||
const p = api.buildPacketsParams({
|
||||
filters: {},
|
||||
regionParam: 'SJC,SFO',
|
||||
windowMin: 60,
|
||||
groupByHash: false,
|
||||
limit: 200,
|
||||
});
|
||||
assert.strictEqual(p.get('region'), 'SJC,SFO', 'region must apply when no hash');
|
||||
assert.strictEqual(p.get('hash'), null);
|
||||
assert(p.get('since'), 'since must apply when no hash and windowMin>0');
|
||||
});
|
||||
|
||||
test('observer/node/channel pass through normally when no hash', () => {
|
||||
const p = api.buildPacketsParams({
|
||||
filters: { observer: 'obs1', node: 'node1', channel: '#test' },
|
||||
regionParam: '',
|
||||
windowMin: 0,
|
||||
groupByHash: false,
|
||||
limit: 50,
|
||||
});
|
||||
assert.strictEqual(p.get('observer'), 'obs1');
|
||||
assert.strictEqual(p.get('node'), 'node1');
|
||||
assert.strictEqual(p.get('channel'), '#test');
|
||||
});
|
||||
|
||||
test('region absent when regionParam empty — no spurious empty region= param', () => {
|
||||
const p = api.buildPacketsParams({
|
||||
filters: {},
|
||||
regionParam: '',
|
||||
windowMin: 0,
|
||||
groupByHash: false,
|
||||
limit: 50,
|
||||
});
|
||||
assert.strictEqual(p.get('region'), null);
|
||||
});
|
||||
|
||||
test('groupByHash=true with hash sets groupByHash and omits expand', () => {
|
||||
const p = api.buildPacketsParams({
|
||||
filters: { hash: 'h' }, regionParam: '', windowMin: 0, groupByHash: true, limit: 50,
|
||||
});
|
||||
assert.strictEqual(p.get('groupByHash'), 'true');
|
||||
assert.strictEqual(p.get('expand'), null);
|
||||
assert.strictEqual(p.get('hash'), 'h');
|
||||
});
|
||||
|
||||
test('groupByHash=false with hash sets expand=observations', () => {
|
||||
const p = api.buildPacketsParams({
|
||||
filters: { hash: 'h' }, regionParam: '', windowMin: 0, groupByHash: false, limit: 50,
|
||||
});
|
||||
assert.strictEqual(p.get('expand'), 'observations');
|
||||
assert.strictEqual(p.get('groupByHash'), null);
|
||||
assert.strictEqual(p.get('hash'), 'h');
|
||||
});
|
||||
|
||||
test('groupByHash=false without hash sets expand=observations', () => {
|
||||
const p = api.buildPacketsParams({
|
||||
filters: {}, regionParam: '', windowMin: 0, groupByHash: false, limit: 50,
|
||||
});
|
||||
assert.strictEqual(p.get('expand'), 'observations');
|
||||
assert.strictEqual(p.get('groupByHash'), null);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
console.log(`\n${'='.repeat(40)}`);
|
||||
console.log(`packets.js tests: ${passed} passed, ${failed} failed`);
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// E2E tests for Path Inspector (spec §5 — Playwright).
|
||||
// Run: npx playwright test test-path-inspector-e2e.js
|
||||
// Requires: running server on BASE_URL (default http://localhost:3000).
|
||||
'use strict';
|
||||
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
|
||||
|
||||
test.describe('Path Inspector — Map Side Pane (spec §2.7)', () => {
|
||||
test('side pane present and collapsed by default', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/#/map`);
|
||||
const pane = page.locator('#mapSidePane');
|
||||
await expect(pane).toBeVisible();
|
||||
await expect(pane).not.toHaveClass(/expanded/);
|
||||
});
|
||||
|
||||
test('click toggle expands the pane', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/#/map`);
|
||||
await page.click('#mapPaneToggle');
|
||||
const pane = page.locator('#mapSidePane');
|
||||
await expect(pane).toHaveClass(/expanded/);
|
||||
});
|
||||
|
||||
test('submit valid prefixes renders candidates within 1s', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/#/map`);
|
||||
await page.click('#mapPaneToggle');
|
||||
await page.fill('#mapPiInput', '2c,a1,f4');
|
||||
await page.click('#mapPiSubmit');
|
||||
// Wait for results or error (both indicate API round-trip complete).
|
||||
await expect(page.locator('#mapPiResults table, #mapPiResults .no-results, #mapPiError')).toBeVisible({ timeout: 1000 });
|
||||
});
|
||||
|
||||
test('Show on Map button draws polyline on map', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/#/map`);
|
||||
await page.click('#mapPaneToggle');
|
||||
await page.fill('#mapPiInput', '2c,a1');
|
||||
await page.click('#mapPiSubmit');
|
||||
// Wait for results.
|
||||
const btn = page.locator('#mapPiResults button[data-idx="0"]');
|
||||
await btn.waitFor({ timeout: 2000 });
|
||||
await btn.click();
|
||||
// Check that route layer has SVG polyline paths drawn.
|
||||
const svg = page.locator('#leaflet-map .leaflet-overlay-pane svg path');
|
||||
await expect(svg.first()).toBeVisible({ timeout: 2000 });
|
||||
});
|
||||
|
||||
test('switching candidate clears prior polyline', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/#/map`);
|
||||
await page.click('#mapPaneToggle');
|
||||
await page.fill('#mapPiInput', '2c,a1');
|
||||
await page.click('#mapPiSubmit');
|
||||
const btn0 = page.locator('#mapPiResults button[data-idx="0"]');
|
||||
await btn0.waitFor({ timeout: 2000 });
|
||||
await btn0.click();
|
||||
// Click second candidate if available.
|
||||
const btn1 = page.locator('#mapPiResults button[data-idx="1"]');
|
||||
if (await btn1.isVisible()) {
|
||||
await btn1.click();
|
||||
// Prior route should be cleared — only one polyline group visible.
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Path Inspector — Standalone Page', () => {
|
||||
test('deep link auto-fills and runs', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/#/tools/path-inspector?prefixes=2c,a1,f4`);
|
||||
const input = page.locator('#path-inspector-input');
|
||||
await expect(input).toHaveValue('2c,a1,f4');
|
||||
// Should auto-submit and show results or error.
|
||||
await expect(page.locator('#path-inspector-results table, #path-inspector-results .no-results, #path-inspector-error')).toBeVisible({ timeout: 2000 });
|
||||
});
|
||||
|
||||
test('old #/traces/<hash> redirects to #/tools/trace/<hash>', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/#/traces/abc123`);
|
||||
await page.waitForTimeout(500);
|
||||
expect(page.url()).toContain('#/tools/trace/abc123');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Path Inspector — Tools Landing (spec §2.8)', () => {
|
||||
test('Tools nav shows landing with both entries', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/#/tools`);
|
||||
await expect(page.locator('.tools-landing')).toBeVisible();
|
||||
await expect(page.locator('a[href="#/tools/path-inspector"]')).toBeVisible();
|
||||
await expect(page.locator('a[href*="#/tools/trace"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
// test-path-inspector.js — vm.createContext sandbox tests for path-inspector.js
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
const src = fs.readFileSync(__dirname + '/public/path-inspector.js', 'utf8');
|
||||
|
||||
function createSandbox() {
|
||||
const sandbox = {
|
||||
window: {},
|
||||
document: {
|
||||
getElementById: () => ({ textContent: '', innerHTML: '', addEventListener: () => {}, querySelectorAll: () => [] }),
|
||||
querySelectorAll: () => []
|
||||
},
|
||||
location: { hash: '#/tools/path-inspector' },
|
||||
history: { replaceState: () => {} },
|
||||
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({ candidates: [] }) }),
|
||||
URLSearchParams: URLSearchParams,
|
||||
registerPage: function () {},
|
||||
escapeHtml: s => s,
|
||||
console: console
|
||||
};
|
||||
sandbox.self = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
const ctx = vm.createContext(sandbox);
|
||||
vm.runInContext(src, ctx);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
// Test: parsePrefixes accepts comma-separated.
|
||||
(function testParseComma() {
|
||||
const sb = createSandbox();
|
||||
const result = sb.window.PathInspector.parsePrefixes('2C,A1,F4');
|
||||
assert.strictEqual(JSON.stringify(result), JSON.stringify(['2c', 'a1', 'f4']));
|
||||
console.log('✓ parsePrefixes comma-separated');
|
||||
})();
|
||||
|
||||
// Test: parsePrefixes accepts space-separated.
|
||||
(function testParseSpace() {
|
||||
const sb = createSandbox();
|
||||
const result = sb.window.PathInspector.parsePrefixes('2C A1 F4');
|
||||
assert.strictEqual(JSON.stringify(result), JSON.stringify(['2c', 'a1', 'f4']));
|
||||
console.log('✓ parsePrefixes space-separated');
|
||||
})();
|
||||
|
||||
// Test: parsePrefixes accepts mixed.
|
||||
(function testParseMixed() {
|
||||
const sb = createSandbox();
|
||||
const result = sb.window.PathInspector.parsePrefixes(' 2C, A1 F4 ');
|
||||
assert.strictEqual(JSON.stringify(result), JSON.stringify(['2c', 'a1', 'f4']));
|
||||
console.log('✓ parsePrefixes mixed separators');
|
||||
})();
|
||||
|
||||
// Test: validatePrefixes rejects empty.
|
||||
(function testValidateEmpty() {
|
||||
const sb = createSandbox();
|
||||
const err = sb.window.PathInspector.validatePrefixes([]);
|
||||
assert.ok(err !== null, 'should reject empty');
|
||||
console.log('✓ validatePrefixes rejects empty');
|
||||
})();
|
||||
|
||||
// Test: validatePrefixes rejects odd-length.
|
||||
(function testValidateOdd() {
|
||||
const sb = createSandbox();
|
||||
const err = sb.window.PathInspector.validatePrefixes(['abc']);
|
||||
assert.ok(err !== null && err.includes('Odd'), 'should reject odd-length');
|
||||
console.log('✓ validatePrefixes rejects odd-length');
|
||||
})();
|
||||
|
||||
// Test: validatePrefixes rejects >3 bytes.
|
||||
(function testValidateTooLong() {
|
||||
const sb = createSandbox();
|
||||
const err = sb.window.PathInspector.validatePrefixes(['aabbccdd']);
|
||||
assert.ok(err !== null && err.includes('too long'), 'should reject >3 bytes');
|
||||
console.log('✓ validatePrefixes rejects >3 bytes');
|
||||
})();
|
||||
|
||||
// Test: validatePrefixes rejects mixed lengths.
|
||||
(function testValidateMixed() {
|
||||
const sb = createSandbox();
|
||||
const err = sb.window.PathInspector.validatePrefixes(['aa', 'bbcc']);
|
||||
assert.ok(err !== null && err.includes('Mixed'), 'should reject mixed');
|
||||
console.log('✓ validatePrefixes rejects mixed lengths');
|
||||
})();
|
||||
|
||||
// Test: validatePrefixes accepts valid input.
|
||||
(function testValidateValid() {
|
||||
const sb = createSandbox();
|
||||
const err = sb.window.PathInspector.validatePrefixes(['2c', 'a1', 'f4']);
|
||||
assert.strictEqual(err, null);
|
||||
console.log('✓ validatePrefixes accepts valid');
|
||||
})();
|
||||
|
||||
// Test: validatePrefixes rejects invalid hex.
|
||||
(function testValidateInvalidHex() {
|
||||
const sb = createSandbox();
|
||||
const err = sb.window.PathInspector.validatePrefixes(['zz']);
|
||||
assert.ok(err !== null && err.includes('Invalid hex'), 'should reject invalid hex');
|
||||
console.log('✓ validatePrefixes rejects invalid hex');
|
||||
})();
|
||||
|
||||
// Anti-tautology: if validation were removed (always return null), the odd-length test would fail.
|
||||
// Mental revert: validatePrefixes = () => null; → testValidateOdd would fail because err would be null.
|
||||
|
||||
console.log('\nAll path-inspector tests passed!');
|
||||
@@ -72,8 +72,7 @@ let polygon = null;
|
||||
let closingLine = null;
|
||||
|
||||
function latLonPair(latlng) {
|
||||
const w = latlng.wrap();
|
||||
return [parseFloat(w.lat.toFixed(6)), parseFloat(w.lng.toFixed(6))];
|
||||
return [parseFloat(latlng.lat.toFixed(6)), parseFloat(latlng.lng.toFixed(6))];
|
||||
}
|
||||
|
||||
function render() {
|
||||
|
||||
Reference in New Issue
Block a user