Fix: patrol interval not applied — omitempty caused preset to persist across reloads

The "Every" dropdown on the Patrol page was not being respected. Setting
15 min would show "Runs every 6 hours" and the countdown timer was wrong.

Root cause: PatrolSchedulePreset and PatrolIntervalMinutes had omitempty
JSON tags. When the API handler cleared the preset to "", json.Marshal
dropped the field. On reload, NewDefaultAIConfig() re-introduced "6hr"
as the preset, which took priority over the user's custom minutes.

Additional fixes in the same area:
- Track nextScheduledAt explicitly in the patrol loop so next_patrol_at
  reflects the actual ticker schedule, not a stale lastPatrol + interval
  calculation that diverges when the interval changes mid-cycle.
- Refetch patrol status in the frontend after an interval change so the
  countdown timer updates immediately.
- Seed lastPatrol from persisted run history on startup so the header
  countdown timer appears immediately after a backend restart.
This commit is contained in:
rcourtman
2026-02-02 22:53:24 +00:00
parent bfa648ddd5
commit eed80e2883
6 changed files with 215 additions and 114 deletions
+104 -98
View File
@@ -49,7 +49,7 @@ import CheckCircleIcon from 'lucide-solid/icons/check-circle';
import SettingsIcon from 'lucide-solid/icons/settings';
import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo';
import { TogglePrimitive, Toggle } from '@/components/shared/Toggle';
import { ApprovalBanner, PatrolStatusBar, RunHistoryPanel } from '@/components/patrol';
import { ApprovalBanner, PatrolStatusBar, RunHistoryPanel, CountdownTimer } from '@/components/patrol';
import { usePatrolStream } from '@/hooks/usePatrolStream';
import { hasFeature } from '@/stores/license';
import {
@@ -306,6 +306,8 @@ export function AIIntelligence() {
});
setPatrolInterval(minutes);
setPatrolEnabledLocal(minutes > 0);
// Refetch patrol status so the countdown timer reflects the new interval
refetchPatrolStatus();
} catch (err) {
console.error('Failed to update patrol interval:', err);
notificationStore.error('Failed to update patrol schedule');
@@ -589,7 +591,11 @@ export function AIIntelligence() {
<span>Last: {formatRelativeTime(patrolStatus()?.last_patrol_at)}</span>
<Show when={patrolStatus()?.next_patrol_at}>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span>Next: {formatRelativeTime(patrolStatus()?.next_patrol_at)}</span>
<CountdownTimer
targetDate={patrolStatus()!.next_patrol_at!}
prefix="Next run: "
className="font-variant-numeric tabular-nums font-medium text-blue-600 dark:text-blue-400"
/>
</Show>
</div>
</Show>
@@ -738,109 +744,109 @@ export function AIIntelligence() {
{/* Advanced Settings Gear — hidden for free users since all settings are Pro-only */}
<Show when={!autoFixLocked() || !alertAnalysisLocked()}>
<div class="relative" ref={advancedSettingsRef}>
<button
onClick={() => setShowAdvancedSettings(!showAdvancedSettings())}
disabled={!patrolEnabledLocal()}
class={`p-1 rounded transition-colors ${showAdvancedSettings()
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
} ${!patrolEnabledLocal() ? 'opacity-50 cursor-not-allowed' : ''}`}
title="Advanced investigation settings"
>
<SettingsIcon class="w-4 h-4" />
</button>
<div class="relative" ref={advancedSettingsRef}>
<button
onClick={() => setShowAdvancedSettings(!showAdvancedSettings())}
disabled={!patrolEnabledLocal()}
class={`p-1 rounded transition-colors ${showAdvancedSettings()
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
} ${!patrolEnabledLocal() ? 'opacity-50 cursor-not-allowed' : ''}`}
title="Advanced investigation settings"
>
<SettingsIcon class="w-4 h-4" />
</button>
{/* Advanced Settings Popover */}
<Show when={showAdvancedSettings()}>
<div class="absolute right-0 top-8 z-50 w-72 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">Advanced Settings</h4>
<button
onClick={() => setShowAdvancedSettings(false)}
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<XIcon class="w-4 h-4" />
</button>
</div>
<div class="space-y-4">
{/* Auto-fix critical issues toggle */}
<div>
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<label class="text-xs font-medium text-red-600 dark:text-red-400">Auto-fix critical issues</label>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
When enabled, Patrol will automatically fix critical issues without requiring your approval.
</p>
</div>
<Toggle
checked={!autoFixLocked() && fullModeUnlocked()}
onChange={(e) => setFullModeUnlocked(e.currentTarget.checked)}
disabled={autoFixLocked() || !(autonomyLevel() === 'assisted' || autonomyLevel() === 'full')}
/>
</div>
<Show when={autoFixLocked()}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">
<a class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline" href="https://pulserelay.pro/" target="_blank" rel="noreferrer">Upgrade to Pro</a>
{' '}to unlock auto-fix.
</p>
</Show>
<Show when={!autoFixLocked() && !(autonomyLevel() === 'assisted' || autonomyLevel() === 'full')}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">
Select Auto-fix mode to configure this setting.
</p>
</Show>
<Show when={!autoFixLocked() && fullModeUnlocked() && (autonomyLevel() === 'assisted' || autonomyLevel() === 'full')}>
<p class="text-[10px] text-amber-600 dark:text-amber-400 mt-2 flex items-center gap-1">
<ShieldAlertIcon class="w-3 h-3 flex-shrink-0" />
Critical issues will be auto-fixed without approval. Click Save to apply.
</p>
</Show>
{/* Advanced Settings Popover */}
<Show when={showAdvancedSettings()}>
<div class="absolute right-0 top-8 z-50 w-72 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">Advanced Settings</h4>
<button
onClick={() => setShowAdvancedSettings(false)}
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<XIcon class="w-4 h-4" />
</button>
</div>
{/* Alert-Triggered Analysis */}
<div class="pt-3 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between gap-3">
<div class="flex-1">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
Alert-Triggered Analysis
</label>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
Analyze infrastructure when alerts fire.
</p>
<div class="space-y-4">
{/* Auto-fix critical issues toggle */}
<div>
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<label class="text-xs font-medium text-red-600 dark:text-red-400">Auto-fix critical issues</label>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
When enabled, Patrol will automatically fix critical issues without requiring your approval.
</p>
</div>
<Toggle
checked={!autoFixLocked() && fullModeUnlocked()}
onChange={(e) => setFullModeUnlocked(e.currentTarget.checked)}
disabled={autoFixLocked() || !(autonomyLevel() === 'assisted' || autonomyLevel() === 'full')}
/>
</div>
<Toggle
checked={alertTriggeredAnalysis()}
onChange={(e) => handleAlertTriggeredAnalysisChange(e.currentTarget.checked)}
disabled={isUpdatingSettings() || alertAnalysisLocked()}
/>
<Show when={autoFixLocked()}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">
<a class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline" href="https://pulserelay.pro/" target="_blank" rel="noreferrer">Upgrade to Pro</a>
{' '}to unlock auto-fix.
</p>
</Show>
<Show when={!autoFixLocked() && !(autonomyLevel() === 'assisted' || autonomyLevel() === 'full')}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">
Select Auto-fix mode to configure this setting.
</p>
</Show>
<Show when={!autoFixLocked() && fullModeUnlocked() && (autonomyLevel() === 'assisted' || autonomyLevel() === 'full')}>
<p class="text-[10px] text-amber-600 dark:text-amber-400 mt-2 flex items-center gap-1">
<ShieldAlertIcon class="w-3 h-3 flex-shrink-0" />
Critical issues will be auto-fixed without approval. Click Save to apply.
</p>
</Show>
</div>
<Show when={alertAnalysisLocked()}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">
<a class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline" href="https://pulserelay.pro/" target="_blank" rel="noreferrer">Upgrade to Pro</a>
{' '}to enable alert-triggered analysis.
</p>
</Show>
{/* Alert-Triggered Analysis */}
<div class="pt-3 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between gap-3">
<div class="flex-1">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
Alert-Triggered Analysis
</label>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
Analyze infrastructure when alerts fire.
</p>
</div>
<Toggle
checked={alertTriggeredAnalysis()}
onChange={(e) => handleAlertTriggeredAnalysisChange(e.currentTarget.checked)}
disabled={isUpdatingSettings() || alertAnalysisLocked()}
/>
</div>
<Show when={alertAnalysisLocked()}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">
<a class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline" href="https://pulserelay.pro/" target="_blank" rel="noreferrer">Upgrade to Pro</a>
{' '}to enable alert-triggered analysis.
</p>
</Show>
</div>
{/* Save Button (for investigation limits + full mode unlock) */}
<button
onClick={saveAdvancedSettings}
disabled={isSavingAdvanced()}
class="w-full px-3 py-2 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Show when={isSavingAdvanced()}>
<div class="animate-spin h-3 w-3 border-2 border-white border-t-transparent rounded-full"></div>
</Show>
<Show when={!isSavingAdvanced()}>Save</Show>
</button>
</div>
{/* Save Button (for investigation limits + full mode unlock) */}
<button
onClick={saveAdvancedSettings}
disabled={isSavingAdvanced()}
class="w-full px-3 py-2 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Show when={isSavingAdvanced()}>
<div class="animate-spin h-3 w-3 border-2 border-white border-t-transparent rounded-full"></div>
</Show>
<Show when={!isSavingAdvanced()}>Save</Show>
</button>
</div>
</div>
</Show>
</div>
</Show>
</div>
</Show>
</div>
</div>
+1
View File
@@ -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
+24 -3
View File
@@ -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
}
+48 -6
View File
@@ -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")
+3 -5
View File
@@ -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
}
+35 -2
View File
@@ -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