diff --git a/cmd/server/config.go b/cmd/server/config.go index d8afe52a..ff2ee36d 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -60,10 +60,10 @@ func (c *Config) NodeDaysOrDefault() int { } type HealthThresholds struct { - InfraDegradedMs int `json:"infraDegradedMs"` - InfraSilentMs int `json:"infraSilentMs"` - NodeDegradedMs int `json:"nodeDegradedMs"` - NodeSilentMs int `json:"nodeSilentMs"` + InfraDegradedHours float64 `json:"infraDegradedHours"` + InfraSilentHours float64 `json:"infraSilentHours"` + NodeDegradedHours float64 `json:"nodeDegradedHours"` + NodeSilentHours float64 `json:"nodeSilentHours"` } // ThemeFile mirrors theme.json overlay. @@ -126,34 +126,46 @@ func LoadTheme(baseDirs ...string) *ThemeFile { func (c *Config) GetHealthThresholds() HealthThresholds { h := HealthThresholds{ - InfraDegradedMs: 86400000, - InfraSilentMs: 259200000, - NodeDegradedMs: 3600000, - NodeSilentMs: 86400000, + InfraDegradedHours: 24, + InfraSilentHours: 72, + NodeDegradedHours: 1, + NodeSilentHours: 24, } if c.HealthThresholds != nil { - if c.HealthThresholds.InfraDegradedMs > 0 { - h.InfraDegradedMs = c.HealthThresholds.InfraDegradedMs + if c.HealthThresholds.InfraDegradedHours > 0 { + h.InfraDegradedHours = c.HealthThresholds.InfraDegradedHours } - if c.HealthThresholds.InfraSilentMs > 0 { - h.InfraSilentMs = c.HealthThresholds.InfraSilentMs + if c.HealthThresholds.InfraSilentHours > 0 { + h.InfraSilentHours = c.HealthThresholds.InfraSilentHours } - if c.HealthThresholds.NodeDegradedMs > 0 { - h.NodeDegradedMs = c.HealthThresholds.NodeDegradedMs + if c.HealthThresholds.NodeDegradedHours > 0 { + h.NodeDegradedHours = c.HealthThresholds.NodeDegradedHours } - if c.HealthThresholds.NodeSilentMs > 0 { - h.NodeSilentMs = c.HealthThresholds.NodeSilentMs + if c.HealthThresholds.NodeSilentHours > 0 { + h.NodeSilentHours = c.HealthThresholds.NodeSilentHours } } return h } -// GetHealthMs returns degraded/silent thresholds for a given role. +// GetHealthMs returns degraded/silent thresholds in ms for a given role. func (h HealthThresholds) GetHealthMs(role string) (degradedMs, silentMs int) { + const hourMs = 3600000 if role == "repeater" || role == "room" { - return h.InfraDegradedMs, h.InfraSilentMs + return int(h.InfraDegradedHours * hourMs), int(h.InfraSilentHours * hourMs) + } + return int(h.NodeDegradedHours * hourMs), int(h.NodeSilentHours * hourMs) +} + +// ToClientMs returns the thresholds as ms for the frontend. +func (h HealthThresholds) ToClientMs() map[string]int { + const hourMs = 3600000 + return map[string]int{ + "infraDegradedMs": int(h.InfraDegradedHours * hourMs), + "infraSilentMs": int(h.InfraSilentHours * hourMs), + "nodeDegradedMs": int(h.NodeDegradedHours * hourMs), + "nodeSilentMs": int(h.NodeSilentHours * hourMs), } - return h.NodeDegradedMs, h.NodeSilentMs } func (c *Config) ResolveDBPath(baseDir string) string { diff --git a/cmd/server/config_test.go b/cmd/server/config_test.go index 2a6f5cfc..4252685d 100644 --- a/cmd/server/config_test.go +++ b/cmd/server/config_test.go @@ -23,10 +23,10 @@ func TestLoadConfigValidJSON(t *testing.T) { "SJC": "San Jose", }, "healthThresholds": map[string]interface{}{ - "infraDegradedMs": 100000, - "infraSilentMs": 200000, - "nodeDegradedMs": 50000, - "nodeSilentMs": 100000, + "infraDegradedHours": 2, + "infraSilentHours": 4, + "nodeDegradedHours": 0.5, + "nodeSilentHours": 2, }, "liveMap": map[string]interface{}{ "propagationBufferMs": 3000, @@ -178,68 +178,68 @@ func TestGetHealthThresholdsDefaults(t *testing.T) { cfg := &Config{} ht := cfg.GetHealthThresholds() - if ht.InfraDegradedMs != 86400000 { - t.Errorf("expected 86400000, got %d", ht.InfraDegradedMs) + if ht.InfraDegradedHours != 24 { + t.Errorf("expected 24, got %v", ht.InfraDegradedHours) } - if ht.InfraSilentMs != 259200000 { - t.Errorf("expected 259200000, got %d", ht.InfraSilentMs) + if ht.InfraSilentHours != 72 { + t.Errorf("expected 72, got %v", ht.InfraSilentHours) } - if ht.NodeDegradedMs != 3600000 { - t.Errorf("expected 3600000, got %d", ht.NodeDegradedMs) + if ht.NodeDegradedHours != 1 { + t.Errorf("expected 1, got %v", ht.NodeDegradedHours) } - if ht.NodeSilentMs != 86400000 { - t.Errorf("expected 86400000, got %d", ht.NodeSilentMs) + if ht.NodeSilentHours != 24 { + t.Errorf("expected 24, got %v", ht.NodeSilentHours) } } func TestGetHealthThresholdsCustom(t *testing.T) { cfg := &Config{ HealthThresholds: &HealthThresholds{ - InfraDegradedMs: 100000, - InfraSilentMs: 200000, - NodeDegradedMs: 50000, - NodeSilentMs: 100000, + InfraDegradedHours: 2, + InfraSilentHours: 4, + NodeDegradedHours: 0.5, + NodeSilentHours: 2, }, } ht := cfg.GetHealthThresholds() - if ht.InfraDegradedMs != 100000 { - t.Errorf("expected 100000, got %d", ht.InfraDegradedMs) + if ht.InfraDegradedHours != 2 { + t.Errorf("expected 2, got %v", ht.InfraDegradedHours) } - if ht.InfraSilentMs != 200000 { - t.Errorf("expected 200000, got %d", ht.InfraSilentMs) + if ht.InfraSilentHours != 4 { + t.Errorf("expected 4, got %v", ht.InfraSilentHours) } - if ht.NodeDegradedMs != 50000 { - t.Errorf("expected 50000, got %d", ht.NodeDegradedMs) + if ht.NodeDegradedHours != 0.5 { + t.Errorf("expected 0.5, got %v", ht.NodeDegradedHours) } - if ht.NodeSilentMs != 100000 { - t.Errorf("expected 100000, got %d", ht.NodeSilentMs) + if ht.NodeSilentHours != 2 { + t.Errorf("expected 2, got %v", ht.NodeSilentHours) } } func TestGetHealthThresholdsPartialCustom(t *testing.T) { cfg := &Config{ HealthThresholds: &HealthThresholds{ - InfraDegradedMs: 100000, + InfraDegradedHours: 2, // Others left as zero → should use defaults }, } ht := cfg.GetHealthThresholds() - if ht.InfraDegradedMs != 100000 { - t.Errorf("expected 100000, got %d", ht.InfraDegradedMs) + if ht.InfraDegradedHours != 2 { + t.Errorf("expected 2, got %v", ht.InfraDegradedHours) } - if ht.InfraSilentMs != 259200000 { - t.Errorf("expected default 259200000, got %d", ht.InfraSilentMs) + if ht.InfraSilentHours != 72 { + t.Errorf("expected default 72, got %v", ht.InfraSilentHours) } } func TestGetHealthMs(t *testing.T) { ht := HealthThresholds{ - InfraDegradedMs: 86400000, - InfraSilentMs: 259200000, - NodeDegradedMs: 3600000, - NodeSilentMs: 86400000, + InfraDegradedHours: 24, + InfraSilentHours: 72, + NodeDegradedHours: 1, + NodeSilentHours: 24, } tests := []struct { diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index d571b8d2..9f8b1e1f 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -513,10 +513,10 @@ func TestGetNetworkStatus(t *testing.T) { seedTestData(t, db) ht := HealthThresholds{ - InfraDegradedMs: 86400000, - InfraSilentMs: 259200000, - NodeDegradedMs: 3600000, - NodeSilentMs: 86400000, + InfraDegradedHours: 24, + InfraSilentHours: 72, + NodeDegradedHours: 1, + NodeSilentHours: 24, } result, err := db.GetNetworkStatus(ht) if err != nil { @@ -1050,10 +1050,10 @@ func TestGetNetworkStatusDateFormats(t *testing.T) { VALUES ('node4444', 'NodeBad', 'sensor', 'not-a-date')`) ht := HealthThresholds{ - InfraDegradedMs: 86400000, - InfraSilentMs: 259200000, - NodeDegradedMs: 3600000, - NodeSilentMs: 86400000, + InfraDegradedHours: 24, + InfraSilentHours: 72, + NodeDegradedHours: 1, + NodeSilentHours: 24, } result, err := db.GetNetworkStatus(ht) if err != nil { diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 0bec70a5..aa4c6290 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -213,7 +213,7 @@ func (s *Server) handleConfigCache(w http.ResponseWriter, r *http.Request) { func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) { writeJSON(w, ClientConfigResponse{ Roles: s.cfg.Roles, - HealthThresholds: s.cfg.HealthThresholds, + HealthThresholds: s.cfg.GetHealthThresholds().ToClientMs(), Tiles: s.cfg.Tiles, SnrThresholds: s.cfg.SnrThresholds, DistThresholds: s.cfg.DistThresholds, diff --git a/config.example.json b/config.example.json index c53a2714..ebde196a 100644 --- a/config.example.json +++ b/config.example.json @@ -98,6 +98,13 @@ "#bookclub", "#shtf" ], + "healthThresholds": { + "infraDegradedHours": 24, + "infraSilentHours": 72, + "nodeDegradedHours": 1, + "nodeSilentHours": 24, + "_comment": "How long (hours) before nodes show as degraded/silent. 'infra' = repeaters & rooms, 'node' = companions & others." + }, "defaultRegion": "SJC", "mapDefaults": { "center": [ diff --git a/public/nodes.js b/public/nodes.js index ff8ac231..4370d3ad 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -89,7 +89,8 @@ function getStatusTooltip(role, status) { const isInfra = role === 'repeater' || role === 'room'; - const threshold = isInfra ? '72h' : '24h'; + const threshMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs; + const threshold = threshMs >= 3600000 ? Math.round(threshMs / 3600000) + 'h' : Math.round(threshMs / 60000) + 'm'; if (status === 'active') { return 'Active \u2014 heard within the last ' + threshold + '.' + (isInfra ? ' Repeaters typically advertise every 12-24h.' : ''); } diff --git a/server-helpers.js b/server-helpers.js index d04c7029..568efdf9 100644 --- a/server-helpers.js +++ b/server-helpers.js @@ -36,18 +36,19 @@ function loadThemeFile(themePaths) { function buildHealthConfig(config) { const _ht = (config && config.healthThresholds) || {}; return { - infraDegradedMs: _ht.infraDegradedMs || 86400000, - infraSilentMs: _ht.infraSilentMs || 259200000, - nodeDegradedMs: _ht.nodeDegradedMs || 3600000, - nodeSilentMs: _ht.nodeSilentMs || 86400000 + infraDegraded: _ht.infraDegradedHours || 24, + infraSilent: _ht.infraSilentHours || 72, + nodeDegraded: _ht.nodeDegradedHours || 1, + nodeSilent: _ht.nodeSilentHours || 24 }; } function getHealthMs(role, HEALTH) { + const H = 3600000; const isInfra = role === 'repeater' || role === 'room'; return { - degradedMs: isInfra ? HEALTH.infraDegradedMs : HEALTH.nodeDegradedMs, - silentMs: isInfra ? HEALTH.infraSilentMs : HEALTH.nodeSilentMs + degradedMs: (isInfra ? HEALTH.infraDegraded : HEALTH.nodeDegraded) * H, + silentMs: (isInfra ? HEALTH.infraSilent : HEALTH.nodeSilent) * H }; } diff --git a/server.js b/server.js index 0a3c652d..31cd6d74 100644 --- a/server.js +++ b/server.js @@ -307,7 +307,12 @@ app.get('/api/config/cache', (req, res) => { app.get('/api/config/client', (req, res) => { res.json({ roles: config.roles || null, - healthThresholds: config.healthThresholds || null, + healthThresholds: { + infraDegradedMs: HEALTH.infraDegraded * 3600000, + infraSilentMs: HEALTH.infraSilent * 3600000, + nodeDegradedMs: HEALTH.nodeDegraded * 3600000, + nodeSilentMs: HEALTH.nodeSilent * 3600000 + }, tiles: config.tiles || null, snrThresholds: config.snrThresholds || null, distThresholds: config.distThresholds || null, diff --git a/test-server-helpers.js b/test-server-helpers.js index c0e720df..6086d583 100644 --- a/test-server-helpers.js +++ b/test-server-helpers.js @@ -59,17 +59,17 @@ console.log('\nloadThemeFile:'); console.log('\nbuildHealthConfig:'); { const h = helpers.buildHealthConfig({}); - assert(h.infraDegradedMs === 86400000, 'default infraDegradedMs'); - assert(h.infraSilentMs === 259200000, 'default infraSilentMs'); - assert(h.nodeDegradedMs === 3600000, 'default nodeDegradedMs'); - assert(h.nodeSilentMs === 86400000, 'default nodeSilentMs'); + assert(h.infraDegraded === 24, 'default infraDegraded'); + assert(h.infraSilent === 72, 'default infraSilent'); + assert(h.nodeDegraded === 1, 'default nodeDegraded'); + assert(h.nodeSilent === 24, 'default nodeSilent'); - const h2 = helpers.buildHealthConfig({ healthThresholds: { infraDegradedMs: 1000 } }); - assert(h2.infraDegradedMs === 1000, 'custom infraDegradedMs'); - assert(h2.nodeDegradedMs === 3600000, 'other defaults preserved'); + const h2 = helpers.buildHealthConfig({ healthThresholds: { infraDegradedHours: 2 } }); + assert(h2.infraDegraded === 2, 'custom infraDegraded'); + assert(h2.nodeDegraded === 1, 'other defaults preserved'); const h3 = helpers.buildHealthConfig(null); - assert(h3.infraDegradedMs === 86400000, 'handles null config'); + assert(h3.infraDegraded === 24, 'handles null config'); } // --- getHealthMs --- @@ -78,21 +78,21 @@ console.log('\ngetHealthMs:'); const HEALTH = helpers.buildHealthConfig({}); const rep = helpers.getHealthMs('repeater', HEALTH); - assert(rep.degradedMs === 86400000, 'repeater uses infra degraded'); - assert(rep.silentMs === 259200000, 'repeater uses infra silent'); + assert(rep.degradedMs === 24 * 3600000, 'repeater uses infra degraded'); + assert(rep.silentMs === 72 * 3600000, 'repeater uses infra silent'); const room = helpers.getHealthMs('room', HEALTH); - assert(room.degradedMs === 86400000, 'room uses infra degraded'); + assert(room.degradedMs === 24 * 3600000, 'room uses infra degraded'); const comp = helpers.getHealthMs('companion', HEALTH); - assert(comp.degradedMs === 3600000, 'companion uses node degraded'); - assert(comp.silentMs === 86400000, 'companion uses node silent'); + assert(comp.degradedMs === 1 * 3600000, 'companion uses node degraded'); + assert(comp.silentMs === 24 * 3600000, 'companion uses node silent'); const sensor = helpers.getHealthMs('sensor', HEALTH); - assert(sensor.degradedMs === 3600000, 'sensor uses node degraded'); + assert(sensor.degradedMs === 1 * 3600000, 'sensor uses node degraded'); const undef = helpers.getHealthMs(undefined, HEALTH); - assert(undef.degradedMs === 3600000, 'undefined role uses node degraded'); + assert(undef.degradedMs === 1 * 3600000, 'undefined role uses node degraded'); } // --- isHashSizeFlipFlop ---