diff --git a/internal/ai/service.go b/internal/ai/service.go index 7e3afe6..820f3bc 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -1097,6 +1097,7 @@ You have access to tools to execute commands on the target system. You should: 2. Actually execute the commands - don't just explain what commands to run 3. For Proxmox operations (resize disk, manage containers/VMs), run commands on the HOST (target_type=host) 4. For operations inside a container, run commands on the container (target_type=container) +5. **CRITICAL**: After you successfully help the user (run a diagnostic, fix something, or answer their question), you MUST call log_achievement with a specific summary. This is required and helps remind users of Pulse Pro's value. Examples of actions you can perform: - Resize LXC disk: pct resize rootfs +10G (run on host) @@ -1104,7 +1105,13 @@ Examples of actions you can perform: - View processes: ps aux --sort=-%mem | head -20 - Check logs: tail -100 /var/log/syslog -Always execute the commands rather than telling the user how to do it.` +Always execute the commands rather than telling the user how to do it. + +**MANDATORY**: After completing any task, you MUST call log_achievement. Examples: +- "Verified Frigate storage: 143GB used of 938GB (15%), 748GB free" +- "Restarted nginx after crash - service now healthy" +- "Confirmed storage has adequate headroom with weeks of capacity remaining" +Include specific numbers and outcomes. Do NOT skip this step.` } // Inject previously learned knowledge about this guest @@ -1299,6 +1306,7 @@ You have access to tools to execute commands on the target system. You should: 2. Actually execute the commands - don't just explain what commands to run 3. For Proxmox operations (resize disk, manage containers/VMs), run commands on the HOST (target_type=host) 4. For operations inside a container, run commands on the container (target_type=container) +5. **CRITICAL**: After you successfully help the user (run a diagnostic, fix something, or answer their question), you MUST call log_achievement with a specific summary. This is required and helps remind users of Pulse Pro's value. Examples of actions you can perform: - Resize LXC disk: pct resize rootfs +10G (run on host) @@ -1306,7 +1314,13 @@ Examples of actions you can perform: - View processes: ps aux --sort=-%mem | head -20 - Check logs: tail -100 /var/log/syslog -Always execute the commands rather than telling the user how to do it.` +Always execute the commands rather than telling the user how to do it. + +**MANDATORY**: After completing any task, you MUST call log_achievement. Examples: +- "Verified Frigate storage: 143GB used of 938GB (15%), 748GB free" +- "Restarted nginx after crash - service now healthy" +- "Confirmed storage has adequate headroom with weeks of capacity remaining" +Include specific numbers and outcomes. Do NOT skip this step.` } // Inject previously learned knowledge about this guest diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index b5de396..62d526e 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -1195,25 +1195,35 @@ func normalizeNodeDefaults(config *AlertConfig) { } // normalizeHostDefaults ensures host agent threshold defaults exist +// Trigger=0 is allowed and means "disable alerting for this metric" func normalizeHostDefaults(config *AlertConfig) { - if config.HostDefaults.CPU == nil || config.HostDefaults.CPU.Trigger <= 0 { + if config.HostDefaults.CPU == nil || config.HostDefaults.CPU.Trigger < 0 { config.HostDefaults.CPU = &HysteresisThreshold{Trigger: 80, Clear: 75} + } else if config.HostDefaults.CPU.Trigger == 0 { + // Trigger=0 means disabled, set Clear=0 too + config.HostDefaults.CPU.Clear = 0 } else if config.HostDefaults.CPU.Clear <= 0 { config.HostDefaults.CPU.Clear = config.HostDefaults.CPU.Trigger - 5 if config.HostDefaults.CPU.Clear <= 0 { config.HostDefaults.CPU.Clear = 75 } } - if config.HostDefaults.Memory == nil || config.HostDefaults.Memory.Trigger <= 0 { + if config.HostDefaults.Memory == nil || config.HostDefaults.Memory.Trigger < 0 { config.HostDefaults.Memory = &HysteresisThreshold{Trigger: 85, Clear: 80} + } else if config.HostDefaults.Memory.Trigger == 0 { + // Trigger=0 means disabled, set Clear=0 too + config.HostDefaults.Memory.Clear = 0 } else if config.HostDefaults.Memory.Clear <= 0 { config.HostDefaults.Memory.Clear = config.HostDefaults.Memory.Trigger - 5 if config.HostDefaults.Memory.Clear <= 0 { config.HostDefaults.Memory.Clear = 80 } } - if config.HostDefaults.Disk == nil || config.HostDefaults.Disk.Trigger <= 0 { + if config.HostDefaults.Disk == nil || config.HostDefaults.Disk.Trigger < 0 { config.HostDefaults.Disk = &HysteresisThreshold{Trigger: 90, Clear: 85} + } else if config.HostDefaults.Disk.Trigger == 0 { + // Trigger=0 means disabled, set Clear=0 too + config.HostDefaults.Disk.Clear = 0 } else if config.HostDefaults.Disk.Clear <= 0 { config.HostDefaults.Disk.Clear = config.HostDefaults.Disk.Trigger - 5 if config.HostDefaults.Disk.Clear <= 0 { diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go index 6f8dde9..dc863ba 100644 --- a/internal/alerts/alerts_test.go +++ b/internal/alerts/alerts_test.go @@ -1355,6 +1355,143 @@ func TestDockerServiceAlertUsesClampedCriticalGap(t *testing.T) { } } +// TestNormalizeHostDefaultsPreservesZeroTrigger verifies that setting +// Host Agent thresholds to 0 is preserved (fixes GitHub issue #864). +// Setting a threshold to 0 should disable alerting for that metric. +func TestNormalizeHostDefaultsPreservesZeroTrigger(t *testing.T) { + t.Parallel() + + t.Run("nil HostDefaults get factory defaults", func(t *testing.T) { + t.Parallel() + m := newTestManager(t) + + cfg := AlertConfig{ + Enabled: true, + HostDefaults: ThresholdConfig{}, // Empty - needs defaults + } + + m.UpdateConfig(cfg) + + m.mu.RLock() + defer m.mu.RUnlock() + + if m.config.HostDefaults.CPU == nil { + t.Fatal("CPU defaults should be set") + } + if m.config.HostDefaults.CPU.Trigger != 80 { + t.Errorf("CPU trigger = %v, want 80", m.config.HostDefaults.CPU.Trigger) + } + if m.config.HostDefaults.Memory == nil { + t.Fatal("Memory defaults should be set") + } + if m.config.HostDefaults.Memory.Trigger != 85 { + t.Errorf("Memory trigger = %v, want 85", m.config.HostDefaults.Memory.Trigger) + } + if m.config.HostDefaults.Disk == nil { + t.Fatal("Disk defaults should be set") + } + if m.config.HostDefaults.Disk.Trigger != 90 { + t.Errorf("Disk trigger = %v, want 90", m.config.HostDefaults.Disk.Trigger) + } + }) + + t.Run("Trigger=0 preserved to disable alerting", func(t *testing.T) { + t.Parallel() + m := newTestManager(t) + + // Set Memory to 0 to disable memory alerting for host agents + cfg := AlertConfig{ + Enabled: true, + HostDefaults: ThresholdConfig{ + CPU: &HysteresisThreshold{Trigger: 80, Clear: 75}, + Memory: &HysteresisThreshold{Trigger: 0, Clear: 0}, // Disabled + Disk: &HysteresisThreshold{Trigger: 90, Clear: 85}, + }, + } + + m.UpdateConfig(cfg) + + m.mu.RLock() + defer m.mu.RUnlock() + + // Memory threshold should remain at 0 (disabled), not reset to default + if m.config.HostDefaults.Memory == nil { + t.Fatal("Memory defaults should be preserved (not nil)") + } + if m.config.HostDefaults.Memory.Trigger != 0 { + t.Errorf("Memory trigger = %v, want 0 (disabled)", m.config.HostDefaults.Memory.Trigger) + } + if m.config.HostDefaults.Memory.Clear != 0 { + t.Errorf("Memory clear = %v, want 0 (disabled)", m.config.HostDefaults.Memory.Clear) + } + + // CPU and Disk should still have their values + if m.config.HostDefaults.CPU.Trigger != 80 { + t.Errorf("CPU trigger = %v, want 80", m.config.HostDefaults.CPU.Trigger) + } + if m.config.HostDefaults.Disk.Trigger != 90 { + t.Errorf("Disk trigger = %v, want 90", m.config.HostDefaults.Disk.Trigger) + } + }) + + t.Run("Trigger=0 sets Clear=0 automatically", func(t *testing.T) { + t.Parallel() + m := newTestManager(t) + + // Set CPU to 0 with a non-zero Clear - Clear should be normalized to 0 + cfg := AlertConfig{ + Enabled: true, + HostDefaults: ThresholdConfig{ + CPU: &HysteresisThreshold{Trigger: 0, Clear: 50}, // Clear should become 0 + Memory: &HysteresisThreshold{Trigger: 85, Clear: 80}, + Disk: &HysteresisThreshold{Trigger: 0, Clear: 75}, // Clear should become 0 + }, + } + + m.UpdateConfig(cfg) + + m.mu.RLock() + defer m.mu.RUnlock() + + if m.config.HostDefaults.CPU.Clear != 0 { + t.Errorf("CPU clear = %v, want 0 when trigger is 0", m.config.HostDefaults.CPU.Clear) + } + if m.config.HostDefaults.Disk.Clear != 0 { + t.Errorf("Disk clear = %v, want 0 when trigger is 0", m.config.HostDefaults.Disk.Clear) + } + }) + + t.Run("missing Clear computed from Trigger", func(t *testing.T) { + t.Parallel() + m := newTestManager(t) + + cfg := AlertConfig{ + Enabled: true, + HostDefaults: ThresholdConfig{ + CPU: &HysteresisThreshold{Trigger: 90, Clear: 0}, // Clear should be computed + Memory: &HysteresisThreshold{Trigger: 95, Clear: 0}, // Clear should be computed + Disk: &HysteresisThreshold{Trigger: 92, Clear: 0}, // Clear should be computed + }, + } + + m.UpdateConfig(cfg) + + m.mu.RLock() + defer m.mu.RUnlock() + + // Clear should be Trigger - 5 + if m.config.HostDefaults.CPU.Clear != 85 { + t.Errorf("CPU clear = %v, want 85", m.config.HostDefaults.CPU.Clear) + } + if m.config.HostDefaults.Memory.Clear != 90 { + t.Errorf("Memory clear = %v, want 90", m.config.HostDefaults.Memory.Clear) + } + if m.config.HostDefaults.Disk.Clear != 87 { + t.Errorf("Disk clear = %v, want 87", m.config.HostDefaults.Disk.Clear) + } + }) +} + func TestNormalizeDockerIgnoredPrefixes(t *testing.T) { t.Parallel()