Compare commits

...

1 Commits

Author SHA1 Message Date
you
3b17ea0f1f feat: make health thresholds configurable in hours
Change healthThresholds config from milliseconds to hours for readability.
Config keys: infraDegradedHours, infraSilentHours, nodeDegradedHours, nodeSilentHours.
Defaults: infra degraded 24h, silent 72h; node degraded 1h, silent 24h.

- Config stored in hours, converted to ms at comparison time
- /api/config/client sends ms to frontend (backward compatible)
- Frontend tooltips use dynamic thresholds instead of hardcoded strings
- Added healthThresholds section to config.example.json
- Updated Go and Node.js servers, tests
2026-03-29 16:39:27 +00:00
9 changed files with 110 additions and 84 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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": [

View File

@@ -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.' : '');
}

View File

@@ -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
};
}

View File

@@ -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,

View File

@@ -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 ---