@@ -14,6 +14,7 @@ import (
1414 "patchmon-agent/internal/client"
1515 "patchmon-agent/internal/integrations"
1616 "patchmon-agent/internal/integrations/docker"
17+ "patchmon-agent/internal/utils"
1718 "patchmon-agent/pkg/models"
1819
1920 "github.com/gorilla/websocket"
@@ -44,14 +45,106 @@ func runService() error {
4445 httpClient := client .New (cfgManager , logger )
4546 ctx := context .Background ()
4647
47- // obtain initial interval
48- intervalMinutes := 60
48+ // Get api_id for offset calculation
49+ apiId := cfgManager .GetCredentials ().APIID
50+
51+ // Load interval from config.yml (with default fallback)
52+ intervalMinutes := cfgManager .GetConfig ().UpdateInterval
53+ if intervalMinutes <= 0 {
54+ // Default to 60 if not set or invalid
55+ intervalMinutes = 60
56+ logger .WithField ("interval" , intervalMinutes ).Info ("Using default interval (not set in config)" )
57+ } else {
58+ logger .WithField ("interval" , intervalMinutes ).Info ("Loaded interval from config.yml" )
59+ }
60+
61+ // Fetch interval from server and update config if different
4962 if resp , err := httpClient .GetUpdateInterval (ctx ); err == nil && resp .UpdateInterval > 0 {
50- intervalMinutes = resp .UpdateInterval
63+ if resp .UpdateInterval != intervalMinutes {
64+ logger .WithFields (map [string ]interface {}{
65+ "config_interval" : intervalMinutes ,
66+ "server_interval" : resp .UpdateInterval ,
67+ }).Info ("Server interval differs from config, updating config.yml" )
68+
69+ if err := cfgManager .SetUpdateInterval (resp .UpdateInterval ); err != nil {
70+ logger .WithError (err ).Warn ("Failed to save interval to config.yml" )
71+ } else {
72+ intervalMinutes = resp .UpdateInterval
73+ logger .WithField ("interval" , intervalMinutes ).Info ("Updated interval in config.yml" )
74+ }
75+ }
76+ } else if err != nil {
77+ logger .WithError (err ).Warn ("Failed to fetch interval from server, using config value" )
5178 }
5279
53- ticker := time .NewTicker (time .Duration (intervalMinutes ) * time .Minute )
54- defer ticker .Stop ()
80+ // Fetch integration status from server and sync with config.yml
81+ logger .Info ("Syncing integration status from server..." )
82+ if integrationResp , err := httpClient .GetIntegrationStatus (ctx ); err == nil && integrationResp .Success {
83+ configUpdated := false
84+ for integrationName , serverEnabled := range integrationResp .Integrations {
85+ configEnabled := cfgManager .IsIntegrationEnabled (integrationName )
86+ if serverEnabled != configEnabled {
87+ logger .WithFields (map [string ]interface {}{
88+ "integration" : integrationName ,
89+ "config_value" : configEnabled ,
90+ "server_value" : serverEnabled ,
91+ }).Info ("Integration status differs, updating config.yml" )
92+
93+ if err := cfgManager .SetIntegrationEnabled (integrationName , serverEnabled ); err != nil {
94+ logger .WithError (err ).Warn ("Failed to save integration status to config.yml" )
95+ } else {
96+ configUpdated = true
97+ logger .WithFields (map [string ]interface {}{
98+ "integration" : integrationName ,
99+ "enabled" : serverEnabled ,
100+ }).Info ("Updated integration status in config.yml" )
101+ }
102+ }
103+ }
104+
105+ if configUpdated {
106+ // Reload config so in-memory state matches the updated file
107+ if err := cfgManager .LoadConfig (); err != nil {
108+ logger .WithError (err ).Warn ("Failed to reload config after integration update" )
109+ } else {
110+ logger .Info ("Config reloaded, integration settings will be applied" )
111+ }
112+ } else {
113+ logger .Debug ("Integration status matches config, no update needed" )
114+ }
115+ } else if err != nil {
116+ logger .WithError (err ).Warn ("Failed to fetch integration status from server, using config values" )
117+ }
118+
119+ // Load or calculate offset based on api_id to stagger reporting times
120+ var offset time.Duration
121+ configOffsetSeconds := cfgManager .GetConfig ().ReportOffset
122+
123+ // Calculate what the offset should be based on current api_id and interval
124+ calculatedOffset := utils .CalculateReportOffset (apiId , intervalMinutes )
125+ calculatedOffsetSeconds := int (calculatedOffset .Seconds ())
126+
127+ // Use config offset if it exists and matches calculated value, otherwise recalculate and save
128+ if configOffsetSeconds > 0 && configOffsetSeconds == calculatedOffsetSeconds {
129+ offset = time .Duration (configOffsetSeconds ) * time .Second
130+ logger .WithFields (map [string ]interface {}{
131+ "api_id" : apiId ,
132+ "interval_minutes" : intervalMinutes ,
133+ "offset_seconds" : offset .Seconds (),
134+ }).Info ("Loaded report offset from config.yml" )
135+ } else {
136+ // Offset not in config or doesn't match, calculate and save it
137+ offset = calculatedOffset
138+ if err := cfgManager .SetReportOffset (calculatedOffsetSeconds ); err != nil {
139+ logger .WithError (err ).Warn ("Failed to save offset to config.yml" )
140+ } else {
141+ logger .WithFields (map [string ]interface {}{
142+ "api_id" : apiId ,
143+ "interval_minutes" : intervalMinutes ,
144+ "offset_seconds" : offset .Seconds (),
145+ }).Info ("Calculated and saved report offset to config.yml" )
146+ }
147+ }
55148
56149 // Send startup ping to notify server that agent has started
57150 logger .Info ("🚀 Agent starting up, notifying server..." )
@@ -63,7 +156,7 @@ func runService() error {
63156
64157 // initial report on boot
65158 logger .Info ("Sending initial report on startup..." )
66- if err := sendReport (); err != nil {
159+ if err := sendReport (false ); err != nil {
67160 logger .WithError (err ).Warn ("initial report failed" )
68161 } else {
69162 logger .Info ("✅ Initial report sent successfully" )
@@ -78,22 +171,74 @@ func runService() error {
78171 // Start integration monitoring (Docker real-time events, etc.)
79172 startIntegrationMonitoring (ctx , dockerEvents )
80173
174+ // Create ticker with initial interval
175+ ticker := time .NewTicker (time .Duration (intervalMinutes ) * time .Minute )
176+ defer ticker .Stop ()
177+
178+ // Wait for offset before starting periodic reports
179+ // This staggers the reporting times across different agents
180+ offsetTimer := time .NewTimer (offset )
181+ defer offsetTimer .Stop ()
182+
183+ // Track whether offset period has passed
184+ offsetPassed := false
185+
186+ // Track current interval for offset recalculation on updates
187+ currentInterval := intervalMinutes
188+
81189 for {
82190 select {
191+ case <- offsetTimer .C :
192+ // Offset period completed, start consuming from ticker normally
193+ offsetPassed = true
194+ logger .Debug ("Offset period completed, periodic reports will now start" )
83195 case <- ticker .C :
84- if err := sendReport (); err != nil {
85- logger .WithError (err ).Warn ("periodic report failed" )
196+ // Only process ticker events after offset has passed
197+ if offsetPassed {
198+ if err := sendReport (false ); err != nil {
199+ logger .WithError (err ).Warn ("periodic report failed" )
200+ }
86201 }
87202 case m := <- messages :
88203 switch m .kind {
89204 case "settings_update" :
90- if m .interval > 0 {
205+ if m .interval > 0 && m .interval != currentInterval {
206+ // Save new interval to config.yml
207+ if err := cfgManager .SetUpdateInterval (m .interval ); err != nil {
208+ logger .WithError (err ).Warn ("Failed to save interval to config.yml" )
209+ } else {
210+ logger .WithField ("interval" , m .interval ).Info ("Saved new interval to config.yml" )
211+ }
212+
213+ // Recalculate offset for new interval and save to config.yml
214+ newOffset := utils .CalculateReportOffset (apiId , m .interval )
215+ newOffsetSeconds := int (newOffset .Seconds ())
216+ if err := cfgManager .SetReportOffset (newOffsetSeconds ); err != nil {
217+ logger .WithError (err ).Warn ("Failed to save offset to config.yml" )
218+ }
219+
220+ logger .WithFields (map [string ]interface {}{
221+ "old_interval" : currentInterval ,
222+ "new_interval" : m .interval ,
223+ "new_offset_seconds" : newOffset .Seconds (),
224+ }).Info ("Recalculated and saved offset for new interval" )
225+
226+ // Stop old ticker
91227 ticker .Stop ()
228+
229+ // Create new ticker with updated interval
92230 ticker = time .NewTicker (time .Duration (m .interval ) * time .Minute )
231+ currentInterval = m .interval
232+
233+ // Reset offset timer for new interval
234+ offsetTimer .Stop ()
235+ offsetTimer = time .NewTimer (newOffset )
236+ offsetPassed = false // Reset flag for new interval
237+
93238 logger .WithField ("new_interval" , m .interval ).Info ("interval updated, no report sent" )
94239 }
95240 case "report_now" :
96- if err := sendReport (); err != nil {
241+ if err := sendReport (false ); err != nil {
97242 logger .WithError (err ).Warn ("report_now failed" )
98243 }
99244 case "update_agent" :
@@ -352,7 +497,7 @@ func toggleIntegration(integrationName string, enabled bool) error {
352497 // Since we're running inside the service, we can't stop ourselves directly
353498 // Instead, we'll create a helper script that runs after we exit
354499 logger .Debug ("Detected OpenRC, scheduling service restart via helper script" )
355-
500+
356501 // Create a helper script that will restart the service after we exit
357502 helperScript := `#!/bin/sh
358503# Wait a moment for the current process to exit
@@ -385,7 +530,7 @@ rm -f "$0"
385530 os .Exit (0 )
386531 }
387532 }
388-
533+
389534 // Fallback: If helper script approach failed, just exit and let OpenRC handle it
390535 // OpenRC with command_background="yes" should restart on exit
391536 logger .Info ("Exiting to allow OpenRC to restart service with updated config..." )
0 commit comments