+ Upgrade to Pro
+ {' '}to enable alert-triggered analysis.
+
+
+
+
+
+
+ {/* Save Button (for investigation limits + full mode unlock) */}
+
-
-
-
- {/* Save Button (for investigation limits + full mode unlock) */}
-
-
-
-
+
+
diff --git a/internal/ai/patrol.go b/internal/ai/patrol.go
index 1518e0099..644b2b545 100644
--- a/internal/ai/patrol.go
+++ b/internal/ai/patrol.go
@@ -287,6 +287,7 @@ type PatrolService struct {
errorCount int
lastBlockedReason string
lastBlockedAt time.Time
+ nextScheduledAt time.Time // Tracks actual next patrol time (accounts for ticker resets)
// Patrol run history with persistence support
runHistoryStore *PatrolRunHistoryStore
diff --git a/internal/ai/patrol_run.go b/internal/ai/patrol_run.go
index a069da39e..a4bd89cb8 100644
--- a/internal/ai/patrol_run.go
+++ b/internal/ai/patrol_run.go
@@ -96,6 +96,14 @@ func (p *PatrolService) Stop() {
// patrolLoop is the main background loop
func (p *PatrolService) patrolLoop(ctx context.Context) {
+ // Seed lastPatrol from persisted run history so the API can return
+ // last_patrol_at immediately (before the first in-process patrol completes).
+ if history := p.GetRunHistory(1); len(history) > 0 && !history[0].CompletedAt.IsZero() {
+ p.mu.Lock()
+ p.lastPatrol = history[0].CompletedAt
+ p.mu.Unlock()
+ }
+
// Run initial patrol shortly after startup, but only if one hasn't run recently
initialDelay := initialPatrolStartDelay
select {
@@ -132,9 +140,19 @@ func (p *PatrolService) patrolLoop(ctx context.Context) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
+ p.mu.Lock()
+ p.nextScheduledAt = time.Now().Add(interval)
+ p.mu.Unlock()
+
for {
select {
case <-ticker.C:
+ // Update next scheduled time before the run starts — time.Now() closely
+ // matches the tick time here, and the ticker will fire again at roughly
+ // this moment + interval regardless of how long the run takes.
+ p.mu.Lock()
+ p.nextScheduledAt = time.Now().Add(interval)
+ p.mu.Unlock()
p.runPatrolWithTrigger(ctx, TriggerReasonScheduled, nil)
case alert := <-p.adHocTrigger:
@@ -151,6 +169,9 @@ func (p *PatrolService) patrolLoop(ctx context.Context) {
if newInterval != interval {
interval = newInterval
ticker.Reset(interval)
+ p.mu.Lock()
+ p.nextScheduledAt = time.Now().Add(interval)
+ p.mu.Unlock()
log.Info().
Dur("interval", interval).
Msg("Patrol ticker reset to new interval")
@@ -1037,9 +1058,9 @@ func (p *PatrolService) GetStatus() PatrolStatus {
status.BlockedAt = &p.lastBlockedAt
}
- // Calculate next patrol time only when patrol is enabled
- if p.config.Enabled && interval > 0 && !p.lastPatrol.IsZero() {
- next := p.lastPatrol.Add(interval)
+ // Use the tracked next scheduled time (accounts for ticker resets on interval changes)
+ if p.config.Enabled && interval > 0 && !p.nextScheduledAt.IsZero() {
+ next := p.nextScheduledAt
status.NextPatrolAt = &next
}
diff --git a/internal/ai/patrol_run_test.go b/internal/ai/patrol_run_test.go
index 21214fe35..38ec19d26 100644
--- a/internal/ai/patrol_run_test.go
+++ b/internal/ai/patrol_run_test.go
@@ -598,10 +598,10 @@ func TestGetStatus_Running(t *testing.T) {
func TestGetStatus_NextPatrolAt(t *testing.T) {
ps := NewPatrolService(nil, nil)
- // Set lastPatrol so nextPatrolAt is calculated
- now := time.Now()
+ // Set nextScheduledAt so nextPatrolAt is returned
+ nextTime := time.Now().Add(30 * time.Minute)
ps.mu.Lock()
- ps.lastPatrol = now
+ ps.nextScheduledAt = nextTime
ps.mu.Unlock()
status := ps.GetStatus()
@@ -609,9 +609,46 @@ func TestGetStatus_NextPatrolAt(t *testing.T) {
t.Fatal("expected NextPatrolAt to be calculated")
}
- expected := now.Add(ps.config.GetInterval())
- if !status.NextPatrolAt.Equal(expected) {
- t.Errorf("expected NextPatrolAt %v, got %v", expected, *status.NextPatrolAt)
+ if !status.NextPatrolAt.Equal(nextTime) {
+ t.Errorf("expected NextPatrolAt %v, got %v", nextTime, *status.NextPatrolAt)
+ }
+}
+
+func TestGetStatus_NextPatrolAt_IndependentOfLastPatrol(t *testing.T) {
+ // nextScheduledAt should drive NextPatrolAt, not lastPatrol + interval.
+ // This is the scenario that was previously broken: user changes interval
+ // mid-cycle, lastPatrol is old, so lastPatrol+newInterval could be in the past.
+ ps := NewPatrolService(nil, nil)
+
+ // Simulate: patrol ran 45 min ago
+ ps.mu.Lock()
+ ps.lastPatrol = time.Now().Add(-45 * time.Minute)
+ // But the ticker was just reset with a 15-min interval, so next fire is ~15 min from now
+ expectedNext := time.Now().Add(15 * time.Minute)
+ ps.nextScheduledAt = expectedNext
+ ps.mu.Unlock()
+
+ status := ps.GetStatus()
+ if status.NextPatrolAt == nil {
+ t.Fatal("expected NextPatrolAt to be set")
+ }
+ // NextPatrolAt must be the tracked nextScheduledAt (in the future),
+ // NOT lastPatrol + interval (which would be 30 min in the past).
+ if !status.NextPatrolAt.Equal(expectedNext) {
+ t.Errorf("expected NextPatrolAt = %v, got %v", expectedNext, *status.NextPatrolAt)
+ }
+ if status.NextPatrolAt.Before(time.Now()) {
+ t.Errorf("NextPatrolAt should be in the future, got %v", *status.NextPatrolAt)
+ }
+}
+
+func TestGetStatus_NextPatrolAt_NilWhenNotScheduled(t *testing.T) {
+ // Before the patrol loop starts, nextScheduledAt is zero — no NextPatrolAt should be returned.
+ ps := NewPatrolService(nil, nil)
+
+ status := ps.GetStatus()
+ if status.NextPatrolAt != nil {
+ t.Errorf("expected NextPatrolAt to be nil before patrol loop starts, got %v", *status.NextPatrolAt)
}
}
@@ -620,6 +657,11 @@ func TestGetStatus_Disabled(t *testing.T) {
ps.SetConfig(PatrolConfig{Enabled: false})
+ // Even if nextScheduledAt is set, disabled patrol should not report NextPatrolAt
+ ps.mu.Lock()
+ ps.nextScheduledAt = time.Now().Add(10 * time.Minute)
+ ps.mu.Unlock()
+
status := ps.GetStatus()
if status.Enabled {
t.Error("expected Enabled to be false")
diff --git a/internal/config/ai.go b/internal/config/ai.go
index 61b731c29..5b7e7c1d2 100644
--- a/internal/config/ai.go
+++ b/internal/config/ai.go
@@ -42,8 +42,8 @@ type AIConfig struct {
// Patrol settings for background AI monitoring
PatrolEnabled bool `json:"patrol_enabled"` // Enable background AI health patrol
- PatrolIntervalMinutes int `json:"patrol_interval_minutes,omitempty"` // How often to run quick patrols (default: 360 = 6 hours)
- PatrolSchedulePreset string `json:"patrol_schedule_preset,omitempty"` // User-friendly preset: "15min", "1hr", "6hr", "12hr", "daily", "disabled"
+ PatrolIntervalMinutes int `json:"patrol_interval_minutes"` // How often to run quick patrols (default: 360 = 6 hours)
+ PatrolSchedulePreset string `json:"patrol_schedule_preset"` // User-friendly preset: "15min", "1hr", "6hr", "12hr", "daily", "disabled"
PatrolAnalyzeNodes bool `json:"patrol_analyze_nodes"` // Include Proxmox nodes in patrol
PatrolAnalyzeGuests bool `json:"patrol_analyze_guests"` // Include VMs/containers in patrol
PatrolAnalyzeDocker bool `json:"patrol_analyze_docker"` // Include Docker hosts in patrol
@@ -477,9 +477,7 @@ func (c *AIConfig) GetPatrolInterval() time.Duration {
// This provides better token efficiency for existing installations
if c.PatrolIntervalMinutes > 0 {
// Migrate old 15-minute default to new 6-hour default
- if c.PatrolIntervalMinutes == 15 && c.PatrolSchedulePreset == "" {
- return 6 * time.Hour
- }
+
return time.Duration(c.PatrolIntervalMinutes) * time.Minute
}
diff --git a/internal/config/ai_config_test.go b/internal/config/ai_config_test.go
index 490a07be4..615670840 100644
--- a/internal/config/ai_config_test.go
+++ b/internal/config/ai_config_test.go
@@ -1,6 +1,7 @@
package config
import (
+ "encoding/json"
"testing"
"time"
)
@@ -731,9 +732,9 @@ func TestAIConfig_GetPatrolInterval(t *testing.T) {
expected: 30 * time.Minute,
},
{
- name: "old 15min default migrated to 6hr",
+ name: "explicit 15min should stay 15min",
config: AIConfig{PatrolIntervalMinutes: 15},
- expected: 6 * time.Hour,
+ expected: 15 * time.Minute,
},
{
name: "default 6hr",
@@ -752,6 +753,38 @@ func TestAIConfig_GetPatrolInterval(t *testing.T) {
}
}
+func TestAIConfig_IntervalSurvivesRoundTrip(t *testing.T) {
+ // This test catches the bug where setting patrol_interval_minutes via the API
+ // clears PatrolSchedulePreset to "", but omitempty caused "" to be dropped
+ // from the JSON. On reload, NewDefaultAIConfig() re-introduced "6hr" as the
+ // preset, which took priority over the custom minutes.
+ cfg := NewDefaultAIConfig()
+ cfg.PatrolIntervalMinutes = 15
+ cfg.PatrolSchedulePreset = "" // Cleared by API handler when user sets custom minutes
+
+ // Simulate save → load round-trip via JSON
+ data, err := json.Marshal(cfg)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ loaded := NewDefaultAIConfig()
+ if err := json.Unmarshal(data, loaded); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ // The preset must be empty after round-trip, not the default "6hr"
+ if loaded.PatrolSchedulePreset != "" {
+ t.Errorf("PatrolSchedulePreset should be empty after round-trip, got %q", loaded.PatrolSchedulePreset)
+ }
+
+ // The interval must be the user's 15 minutes, not the default 6 hours
+ interval := loaded.GetPatrolInterval()
+ if interval != 15*time.Minute {
+ t.Errorf("GetPatrolInterval() = %v after round-trip, want 15m", interval)
+ }
+}
+
func TestPresetToMinutes(t *testing.T) {
tests := []struct {
preset string