ScuttleBot
feat: configurable online timeout for agent presence Add online_timeout_secs to agent policy — controls how long since last heartbeat before an agent is considered offline. Default 120s. Configurable from the settings UI. Previously hardcoded as 2 minutes.
Commit
c68066e4704f87451f9a6a5ea565397124bbe4c7e7438dc27bc0819430ff0980
Parent
12ca93f7a2d9a59…
4 files changed
+3
+4
-3
+7
+25
-8
| --- cmd/scuttlebot/main.go | ||
| +++ cmd/scuttlebot/main.go | ||
| @@ -272,10 +272,13 @@ | ||
| 272 | 272 | } |
| 273 | 273 | } |
| 274 | 274 | if bridgeBot != nil { |
| 275 | 275 | bridgeBot.SetWebUserTTL(time.Duration(p.Bridge.WebUserTTLMinutes) * time.Minute) |
| 276 | 276 | } |
| 277 | + if p.AgentPolicy.OnlineTimeoutSecs > 0 { | |
| 278 | + reg.SetOnlineTimeout(time.Duration(p.AgentPolicy.OnlineTimeoutSecs) * time.Second) | |
| 279 | + } | |
| 277 | 280 | botMgr.Sync(ctx, specs) |
| 278 | 281 | }) |
| 279 | 282 | |
| 280 | 283 | // Initial bot sync from loaded policies. |
| 281 | 284 | { |
| 282 | 285 |
| --- cmd/scuttlebot/main.go | |
| +++ cmd/scuttlebot/main.go | |
| @@ -272,10 +272,13 @@ | |
| 272 | } |
| 273 | } |
| 274 | if bridgeBot != nil { |
| 275 | bridgeBot.SetWebUserTTL(time.Duration(p.Bridge.WebUserTTLMinutes) * time.Minute) |
| 276 | } |
| 277 | botMgr.Sync(ctx, specs) |
| 278 | }) |
| 279 | |
| 280 | // Initial bot sync from loaded policies. |
| 281 | { |
| 282 |
| --- cmd/scuttlebot/main.go | |
| +++ cmd/scuttlebot/main.go | |
| @@ -272,10 +272,13 @@ | |
| 272 | } |
| 273 | } |
| 274 | if bridgeBot != nil { |
| 275 | bridgeBot.SetWebUserTTL(time.Duration(p.Bridge.WebUserTTLMinutes) * time.Minute) |
| 276 | } |
| 277 | if p.AgentPolicy.OnlineTimeoutSecs > 0 { |
| 278 | reg.SetOnlineTimeout(time.Duration(p.AgentPolicy.OnlineTimeoutSecs) * time.Second) |
| 279 | } |
| 280 | botMgr.Sync(ctx, specs) |
| 281 | }) |
| 282 | |
| 283 | // Initial bot sync from loaded policies. |
| 284 | { |
| 285 |
+4
-3
| --- internal/api/policies.go | ||
| +++ internal/api/policies.go | ||
| @@ -25,13 +25,14 @@ | ||
| 25 | 25 | Config map[string]any `json:"config,omitempty"` |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | 28 | // AgentPolicy defines requirements applied to all registering agents. |
| 29 | 29 | type AgentPolicy struct { |
| 30 | - RequireCheckin bool `json:"require_checkin"` | |
| 31 | - CheckinChannel string `json:"checkin_channel"` | |
| 32 | - RequiredChannels []string `json:"required_channels"` | |
| 30 | + RequireCheckin bool `json:"require_checkin"` | |
| 31 | + CheckinChannel string `json:"checkin_channel"` | |
| 32 | + RequiredChannels []string `json:"required_channels"` | |
| 33 | + OnlineTimeoutSecs int `json:"online_timeout_secs,omitempty"` | |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | 36 | // LoggingPolicy configures message logging. |
| 36 | 37 | type LoggingPolicy struct { |
| 37 | 38 | Enabled bool `json:"enabled"` |
| 38 | 39 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -25,13 +25,14 @@ | |
| 25 | Config map[string]any `json:"config,omitempty"` |
| 26 | } |
| 27 | |
| 28 | // AgentPolicy defines requirements applied to all registering agents. |
| 29 | type AgentPolicy struct { |
| 30 | RequireCheckin bool `json:"require_checkin"` |
| 31 | CheckinChannel string `json:"checkin_channel"` |
| 32 | RequiredChannels []string `json:"required_channels"` |
| 33 | } |
| 34 | |
| 35 | // LoggingPolicy configures message logging. |
| 36 | type LoggingPolicy struct { |
| 37 | Enabled bool `json:"enabled"` |
| 38 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -25,13 +25,14 @@ | |
| 25 | Config map[string]any `json:"config,omitempty"` |
| 26 | } |
| 27 | |
| 28 | // AgentPolicy defines requirements applied to all registering agents. |
| 29 | type AgentPolicy struct { |
| 30 | RequireCheckin bool `json:"require_checkin"` |
| 31 | CheckinChannel string `json:"checkin_channel"` |
| 32 | RequiredChannels []string `json:"required_channels"` |
| 33 | OnlineTimeoutSecs int `json:"online_timeout_secs,omitempty"` |
| 34 | } |
| 35 | |
| 36 | // LoggingPolicy configures message logging. |
| 37 | type LoggingPolicy struct { |
| 38 | Enabled bool `json:"enabled"` |
| 39 |
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -588,10 +588,15 @@ | ||
| 588 | 588 | <div class="setting-row"> |
| 589 | 589 | <div class="setting-label">required channels</div> |
| 590 | 590 | <div class="setting-desc">Channels every agent is added to automatically.</div> |
| 591 | 591 | <input type="text" id="policy-required-channels" placeholder="#fleet, #alerts" style="width:220px;padding:4px 8px;font-size:12px"> |
| 592 | 592 | </div> |
| 593 | + <div class="setting-row"> | |
| 594 | + <div class="setting-label">online timeout</div> | |
| 595 | + <div class="setting-desc">Seconds since last heartbeat before an agent is considered offline. Default: 120.</div> | |
| 596 | + <input type="number" id="policy-online-timeout" placeholder="120" min="10" max="3600" style="width:100px;padding:4px 8px;font-size:12px"> | |
| 597 | + </div> | |
| 593 | 598 | </div> |
| 594 | 599 | <div id="agentpolicy-save-result" style="display:none;margin:0 16px 12px"></div> |
| 595 | 600 | </div> |
| 596 | 601 | |
| 597 | 602 | <!-- bridge --> |
| @@ -2703,10 +2708,11 @@ | ||
| 2703 | 2708 | |
| 2704 | 2709 | function renderAgentPolicy(p) { |
| 2705 | 2710 | document.getElementById('policy-checkin-enabled').checked = !!p.require_checkin; |
| 2706 | 2711 | document.getElementById('policy-checkin-channel').value = p.checkin_channel || ''; |
| 2707 | 2712 | document.getElementById('policy-required-channels').value = (p.required_channels||[]).join(', '); |
| 2713 | + document.getElementById('policy-online-timeout').value = p.online_timeout_secs || ''; | |
| 2708 | 2714 | toggleCheckinChannel(); |
| 2709 | 2715 | } |
| 2710 | 2716 | function toggleCheckinChannel() { |
| 2711 | 2717 | const on = document.getElementById('policy-checkin-enabled').checked; |
| 2712 | 2718 | document.getElementById('policy-checkin-row').style.display = on ? '' : 'none'; |
| @@ -2959,10 +2965,11 @@ | ||
| 2959 | 2965 | saveConfigPatch({ |
| 2960 | 2966 | agent_policy: { |
| 2961 | 2967 | require_checkin: document.getElementById('policy-checkin-enabled').checked, |
| 2962 | 2968 | checkin_channel: document.getElementById('policy-checkin-channel').value.trim(), |
| 2963 | 2969 | required_channels: document.getElementById('policy-required-channels').value.split(',').map(s=>s.trim()).filter(Boolean), |
| 2970 | + online_timeout_secs: parseInt(document.getElementById('policy-online-timeout').value) || 0, | |
| 2964 | 2971 | } |
| 2965 | 2972 | }, 'agentpolicy-save-result'); |
| 2966 | 2973 | } |
| 2967 | 2974 | |
| 2968 | 2975 | function saveBridgeConfig() { |
| 2969 | 2976 |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -588,10 +588,15 @@ | |
| 588 | <div class="setting-row"> |
| 589 | <div class="setting-label">required channels</div> |
| 590 | <div class="setting-desc">Channels every agent is added to automatically.</div> |
| 591 | <input type="text" id="policy-required-channels" placeholder="#fleet, #alerts" style="width:220px;padding:4px 8px;font-size:12px"> |
| 592 | </div> |
| 593 | </div> |
| 594 | <div id="agentpolicy-save-result" style="display:none;margin:0 16px 12px"></div> |
| 595 | </div> |
| 596 | |
| 597 | <!-- bridge --> |
| @@ -2703,10 +2708,11 @@ | |
| 2703 | |
| 2704 | function renderAgentPolicy(p) { |
| 2705 | document.getElementById('policy-checkin-enabled').checked = !!p.require_checkin; |
| 2706 | document.getElementById('policy-checkin-channel').value = p.checkin_channel || ''; |
| 2707 | document.getElementById('policy-required-channels').value = (p.required_channels||[]).join(', '); |
| 2708 | toggleCheckinChannel(); |
| 2709 | } |
| 2710 | function toggleCheckinChannel() { |
| 2711 | const on = document.getElementById('policy-checkin-enabled').checked; |
| 2712 | document.getElementById('policy-checkin-row').style.display = on ? '' : 'none'; |
| @@ -2959,10 +2965,11 @@ | |
| 2959 | saveConfigPatch({ |
| 2960 | agent_policy: { |
| 2961 | require_checkin: document.getElementById('policy-checkin-enabled').checked, |
| 2962 | checkin_channel: document.getElementById('policy-checkin-channel').value.trim(), |
| 2963 | required_channels: document.getElementById('policy-required-channels').value.split(',').map(s=>s.trim()).filter(Boolean), |
| 2964 | } |
| 2965 | }, 'agentpolicy-save-result'); |
| 2966 | } |
| 2967 | |
| 2968 | function saveBridgeConfig() { |
| 2969 |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -588,10 +588,15 @@ | |
| 588 | <div class="setting-row"> |
| 589 | <div class="setting-label">required channels</div> |
| 590 | <div class="setting-desc">Channels every agent is added to automatically.</div> |
| 591 | <input type="text" id="policy-required-channels" placeholder="#fleet, #alerts" style="width:220px;padding:4px 8px;font-size:12px"> |
| 592 | </div> |
| 593 | <div class="setting-row"> |
| 594 | <div class="setting-label">online timeout</div> |
| 595 | <div class="setting-desc">Seconds since last heartbeat before an agent is considered offline. Default: 120.</div> |
| 596 | <input type="number" id="policy-online-timeout" placeholder="120" min="10" max="3600" style="width:100px;padding:4px 8px;font-size:12px"> |
| 597 | </div> |
| 598 | </div> |
| 599 | <div id="agentpolicy-save-result" style="display:none;margin:0 16px 12px"></div> |
| 600 | </div> |
| 601 | |
| 602 | <!-- bridge --> |
| @@ -2703,10 +2708,11 @@ | |
| 2708 | |
| 2709 | function renderAgentPolicy(p) { |
| 2710 | document.getElementById('policy-checkin-enabled').checked = !!p.require_checkin; |
| 2711 | document.getElementById('policy-checkin-channel').value = p.checkin_channel || ''; |
| 2712 | document.getElementById('policy-required-channels').value = (p.required_channels||[]).join(', '); |
| 2713 | document.getElementById('policy-online-timeout').value = p.online_timeout_secs || ''; |
| 2714 | toggleCheckinChannel(); |
| 2715 | } |
| 2716 | function toggleCheckinChannel() { |
| 2717 | const on = document.getElementById('policy-checkin-enabled').checked; |
| 2718 | document.getElementById('policy-checkin-row').style.display = on ? '' : 'none'; |
| @@ -2959,10 +2965,11 @@ | |
| 2965 | saveConfigPatch({ |
| 2966 | agent_policy: { |
| 2967 | require_checkin: document.getElementById('policy-checkin-enabled').checked, |
| 2968 | checkin_channel: document.getElementById('policy-checkin-channel').value.trim(), |
| 2969 | required_channels: document.getElementById('policy-required-channels').value.split(',').map(s=>s.trim()).filter(Boolean), |
| 2970 | online_timeout_secs: parseInt(document.getElementById('policy-online-timeout').value) || 0, |
| 2971 | } |
| 2972 | }, 'agentpolicy-save-result'); |
| 2973 | } |
| 2974 | |
| 2975 | function saveBridgeConfig() { |
| 2976 |
+25
-8
| --- internal/registry/registry.go | ||
| +++ internal/registry/registry.go | ||
| @@ -72,16 +72,17 @@ | ||
| 72 | 72 | ChangePassword(name, passphrase string) error |
| 73 | 73 | } |
| 74 | 74 | |
| 75 | 75 | // Registry manages registered agents and their credentials. |
| 76 | 76 | type Registry struct { |
| 77 | - mu sync.RWMutex | |
| 78 | - agents map[string]*Agent // keyed by nick | |
| 79 | - provisioner AccountProvisioner | |
| 80 | - signingKey []byte | |
| 81 | - dataPath string // path to persist agents JSON; empty = no persistence | |
| 82 | - db *store.Store // when non-nil, supersedes dataPath | |
| 77 | + mu sync.RWMutex | |
| 78 | + agents map[string]*Agent // keyed by nick | |
| 79 | + provisioner AccountProvisioner | |
| 80 | + signingKey []byte | |
| 81 | + dataPath string // path to persist agents JSON; empty = no persistence | |
| 82 | + db *store.Store // when non-nil, supersedes dataPath | |
| 83 | + onlineTimeout time.Duration | |
| 83 | 84 | } |
| 84 | 85 | |
| 85 | 86 | // New creates a new Registry with the given provisioner and HMAC signing key. |
| 86 | 87 | // Call SetDataPath to enable persistence before registering any agents. |
| 87 | 88 | func New(provisioner AccountProvisioner, signingKey []byte) *Registry { |
| @@ -384,20 +385,36 @@ | ||
| 384 | 385 | now := time.Now() |
| 385 | 386 | a.LastSeen = &now |
| 386 | 387 | // Don't persist every heartbeat — just keep in memory. |
| 387 | 388 | } |
| 388 | 389 | |
| 389 | -const onlineThreshold = 2 * time.Minute | |
| 390 | +const defaultOnlineTimeout = 2 * time.Minute | |
| 391 | + | |
| 392 | +// SetOnlineTimeout configures how long since last_seen before an agent | |
| 393 | +// is considered offline. Pass 0 to reset to the default (2 minutes). | |
| 394 | +func (r *Registry) SetOnlineTimeout(d time.Duration) { | |
| 395 | + r.mu.Lock() | |
| 396 | + defer r.mu.Unlock() | |
| 397 | + r.onlineTimeout = d | |
| 398 | +} | |
| 399 | + | |
| 400 | +func (r *Registry) getOnlineTimeout() time.Duration { | |
| 401 | + if r.onlineTimeout > 0 { | |
| 402 | + return r.onlineTimeout | |
| 403 | + } | |
| 404 | + return defaultOnlineTimeout | |
| 405 | +} | |
| 390 | 406 | |
| 391 | 407 | // List returns all registered agents with computed online status. |
| 392 | 408 | func (r *Registry) List() []*Agent { |
| 393 | 409 | r.mu.RLock() |
| 394 | 410 | defer r.mu.RUnlock() |
| 411 | + threshold := r.getOnlineTimeout() | |
| 395 | 412 | now := time.Now() |
| 396 | 413 | var out []*Agent |
| 397 | 414 | for _, a := range r.agents { |
| 398 | - a.Online = a.LastSeen != nil && now.Sub(*a.LastSeen) < onlineThreshold | |
| 415 | + a.Online = a.LastSeen != nil && now.Sub(*a.LastSeen) < threshold | |
| 399 | 416 | out = append(out, a) |
| 400 | 417 | } |
| 401 | 418 | return out |
| 402 | 419 | } |
| 403 | 420 | |
| 404 | 421 |
| --- internal/registry/registry.go | |
| +++ internal/registry/registry.go | |
| @@ -72,16 +72,17 @@ | |
| 72 | ChangePassword(name, passphrase string) error |
| 73 | } |
| 74 | |
| 75 | // Registry manages registered agents and their credentials. |
| 76 | type Registry struct { |
| 77 | mu sync.RWMutex |
| 78 | agents map[string]*Agent // keyed by nick |
| 79 | provisioner AccountProvisioner |
| 80 | signingKey []byte |
| 81 | dataPath string // path to persist agents JSON; empty = no persistence |
| 82 | db *store.Store // when non-nil, supersedes dataPath |
| 83 | } |
| 84 | |
| 85 | // New creates a new Registry with the given provisioner and HMAC signing key. |
| 86 | // Call SetDataPath to enable persistence before registering any agents. |
| 87 | func New(provisioner AccountProvisioner, signingKey []byte) *Registry { |
| @@ -384,20 +385,36 @@ | |
| 384 | now := time.Now() |
| 385 | a.LastSeen = &now |
| 386 | // Don't persist every heartbeat — just keep in memory. |
| 387 | } |
| 388 | |
| 389 | const onlineThreshold = 2 * time.Minute |
| 390 | |
| 391 | // List returns all registered agents with computed online status. |
| 392 | func (r *Registry) List() []*Agent { |
| 393 | r.mu.RLock() |
| 394 | defer r.mu.RUnlock() |
| 395 | now := time.Now() |
| 396 | var out []*Agent |
| 397 | for _, a := range r.agents { |
| 398 | a.Online = a.LastSeen != nil && now.Sub(*a.LastSeen) < onlineThreshold |
| 399 | out = append(out, a) |
| 400 | } |
| 401 | return out |
| 402 | } |
| 403 | |
| 404 |
| --- internal/registry/registry.go | |
| +++ internal/registry/registry.go | |
| @@ -72,16 +72,17 @@ | |
| 72 | ChangePassword(name, passphrase string) error |
| 73 | } |
| 74 | |
| 75 | // Registry manages registered agents and their credentials. |
| 76 | type Registry struct { |
| 77 | mu sync.RWMutex |
| 78 | agents map[string]*Agent // keyed by nick |
| 79 | provisioner AccountProvisioner |
| 80 | signingKey []byte |
| 81 | dataPath string // path to persist agents JSON; empty = no persistence |
| 82 | db *store.Store // when non-nil, supersedes dataPath |
| 83 | onlineTimeout time.Duration |
| 84 | } |
| 85 | |
| 86 | // New creates a new Registry with the given provisioner and HMAC signing key. |
| 87 | // Call SetDataPath to enable persistence before registering any agents. |
| 88 | func New(provisioner AccountProvisioner, signingKey []byte) *Registry { |
| @@ -384,20 +385,36 @@ | |
| 385 | now := time.Now() |
| 386 | a.LastSeen = &now |
| 387 | // Don't persist every heartbeat — just keep in memory. |
| 388 | } |
| 389 | |
| 390 | const defaultOnlineTimeout = 2 * time.Minute |
| 391 | |
| 392 | // SetOnlineTimeout configures how long since last_seen before an agent |
| 393 | // is considered offline. Pass 0 to reset to the default (2 minutes). |
| 394 | func (r *Registry) SetOnlineTimeout(d time.Duration) { |
| 395 | r.mu.Lock() |
| 396 | defer r.mu.Unlock() |
| 397 | r.onlineTimeout = d |
| 398 | } |
| 399 | |
| 400 | func (r *Registry) getOnlineTimeout() time.Duration { |
| 401 | if r.onlineTimeout > 0 { |
| 402 | return r.onlineTimeout |
| 403 | } |
| 404 | return defaultOnlineTimeout |
| 405 | } |
| 406 | |
| 407 | // List returns all registered agents with computed online status. |
| 408 | func (r *Registry) List() []*Agent { |
| 409 | r.mu.RLock() |
| 410 | defer r.mu.RUnlock() |
| 411 | threshold := r.getOnlineTimeout() |
| 412 | now := time.Now() |
| 413 | var out []*Agent |
| 414 | for _, a := range r.agents { |
| 415 | a.Online = a.LastSeen != nil && now.Sub(*a.LastSeen) < threshold |
| 416 | out = append(out, a) |
| 417 | } |
| 418 | return out |
| 419 | } |
| 420 | |
| 421 |