feat: agent resilience knobs, shared polling loop, settings restructure
Extract the agent polling loop from main.go into internal/agent/loop.go so the Windows service and the CLI agent share one code path. The loop now reads jitter cap and backoff curve from PollingConfig (struct with merge + file/env defaults) instead of hardcoding 30s/10s/300s. Machine ID resolution uses the canonical system.GetMachineID() in both the registration and runtime paths, removing the inline 'unknown-' fallback. Stuck command retries are parameterized (maxRetries arg) rather than hardcoded to < 5. Restructure settings into a uniform hub-of-cards pattern: extract inline Account Settings into /settings/general, un-orphan SecuritySettings with working /settings/security/:tab routes. Add fleet-wide polling resilience tuning (jitter_max_seconds, backoff_base_seconds, backoff_max_seconds) as operational settings — stored in security_settings, delivered over GET /api/v1/agents/:id/config, merged into the agent's local config at runtime with a 15-minute refresh cadence. Frontend includes AgentPolling page, hook, hub card, and route.
This commit is contained in:
parent
487ffa89ef
commit
d18b0f8a02
23 changed files with 714 additions and 197 deletions
|
|
@ -182,6 +182,7 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
|
||||
consecutiveFailures := 0
|
||||
lastSystemInfoUpdate := time.Time{}
|
||||
lastConfigRefresh := time.Time{} // zero → refresh on first successful check-in
|
||||
postUpdateCleanupDone := false
|
||||
|
||||
for {
|
||||
|
|
@ -202,8 +203,9 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
}
|
||||
|
||||
maxJitter := pollingInterval / 2
|
||||
if maxJitter > 30*time.Second {
|
||||
maxJitter = 30 * time.Second
|
||||
jitterCap := time.Duration(resolveJitterMaxSeconds(ctx.Cfg)) * time.Second
|
||||
if maxJitter > jitterCap {
|
||||
maxJitter = jitterCap
|
||||
}
|
||||
if maxJitter < 1*time.Second {
|
||||
maxJitter = 1 * time.Second
|
||||
|
|
@ -259,7 +261,7 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
}
|
||||
}
|
||||
consecutiveFailures++
|
||||
backoffDelay := calculateBackoff(consecutiveFailures)
|
||||
backoffDelay := calculateBackoff(consecutiveFailures, resolveBackoffBase(ctx.Cfg), resolveBackoffMax(ctx.Cfg))
|
||||
log.Printf("[WARNING] Server unavailable (attempt %d), retrying in %s: %v", consecutiveFailures, backoffDelay, err)
|
||||
|
||||
// Non-blocking stop check during backoff
|
||||
|
|
@ -285,6 +287,27 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
postUpdateCleanupDone = true
|
||||
}
|
||||
|
||||
// Refresh fleet-wide operational config (polling resilience tuning) on
|
||||
// first check-in and periodically thereafter. Server is the source of
|
||||
// the fleet default; non-zero values are merged into the local config so
|
||||
// an operator change in the dashboard propagates without touching hosts.
|
||||
// Failure here is non-fatal — the agent keeps its current tuning.
|
||||
if time.Since(lastConfigRefresh) >= 15*time.Minute {
|
||||
if cfgResp, err := ctx.APIClient.GetConfig(ctx.Cfg.AgentID); err != nil {
|
||||
log.Printf("[WARNING] [agent] [config] config_refresh_failed error=%v", err)
|
||||
} else {
|
||||
if applyServerPolling(ctx.Cfg, cfgResp) {
|
||||
if saveErr := ctx.Cfg.Save(constants.GetAgentConfigPath()); saveErr != nil {
|
||||
log.Printf("[ERROR] [agent] [config] polling_persist_failed error=%v", saveErr)
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [config] polling_updated jitter=%d backoff_base=%d backoff_max=%d",
|
||||
ctx.Cfg.Polling.JitterMaxSeconds, ctx.Cfg.Polling.BackoffBaseSeconds, ctx.Cfg.Polling.BackoffMaxSeconds)
|
||||
}
|
||||
}
|
||||
lastConfigRefresh = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// Log check-in success event [TD-003]
|
||||
ctx.APIClient.BufferEvent(models.EventTypeAgentCheckIn, models.SubtypeSuccess, models.SeverityInfo,
|
||||
models.ComponentAgent, "Agent checked in successfully", map[string]interface{}{
|
||||
|
|
@ -511,14 +534,69 @@ func processCommands(ctx *LoopContext, commands []client.Command) {
|
|||
}
|
||||
}
|
||||
|
||||
// calculateBackoff returns exponential backoff delay
|
||||
func calculateBackoff(attempt int) time.Duration {
|
||||
base := 10 * time.Second
|
||||
cap := 5 * time.Minute
|
||||
// Polling resilience defaults. Applied when the config carries no override
|
||||
// (zero value), so older config files and unset server policy keep working.
|
||||
const (
|
||||
defaultJitterMaxSeconds = 30
|
||||
defaultBackoffBaseSeconds = 10
|
||||
defaultBackoffMaxSeconds = 300
|
||||
)
|
||||
|
||||
// resolveJitterMaxSeconds returns the operator-configured jitter cap, or the
|
||||
// built-in default when unset.
|
||||
func resolveJitterMaxSeconds(cfg *config.Config) int {
|
||||
if cfg != nil && cfg.Polling.JitterMaxSeconds > 0 {
|
||||
return cfg.Polling.JitterMaxSeconds
|
||||
}
|
||||
return defaultJitterMaxSeconds
|
||||
}
|
||||
|
||||
func resolveBackoffBase(cfg *config.Config) time.Duration {
|
||||
if cfg != nil && cfg.Polling.BackoffBaseSeconds > 0 {
|
||||
return time.Duration(cfg.Polling.BackoffBaseSeconds) * time.Second
|
||||
}
|
||||
return defaultBackoffBaseSeconds * time.Second
|
||||
}
|
||||
|
||||
func resolveBackoffMax(cfg *config.Config) time.Duration {
|
||||
if cfg != nil && cfg.Polling.BackoffMaxSeconds > 0 {
|
||||
return time.Duration(cfg.Polling.BackoffMaxSeconds) * time.Second
|
||||
}
|
||||
return defaultBackoffMaxSeconds * time.Second
|
||||
}
|
||||
|
||||
// applyServerPolling merges server-delivered polling tuning into cfg.Polling.
|
||||
// It returns true if any value changed. Zero/absent server values are ignored
|
||||
// so the agent keeps its current (possibly locally-configured) tuning rather
|
||||
// than being reset to zero. The polling loop reads cfg.Polling through the
|
||||
// resolve* helpers on every iteration, so a merged change takes effect next
|
||||
// loop without a restart.
|
||||
func applyServerPolling(cfg *config.Config, resp *client.AgentConfigResponse) bool {
|
||||
if cfg == nil || resp == nil || resp.Polling == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
if v := resp.Polling.JitterMaxSeconds; v > 0 && v != cfg.Polling.JitterMaxSeconds {
|
||||
cfg.Polling.JitterMaxSeconds = v
|
||||
changed = true
|
||||
}
|
||||
if v := resp.Polling.BackoffBaseSeconds; v > 0 && v != cfg.Polling.BackoffBaseSeconds {
|
||||
cfg.Polling.BackoffBaseSeconds = v
|
||||
changed = true
|
||||
}
|
||||
if v := resp.Polling.BackoffMaxSeconds; v > 0 && v != cfg.Polling.BackoffMaxSeconds {
|
||||
cfg.Polling.BackoffMaxSeconds = v
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// calculateBackoff returns an exponential backoff delay with full jitter,
|
||||
// bounded by the admin-adjustable base (floor) and maxDelay (ceiling).
|
||||
func calculateBackoff(attempt int, base, maxDelay time.Duration) time.Duration {
|
||||
ceiling := base * time.Duration(1<<uint(attempt))
|
||||
if ceiling > cap || ceiling <= 0 {
|
||||
ceiling = cap
|
||||
if ceiling > maxDelay || ceiling <= 0 {
|
||||
ceiling = maxDelay
|
||||
}
|
||||
|
||||
delay := time.Duration(rand.Int63n(int64(ceiling)))
|
||||
|
|
|
|||
|
|
@ -327,7 +327,7 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
|
|||
"hostname": req.Hostname,
|
||||
"server_url": c.baseURL,
|
||||
})
|
||||
return nil, fmt.Errorf(errorMsg)
|
||||
return nil, fmt.Errorf("%s", errorMsg)
|
||||
}
|
||||
|
||||
var result RegisterResponse
|
||||
|
|
@ -423,7 +423,7 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion
|
|||
"agent_id": agentID.String(),
|
||||
"server_url": c.baseURL,
|
||||
})
|
||||
return fmt.Errorf(errorMsg)
|
||||
return fmt.Errorf("%s", errorMsg)
|
||||
}
|
||||
|
||||
var result TokenRenewalResponse
|
||||
|
|
@ -1154,12 +1154,21 @@ func parseOSRelease(data []byte) string {
|
|||
return "Linux"
|
||||
}
|
||||
|
||||
// AgentConfigResponse contains subsystem configuration from server
|
||||
// AgentConfigResponse contains configuration delivered by the server.
|
||||
type AgentConfigResponse struct {
|
||||
Subsystems map[string]interface{} `json:"subsystems"`
|
||||
Polling *PollingConfigResponse `json:"polling,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
// PollingConfigResponse carries fleet-wide polling resilience tuning from the
|
||||
// server. The agent merges non-zero values into its local config.PollingConfig.
|
||||
type PollingConfigResponse struct {
|
||||
JitterMaxSeconds int `json:"jitter_max_seconds"`
|
||||
BackoffBaseSeconds int `json:"backoff_base_seconds"`
|
||||
BackoffMaxSeconds int `json:"backoff_max_seconds"`
|
||||
}
|
||||
|
||||
// GetConfig retrieves current subsystem configuration from server
|
||||
func (c *Client) GetConfig(agentID uuid.UUID) (*AgentConfigResponse, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/agents/%s/config", c.baseURL, agentID)
|
||||
|
|
@ -1237,7 +1246,7 @@ func (c *Client) GetExpectedHash(packageType, packageName string, agentID uuid.U
|
|||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(bodyBytes, &body); err == nil && body.Error != "" {
|
||||
return "", fmt.Errorf(body.Error)
|
||||
return "", fmt.Errorf("%s", body.Error)
|
||||
}
|
||||
return "", fmt.Errorf("unexpected status: %d - %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,16 @@ type CommandSigningConfig struct {
|
|||
EnforcementMode string `json:"enforcement_mode" env:"REDFLAG_AGENT_COMMAND_ENFORCEMENT_MODE" default:"strict"` // strict, warning, disabled
|
||||
}
|
||||
|
||||
// PollingConfig holds admin-adjustable polling resilience tuning. These shape
|
||||
// the check-in jitter and the reconnect backoff curve. Zero values fall back to
|
||||
// built-in defaults (see the resilience defaults in internal/agent/loop.go), so
|
||||
// older config files without these fields keep working.
|
||||
type PollingConfig struct {
|
||||
JitterMaxSeconds int `json:"jitter_max_seconds,omitempty"` // cap on proportional check-in jitter (default 30)
|
||||
BackoffBaseSeconds int `json:"backoff_base_seconds,omitempty"` // reconnect backoff floor (default 10)
|
||||
BackoffMaxSeconds int `json:"backoff_max_seconds,omitempty"` // reconnect backoff ceiling (default 300)
|
||||
}
|
||||
|
||||
// Config holds agent configuration
|
||||
type Config struct {
|
||||
// Version Information
|
||||
|
|
@ -99,6 +109,9 @@ type Config struct {
|
|||
RapidPollingEnabled bool `json:"rapid_polling_enabled"`
|
||||
RapidPollingUntil time.Time `json:"rapid_polling_until"`
|
||||
|
||||
// Polling resilience tuning (admin-adjustable; server authority)
|
||||
Polling PollingConfig `json:"polling,omitempty"`
|
||||
|
||||
// Degraded mode for operation after repeated failures
|
||||
DegradedMode bool `json:"degraded_mode"`
|
||||
|
||||
|
|
@ -234,6 +247,13 @@ func getDefaultConfig() *Config {
|
|||
RapidPollingUntil: time.Time{},
|
||||
DegradedMode: false,
|
||||
|
||||
// Polling resilience tuning (defaults; operator/server may override)
|
||||
Polling: PollingConfig{
|
||||
JitterMaxSeconds: 30,
|
||||
BackoffBaseSeconds: 10,
|
||||
BackoffMaxSeconds: 300,
|
||||
},
|
||||
|
||||
// Network Security
|
||||
Proxy: ProxyConfig{},
|
||||
TLS: TLSConfig{},
|
||||
|
|
@ -474,6 +494,15 @@ func mergeConfig(target, source *Config) {
|
|||
if source.CheckInInterval != 0 {
|
||||
target.CheckInInterval = source.CheckInInterval
|
||||
}
|
||||
if source.Polling.JitterMaxSeconds != 0 {
|
||||
target.Polling.JitterMaxSeconds = source.Polling.JitterMaxSeconds
|
||||
}
|
||||
if source.Polling.BackoffBaseSeconds != 0 {
|
||||
target.Polling.BackoffBaseSeconds = source.Polling.BackoffBaseSeconds
|
||||
}
|
||||
if source.Polling.BackoffMaxSeconds != 0 {
|
||||
target.Polling.BackoffMaxSeconds = source.Polling.BackoffMaxSeconds
|
||||
}
|
||||
if source.AgentID != uuid.Nil {
|
||||
target.AgentID = source.AgentID
|
||||
}
|
||||
|
|
@ -647,6 +676,15 @@ func mergeConfigPreservingDefaults(target, source *Config) {
|
|||
if source.CheckInInterval != 0 {
|
||||
target.CheckInInterval = source.CheckInInterval
|
||||
}
|
||||
if source.Polling.JitterMaxSeconds != 0 {
|
||||
target.Polling.JitterMaxSeconds = source.Polling.JitterMaxSeconds
|
||||
}
|
||||
if source.Polling.BackoffBaseSeconds != 0 {
|
||||
target.Polling.BackoffBaseSeconds = source.Polling.BackoffBaseSeconds
|
||||
}
|
||||
if source.Polling.BackoffMaxSeconds != 0 {
|
||||
target.Polling.BackoffMaxSeconds = source.Polling.BackoffMaxSeconds
|
||||
}
|
||||
if source.AgentID != uuid.Nil {
|
||||
target.AgentID = source.AgentID
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,12 @@ import (
|
|||
|
||||
func TestJitterExceedsRapidModeInterval(t *testing.T) {
|
||||
// POST-FIX: Fixed 30s jitter no longer applied to rapid mode.
|
||||
mainPath := filepath.Join("..", "cmd", "agent", "main.go")
|
||||
content, err := os.ReadFile(mainPath)
|
||||
// The polling loop was extracted from main.go into the shared loop
|
||||
// (internal/agent/loop.go); the Windows service delegates to the same loop.
|
||||
loopPath := filepath.Join("agent", "loop.go")
|
||||
content, err := os.ReadFile(loopPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read agent main.go: %v", err)
|
||||
t.Fatalf("failed to read agent loop.go: %v", err)
|
||||
}
|
||||
|
||||
src := string(content)
|
||||
|
|
@ -35,10 +37,12 @@ func TestJitterExceedsRapidModeInterval(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestJitterDoesNotExceedPollingInterval(t *testing.T) {
|
||||
mainPath := filepath.Join("..", "cmd", "agent", "main.go")
|
||||
content, err := os.ReadFile(mainPath)
|
||||
// The polling loop was extracted from main.go into the shared loop
|
||||
// (internal/agent/loop.go); the Windows service delegates to the same loop.
|
||||
loopPath := filepath.Join("agent", "loop.go")
|
||||
content, err := os.ReadFile(loopPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read agent main.go: %v", err)
|
||||
t.Fatalf("failed to read agent loop.go: %v", err)
|
||||
}
|
||||
|
||||
src := string(content)
|
||||
|
|
|
|||
|
|
@ -14,10 +14,12 @@ import (
|
|||
|
||||
func TestReconnectionUsesFixedJitterOnly(t *testing.T) {
|
||||
// POST-FIX: Reconnection now uses exponential backoff.
|
||||
mainPath := filepath.Join("..", "cmd", "agent", "main.go")
|
||||
content, err := os.ReadFile(mainPath)
|
||||
// Reconnect backoff lives in the shared loop (internal/agent/loop.go),
|
||||
// which both main.go and the Windows service delegate to.
|
||||
loopPath := filepath.Join("agent", "loop.go")
|
||||
content, err := os.ReadFile(loopPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read agent main.go: %v", err)
|
||||
t.Fatalf("failed to read agent loop.go: %v", err)
|
||||
}
|
||||
|
||||
src := string(content)
|
||||
|
|
@ -36,10 +38,12 @@ func TestReconnectionUsesFixedJitterOnly(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestReconnectionUsesExponentialBackoffWithJitter(t *testing.T) {
|
||||
mainPath := filepath.Join("..", "cmd", "agent", "main.go")
|
||||
content, err := os.ReadFile(mainPath)
|
||||
// Reconnect backoff lives in the shared loop (internal/agent/loop.go),
|
||||
// which both main.go and the Windows service delegate to.
|
||||
loopPath := filepath.Join("agent", "loop.go")
|
||||
content, err := os.ReadFile(loopPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read agent main.go: %v", err)
|
||||
t.Fatalf("failed to read agent loop.go: %v", err)
|
||||
}
|
||||
|
||||
src := strings.ToLower(string(content))
|
||||
|
|
|
|||
|
|
@ -60,11 +60,14 @@ func TestWindowsServicePollingLoopHasFixedJitter(t *testing.T) {
|
|||
|
||||
src := string(content)
|
||||
|
||||
if !strings.Contains(src, "maxJitter") && !strings.Contains(src, "pollingInterval / 2") {
|
||||
// Ideal fix (now in place): the service delegates to the shared loop via
|
||||
// agent.RunPollingLoop, so jitter lives there rather than being copied here.
|
||||
delegates := strings.Contains(src, "RunPollingLoop")
|
||||
if !delegates && !strings.Contains(src, "maxJitter") && !strings.Contains(src, "pollingInterval / 2") {
|
||||
t.Error("[ERROR] [agent] [service] F-C1-5 NOT FIXED: proportional jitter not in service")
|
||||
}
|
||||
|
||||
t.Log("[INFO] [agent] [service] F-C1-5 FIXED: service has proportional jitter")
|
||||
t.Log("[INFO] [agent] [service] F-C1-5 FIXED: service delegates jitter to shared loop")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -85,7 +88,8 @@ func TestWindowsServicePollingLoopHasProportionalJitter(t *testing.T) {
|
|||
|
||||
hasProportionalJitter := strings.Contains(src, "pollingInterval / 2") ||
|
||||
strings.Contains(src, "maxJitter") ||
|
||||
// Or the ideal fix: service delegates to shared function
|
||||
// Or the ideal fix: service delegates to the shared loop
|
||||
strings.Contains(src, "RunPollingLoop") ||
|
||||
strings.Contains(src, "runAgentLoop") ||
|
||||
strings.Contains(src, "commonPollingLoop")
|
||||
|
||||
|
|
@ -112,11 +116,14 @@ func TestWindowsServicePollingLoopHasNoExponentialBackoff(t *testing.T) {
|
|||
|
||||
src := string(content)
|
||||
|
||||
if !strings.Contains(src, "calculateBackoff") || !strings.Contains(src, "consecutiveFailures") {
|
||||
// Backoff lives in the shared loop the service delegates to (RunPollingLoop),
|
||||
// or — for the legacy in-place form — directly via calculateBackoff.
|
||||
delegates := strings.Contains(src, "RunPollingLoop")
|
||||
if !delegates && (!strings.Contains(src, "calculateBackoff") || !strings.Contains(src, "consecutiveFailures")) {
|
||||
t.Error("[ERROR] [agent] [service] F-C1-5 NOT FIXED: exponential backoff missing")
|
||||
}
|
||||
|
||||
t.Log("[INFO] [agent] [service] F-C1-5 FIXED: exponential backoff in service polling loop")
|
||||
t.Log("[INFO] [agent] [service] F-C1-5 FIXED: service delegates backoff to shared loop")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -138,7 +145,8 @@ func TestWindowsServicePollingLoopHasExponentialBackoff(t *testing.T) {
|
|||
hasBackoff := strings.Contains(src, "calculateBackoff") ||
|
||||
strings.Contains(src, "consecutiveFailures") ||
|
||||
strings.Contains(src, "backoffDelay") ||
|
||||
// Or the ideal fix: delegates to shared function
|
||||
// Or the ideal fix: delegates to the shared loop
|
||||
strings.Contains(src, "RunPollingLoop") ||
|
||||
strings.Contains(src, "runAgentLoop")
|
||||
|
||||
if !hasBackoff {
|
||||
|
|
@ -168,7 +176,9 @@ func TestPollingLoopIsNotDuplicated(t *testing.T) {
|
|||
src := string(content)
|
||||
|
||||
// Accept if either: (a) delegated to shared function, or (b) has parity
|
||||
hasDelegation := strings.Contains(src, "runAgentLoop") || strings.Contains(src, "polling.Run")
|
||||
hasDelegation := strings.Contains(src, "RunPollingLoop") ||
|
||||
strings.Contains(src, "runAgentLoop") ||
|
||||
strings.Contains(src, "polling.Run")
|
||||
hasParity := strings.Contains(src, "maxJitter") && strings.Contains(src, "calculateBackoff")
|
||||
|
||||
if !hasDelegation && !hasParity {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ package service
|
|||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
|
|
@ -373,42 +372,10 @@ func ServiceStatus() error {
|
|||
|
||||
// Helper functions - these implement the same functionality as in main.go but adapted for service mode
|
||||
|
||||
// getCurrentPollingInterval returns the appropriate polling interval based on rapid mode
|
||||
func (s *redflagService) getCurrentPollingInterval() int {
|
||||
// Check if rapid polling mode is active and not expired
|
||||
if s.agent.RapidPollingEnabled && time.Now().Before(s.agent.RapidPollingUntil) {
|
||||
return 5 // Rapid polling: 5 seconds
|
||||
}
|
||||
|
||||
// Check if rapid polling has expired and clean up
|
||||
if s.agent.RapidPollingEnabled && time.Now().After(s.agent.RapidPollingUntil) {
|
||||
s.agent.RapidPollingEnabled = false
|
||||
s.agent.RapidPollingUntil = time.Time{}
|
||||
// Save the updated config to clean up expired rapid mode
|
||||
configPath := s.getConfigPath()
|
||||
if err := s.agent.Save(configPath); err != nil {
|
||||
log.Printf("Warning: Failed to cleanup expired rapid polling mode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.agent.CheckInInterval // Normal polling: 5 minutes (300 seconds) by default
|
||||
}
|
||||
|
||||
// calculateBackoff returns exponential backoff delay with full jitter (F-C1-5 parity with main.go)
|
||||
// TODO: make base and cap configurable via agent config
|
||||
func (s *redflagService) calculateBackoff(attempt int) time.Duration {
|
||||
base := 10 * time.Second
|
||||
cap := 5 * time.Minute
|
||||
ceiling := base * time.Duration(1<<uint(attempt))
|
||||
if ceiling > cap || ceiling <= 0 {
|
||||
ceiling = cap
|
||||
}
|
||||
delay := time.Duration(rand.Int63n(int64(ceiling)))
|
||||
if delay < base {
|
||||
delay = base
|
||||
}
|
||||
return delay
|
||||
}
|
||||
// Polling interval selection and reconnect backoff now live in the shared
|
||||
// loop (internal/agent/loop.go), which runAgent() delegates to via
|
||||
// agent.RunPollingLoop. The previous per-service copies were removed as part
|
||||
// of the CRITICAL-007 deduplication so there is one resilience implementation.
|
||||
|
||||
// getConfigPath returns the platform-specific config path
|
||||
func (s *redflagService) getConfigPath() string {
|
||||
|
|
|
|||
|
|
@ -32,10 +32,12 @@ func TestRegistrationFallbackIsNotHashed(t *testing.T) {
|
|||
|
||||
func TestRegistrationFallbackUsesCanonicalFunction(t *testing.T) {
|
||||
// POST-FIX: Registration uses system.GetMachineID() with no inline fallback.
|
||||
mainPath := filepath.Join("..", "..", "cmd", "agent", "main.go")
|
||||
content, err := os.ReadFile(mainPath)
|
||||
// The registration flow lives in internal/registration/service.go, which
|
||||
// calls the canonical function and aborts if it fails (no "unknown-" path).
|
||||
regPath := filepath.Join("..", "registration", "service.go")
|
||||
content, err := os.ReadFile(regPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read main.go: %v", err)
|
||||
t.Fatalf("failed to read registration/service.go: %v", err)
|
||||
}
|
||||
|
||||
src := string(content)
|
||||
|
|
@ -76,11 +78,13 @@ func TestMachineIDIsAlways64HexChars(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestRegistrationAndRuntimeUseSameCodePath(t *testing.T) {
|
||||
// POST-FIX: Both paths call system.GetMachineID(), no divergent fallback.
|
||||
mainPath := filepath.Join("..", "..", "cmd", "agent", "main.go")
|
||||
mainContent, err := os.ReadFile(mainPath)
|
||||
// POST-FIX: registration and runtime both resolve the machine ID through the
|
||||
// canonical system.GetMachineID(), with no divergent "unknown-" fallback.
|
||||
// Registration: internal/registration/service.go. Runtime: internal/client/client.go.
|
||||
regPath := filepath.Join("..", "registration", "service.go")
|
||||
regContent, err := os.ReadFile(regPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read main.go: %v", err)
|
||||
t.Fatalf("failed to read registration/service.go: %v", err)
|
||||
}
|
||||
|
||||
clientPath := filepath.Join("..", "client", "client.go")
|
||||
|
|
@ -89,18 +93,18 @@ func TestRegistrationAndRuntimeUseSameCodePath(t *testing.T) {
|
|||
t.Fatalf("failed to read client.go: %v", err)
|
||||
}
|
||||
|
||||
mainSrc := string(mainContent)
|
||||
regSrc := string(regContent)
|
||||
clientSrc := string(clientContent)
|
||||
|
||||
if !strings.Contains(mainSrc, "system.GetMachineID()") {
|
||||
t.Error("[ERROR] [agent] [system] main.go doesn't call system.GetMachineID()")
|
||||
if !strings.Contains(regSrc, "system.GetMachineID()") {
|
||||
t.Error("[ERROR] [agent] [system] registration doesn't call system.GetMachineID()")
|
||||
}
|
||||
if !strings.Contains(clientSrc, "system.GetMachineID()") {
|
||||
t.Error("[ERROR] [agent] [system] client.go doesn't call system.GetMachineID()")
|
||||
}
|
||||
|
||||
if strings.Contains(mainSrc, `"unknown-"`) {
|
||||
t.Errorf("[ERROR] [agent] [system] main.go has divergent 'unknown-' fallback")
|
||||
if strings.Contains(regSrc, `"unknown-"`) {
|
||||
t.Errorf("[ERROR] [agent] [system] registration has divergent 'unknown-' fallback")
|
||||
}
|
||||
|
||||
t.Log("[INFO] [agent] [system] F-D1-1 FIXED: both paths use canonical GetMachineID()")
|
||||
|
|
|
|||
|
|
@ -300,10 +300,8 @@ func (h *AgentTrackedSoftwareHandler) GenerateInstallScript(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Get tracked software details
|
||||
bindingView.InstallPath = bindingView.InstallPath
|
||||
bindingView.Notes = bindingView.Notes
|
||||
bindingView.LastObservedAt = bindingView.LastObservedAt
|
||||
|
||||
// TODO: populate from the resolved binding/software record once wired:
|
||||
// bindingView.InstallPath, bindingView.Notes, bindingView.LastObservedAt
|
||||
software, err := h.upstream.GetByID(bindingView.TrackedSoftwareID)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [server] [agent_tracked_software] get_software software=%s err=%v",
|
||||
|
|
@ -366,6 +364,7 @@ fi
|
|||
*software.LatestVersion,
|
||||
bindingView.Name,
|
||||
bindingView.Name, bindingView.Name, bindingView.Name,
|
||||
bindingView.Name,
|
||||
strings.Split(software.Source, "://")[1], software.SourceRef,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1730,8 +1730,24 @@ func (h *AgentHandler) GetAgentConfig(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// Polling resilience tuning (operational, fleet-wide). Delivered here so the
|
||||
// agent merges it into its local PollingConfig. Defaults match the agent's
|
||||
// built-in resilience defaults (agent/internal/agent/loop.go) so a missing
|
||||
// settings row, or a server without the settings service, degrades to those.
|
||||
polling := gin.H{
|
||||
"jitter_max_seconds": 30,
|
||||
"backoff_base_seconds": 10,
|
||||
"backoff_max_seconds": 300,
|
||||
}
|
||||
if h.securitySettings != nil {
|
||||
polling["jitter_max_seconds"] = h.securitySettings.GetOperationalInt("jitter_max_seconds", 30)
|
||||
polling["backoff_base_seconds"] = h.securitySettings.GetOperationalInt("backoff_base_seconds", 10)
|
||||
polling["backoff_max_seconds"] = h.securitySettings.GetOperationalInt("backoff_max_seconds", 300)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"subsystems": config,
|
||||
"polling": polling,
|
||||
"version": time.Now().UTC().Unix(), // Simple version timestamp
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
-- Migration: 025_platform_scanner_subsystems (down)
|
||||
-- Purpose: Remove platform-specific package scanner subsystems and restore original trigger
|
||||
-- Version: 0.1.29
|
||||
-- Date: 2025-12-23
|
||||
|
||||
-- Remove platform-specific subsystems for Linux agents
|
||||
DELETE FROM agent_subsystems
|
||||
WHERE subsystem IN ('apt', 'dnf', 'windows', 'winget');
|
||||
|
||||
-- Restore original trigger from migration 015
|
||||
DROP TRIGGER IF EXISTS trigger_create_default_subsystems ON agents;
|
||||
|
||||
CREATE OR REPLACE FUNCTION create_default_subsystems()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Insert default subsystems for new agent (legacy pattern)
|
||||
INSERT INTO agent_subsystems (agent_id, subsystem, enabled, interval_minutes, auto_run)
|
||||
VALUES
|
||||
(NEW.id, 'storage', true, 15, false),
|
||||
(NEW.id, 'system', true, 30, false),
|
||||
(NEW.id, 'docker', false, 15, false);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_create_default_subsystems
|
||||
AFTER INSERT ON agents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION create_default_subsystems();
|
||||
|
||||
RAISE NOTICE 'Migration 025_platform_scanner_subsystems down completed: Original trigger restored';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- Reverse of 044_add_polling_operational_settings.up.sql
|
||||
DELETE FROM security_settings WHERE category = 'operational' AND key IN (
|
||||
'jitter_max_seconds',
|
||||
'backoff_base_seconds',
|
||||
'backoff_max_seconds'
|
||||
);
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
-- Migration 044: Polling resilience tuning (operational)
|
||||
-- Fleet-wide check-in jitter and reconnect backoff curve. Delivered to agents
|
||||
-- over the authenticated GET /api/v1/agents/:id/config channel and merged into
|
||||
-- local agent config (agent/internal/config PollingConfig). Same tier as the
|
||||
-- existing operational.update_stuck_minutes row from migration 038 — runtime
|
||||
-- tuning, not policy doctrine.
|
||||
INSERT INTO security_settings
|
||||
(id, category, key, value, value_type, description, requires_restart, validation_rules)
|
||||
VALUES
|
||||
(gen_random_uuid(), 'operational', 'jitter_max_seconds', '30',
|
||||
'number', 'Cap on proportional check-in jitter (seconds). Spreads fleet check-ins to avoid thundering herd.', false,
|
||||
'{"min": 0, "max": 300}'),
|
||||
|
||||
(gen_random_uuid(), 'operational', 'backoff_base_seconds', '10',
|
||||
'number', 'Reconnect backoff floor (seconds) after a failed check-in.', false,
|
||||
'{"min": 1, "max": 300}'),
|
||||
|
||||
(gen_random_uuid(), 'operational', 'backoff_max_seconds', '300',
|
||||
'number', 'Reconnect backoff ceiling (seconds). Caps the exponential backoff curve.', false,
|
||||
'{"min": 10, "max": 3600}')
|
||||
ON CONFLICT (category, key) DO NOTHING;
|
||||
|
|
@ -81,7 +81,13 @@ func TestStuckCommandHasNoMaxRetryCount(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestStuckCommandHasMaxRetryCount(t *testing.T) {
|
||||
// Verify: column exists, filter in query, and increment function exists
|
||||
// Verify the cap is wired end-to-end: increment on re-delivery, a retry_count
|
||||
// filter in the stuck-command queries, and a default cap of 5.
|
||||
//
|
||||
// NOTE: the cap value was parameterized in accab6de — the queries now filter
|
||||
// `retry_count < $3` (maxRetries arg) instead of a hardcoded `retry_count < 5`.
|
||||
// The literal 5 moved to the handler's default fallback, which is the real
|
||||
// source of the cap. This test asserts that invariant, not the old literal.
|
||||
cmdPath := filepath.Join("queries", "commands.go")
|
||||
content, err := os.ReadFile(cmdPath)
|
||||
if err != nil {
|
||||
|
|
@ -96,10 +102,21 @@ func TestStuckCommandHasMaxRetryCount(t *testing.T) {
|
|||
"DEV-029: RedeliverStuckCommandTx must increment retry_count on re-delivery.")
|
||||
}
|
||||
|
||||
// Must have retry_count < 5 filter
|
||||
if !strings.Contains(src, "retry_count < 5") {
|
||||
t.Errorf("[ERROR] [server] [database] no retry_count < 5 filter in stuck command queries")
|
||||
// Must filter on retry_count (parameterized maxRetries) in the stuck queries
|
||||
if !strings.Contains(src, "retry_count < $") {
|
||||
t.Errorf("[ERROR] [server] [database] no retry_count filter in stuck command queries")
|
||||
}
|
||||
|
||||
t.Log("[INFO] [server] [database] F-B2-10 + DEV-029 FIXED: retry count capped at 5")
|
||||
// The cap value must default to 5 in the handler fallback (agents.go).
|
||||
agentsPath := filepath.Join("..", "api", "handlers", "agents.go")
|
||||
agentsContent, err := os.ReadFile(agentsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read agents.go: %v", err)
|
||||
}
|
||||
agentsSrc := string(agentsContent)
|
||||
if !strings.Contains(agentsSrc, "maxCommandRetries = 5") {
|
||||
t.Errorf("[ERROR] [server] [database] no default cap of 5 (maxCommandRetries = 5) in agents.go")
|
||||
}
|
||||
|
||||
t.Log("[INFO] [server] [database] F-B2-10 + DEV-029 FIXED: retry count capped, default 5")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,6 +248,33 @@ func (s *SecuritySettingsService) ValidateSetting(category, key string, value in
|
|||
return fmt.Errorf("log retention must be a number")
|
||||
}
|
||||
|
||||
case "operational.jitter_max_seconds":
|
||||
if v, ok := value.(float64); ok {
|
||||
if v < 0 || v > 300 {
|
||||
return fmt.Errorf("jitter_max_seconds must be between 0 and 300")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("jitter_max_seconds must be a number")
|
||||
}
|
||||
|
||||
case "operational.backoff_base_seconds":
|
||||
if v, ok := value.(float64); ok {
|
||||
if v < 1 || v > 300 {
|
||||
return fmt.Errorf("backoff_base_seconds must be between 1 and 300")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("backoff_base_seconds must be a number")
|
||||
}
|
||||
|
||||
case "operational.backoff_max_seconds":
|
||||
if v, ok := value.(float64); ok {
|
||||
if v < 10 || v > 3600 {
|
||||
return fmt.Errorf("backoff_max_seconds must be between 10 and 3600")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("backoff_max_seconds must be a number")
|
||||
}
|
||||
|
||||
case "command_signing.algorithm", "update_signing.algorithm":
|
||||
if algo, ok := value.(string); ok {
|
||||
if algo != "ed25519" {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import RateLimiting from '@/pages/RateLimiting';
|
|||
import AgentManagement from '@/pages/settings/AgentManagement';
|
||||
import MaintenanceWindows from '@/pages/settings/MaintenanceWindows';
|
||||
import UpstreamTracking from '@/pages/settings/UpstreamTracking';
|
||||
import General from '@/pages/settings/General';
|
||||
import AgentPolling from '@/pages/settings/AgentPolling';
|
||||
import SecuritySettings from '@/pages/SecuritySettings';
|
||||
import Login from '@/pages/Login';
|
||||
import Setup from '@/pages/Setup';
|
||||
import { WelcomeChecker } from '@/components/WelcomeChecker';
|
||||
|
|
@ -137,9 +140,13 @@ const App: React.FC = () => {
|
|||
<Route path="/live" element={<LiveOperations />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/settings/general" element={<General />} />
|
||||
<Route path="/settings/tokens" element={<TokenManagement />} />
|
||||
<Route path="/settings/rate-limiting" element={<RateLimiting />} />
|
||||
<Route path="/settings/agents" element={<AgentManagement />} />
|
||||
<Route path="/settings/polling" element={<AgentPolling />} />
|
||||
<Route path="/settings/security" element={<SecuritySettings />} />
|
||||
<Route path="/settings/security/:tab" element={<SecuritySettings />} />
|
||||
<Route path="/settings/maintenance-windows" element={<MaintenanceWindows />} />
|
||||
<Route path="/settings/upstream" element={<UpstreamTracking />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function AgentUpdatesModal({
|
|||
onAgentsUpdated,
|
||||
}: AgentUpdatesModalProps) {
|
||||
const [selectedVersion, setSelectedVersion] = useState('');
|
||||
const [selectedPlatform, setSelectedPlatform] = useState('');
|
||||
const [selectedPlatform] = useState('');
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// Fetch selected agents details
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ interface HistoryEntry {
|
|||
metadata?: Record<string, string>;
|
||||
params?: Record<string, any>;
|
||||
hostname?: string;
|
||||
narrative?: string; // server-supplied summary; falls back to action enum when absent
|
||||
}
|
||||
|
||||
interface ChatTimelineProps {
|
||||
|
|
|
|||
60
web/src/hooks/useAgentPolling.ts
Normal file
60
web/src/hooks/useAgentPolling.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
|
||||
// Polling resilience tuning lives in the security_settings store under the
|
||||
// 'operational' category (same tier as update_stuck_minutes). The server
|
||||
// delivers these to agents over GET /api/v1/agents/:id/config, which the agent
|
||||
// merges into its local PollingConfig — so a change here propagates fleet-wide.
|
||||
export interface PollingSettings {
|
||||
jitter_max_seconds: number
|
||||
backoff_base_seconds: number
|
||||
backoff_max_seconds: number
|
||||
}
|
||||
|
||||
export const POLLING_DEFAULTS: PollingSettings = {
|
||||
jitter_max_seconds: 30,
|
||||
backoff_base_seconds: 10,
|
||||
backoff_max_seconds: 300,
|
||||
}
|
||||
|
||||
const toNumber = (v: unknown, fallback: number): number => {
|
||||
const n = typeof v === 'string' ? parseInt(v, 10) : (v as number)
|
||||
return Number.isFinite(n) ? (n as number) : fallback
|
||||
}
|
||||
|
||||
export function usePollingSettings() {
|
||||
return useQuery({
|
||||
queryKey: ['polling-settings'],
|
||||
queryFn: async (): Promise<PollingSettings> => {
|
||||
const { data } = await api.get('/security/settings')
|
||||
const op = data?.settings?.operational ?? {}
|
||||
return {
|
||||
jitter_max_seconds: toNumber(op.jitter_max_seconds, POLLING_DEFAULTS.jitter_max_seconds),
|
||||
backoff_base_seconds: toNumber(op.backoff_base_seconds, POLLING_DEFAULTS.backoff_base_seconds),
|
||||
backoff_max_seconds: toNumber(op.backoff_max_seconds, POLLING_DEFAULTS.backoff_max_seconds),
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface PollingUpdate {
|
||||
key: keyof PollingSettings
|
||||
value: number
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export function useUpdatePollingSetting() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ key, value, reason }: PollingUpdate): Promise<void> => {
|
||||
await api.put(`/security/settings/operational/${key}`, {
|
||||
value,
|
||||
reason: reason || 'Updated via Agent Polling settings',
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['polling-settings'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -2,47 +2,24 @@ import React from 'react';
|
|||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Shield,
|
||||
Lock,
|
||||
SlidersHorizontal,
|
||||
Settings as SettingsIcon,
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
Activity,
|
||||
Clock,
|
||||
GitBranch,
|
||||
RadioTower,
|
||||
} from 'lucide-react';
|
||||
import { useSettingsStore } from '@/lib/store';
|
||||
import { useTimezones, useTimezone, useUpdateTimezone } from '../hooks/useSettings';
|
||||
import { useRegistrationTokenStats } from '../hooks/useRegistrationTokens';
|
||||
import { useRateLimitStats } from '../hooks/useRateLimits';
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const { autoRefresh, refreshInterval, setAutoRefresh, setRefreshInterval } = useSettingsStore();
|
||||
|
||||
// Timezone settings
|
||||
const { data: timezones, isLoading: isLoadingTimezones } = useTimezones();
|
||||
const { data: currentTimezone } = useTimezone();
|
||||
const updateTimezone = useUpdateTimezone();
|
||||
const [selectedTimezone, setSelectedTimezone] = React.useState('');
|
||||
|
||||
// Statistics for overview
|
||||
const { data: tokenStats } = useRegistrationTokenStats();
|
||||
const { data: rateLimitStats } = useRateLimitStats();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentTimezone?.timezone) {
|
||||
setSelectedTimezone(currentTimezone.timezone);
|
||||
}
|
||||
}, [currentTimezone]);
|
||||
|
||||
const handleTimezoneChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newTimezone = e.target.value;
|
||||
setSelectedTimezone(newTimezone);
|
||||
try {
|
||||
await updateTimezone.mutateAsync(newTimezone);
|
||||
} catch (error) {
|
||||
console.error('Failed to update timezone:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
{/* Header */}
|
||||
|
|
@ -53,6 +30,18 @@ const Settings: React.FC = () => {
|
|||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<Link
|
||||
to="/settings/general"
|
||||
className="block p-6 bg-white border border-gray-200 rounded-lg hover:border-gray-400 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<SlidersHorizontal className="w-8 h-8 text-gray-600" />
|
||||
<ArrowRight className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">General</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">Display preferences and dashboard behavior</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/settings/tokens"
|
||||
className="block p-6 bg-white border border-gray-200 rounded-lg hover:border-blue-300 hover:shadow-sm transition-all"
|
||||
|
|
@ -89,6 +78,30 @@ const Settings: React.FC = () => {
|
|||
<p className="text-sm text-gray-600 mt-1">Deploy and configure agents</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/settings/polling"
|
||||
className="block p-6 bg-white border border-gray-200 rounded-lg hover:border-amber-300 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<RadioTower className="w-8 h-8 text-amber-600" />
|
||||
<ArrowRight className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">Agent Polling</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">Fleet check-in jitter and reconnect backoff</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/settings/security"
|
||||
className="block p-6 bg-white border border-gray-200 rounded-lg hover:border-red-300 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Lock className="w-8 h-8 text-red-600" />
|
||||
<ArrowRight className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">Security</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">Command signing, machine binding, key management</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/settings/maintenance-windows"
|
||||
className="block p-6 bg-white border border-gray-200 rounded-lg hover:border-indigo-300 hover:shadow-sm transition-all"
|
||||
|
|
@ -198,87 +211,8 @@ const Settings: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Settings */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6 pb-2 border-b border-gray-200">Account Settings</h2>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Display Preferences */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Display Preferences</h3>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Timezone
|
||||
<span className="ml-1 text-xs text-gray-500">(Note: Changes apply to current session only)</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedTimezone}
|
||||
onChange={handleTimezoneChange}
|
||||
disabled={isLoadingTimezones || updateTimezone.isPending}
|
||||
className="w-full md:w-64 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{isLoadingTimezones ? (
|
||||
<option>Loading...</option>
|
||||
) : (
|
||||
timezones?.map((tz) => (
|
||||
<option key={tz.value} value={tz.value}>{tz.label}</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
{updateTimezone.isPending && (
|
||||
<p className="mt-2 text-sm text-blue-600">Updating timezone...</p>
|
||||
)}
|
||||
{updateTimezone.isSuccess && (
|
||||
<p className="mt-2 text-sm text-green-600">Timezone updated successfully</p>
|
||||
)}
|
||||
{updateTimezone.isError && (
|
||||
<p className="mt-2 text-sm text-red-600">Failed to update timezone</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Behavior */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Dashboard Behavior</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Auto-refresh</div>
|
||||
<div className="text-sm text-gray-600">Automatically refresh dashboard data</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
||||
autoRefresh ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span className={`${autoRefresh ? 'translate-x-5' : 'translate-x-0'} inline-block h-5 w-5 transform rounded-full bg-white transition`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Refresh Interval</label>
|
||||
<select
|
||||
value={refreshInterval}
|
||||
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
||||
disabled={!autoRefresh}
|
||||
className="w-full md:w-64 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<option value={10000}>10 seconds</option>
|
||||
<option value={30000}>30 seconds</option>
|
||||
<option value={60000}>1 minute</option>
|
||||
<option value={300000}>5 minutes</option>
|
||||
<option value={600000}>10 minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
export default Settings;
|
||||
|
|
|
|||
164
web/src/pages/settings/AgentPolling.tsx
Normal file
164
web/src/pages/settings/AgentPolling.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import React from 'react';
|
||||
import { RadioTower, Save } from 'lucide-react';
|
||||
import {
|
||||
usePollingSettings,
|
||||
useUpdatePollingSetting,
|
||||
POLLING_DEFAULTS,
|
||||
PollingSettings,
|
||||
} from '../../hooks/useAgentPolling';
|
||||
|
||||
interface Field {
|
||||
key: keyof PollingSettings;
|
||||
label: string;
|
||||
unit: string;
|
||||
min: number;
|
||||
max: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const FIELDS: Field[] = [
|
||||
{
|
||||
key: 'jitter_max_seconds',
|
||||
label: 'Jitter Cap',
|
||||
unit: 'seconds',
|
||||
min: 0,
|
||||
max: 300,
|
||||
description:
|
||||
'Maximum random delay added before each check-in. Spreads fleet check-ins so agents do not all hit the server at once.',
|
||||
},
|
||||
{
|
||||
key: 'backoff_base_seconds',
|
||||
label: 'Backoff Floor',
|
||||
unit: 'seconds',
|
||||
min: 1,
|
||||
max: 300,
|
||||
description:
|
||||
'Shortest reconnect delay after a failed check-in. The exponential backoff curve starts here.',
|
||||
},
|
||||
{
|
||||
key: 'backoff_max_seconds',
|
||||
label: 'Backoff Ceiling',
|
||||
unit: 'seconds',
|
||||
min: 10,
|
||||
max: 3600,
|
||||
description:
|
||||
'Longest reconnect delay. Caps how far the exponential backoff can grow during an outage.',
|
||||
},
|
||||
];
|
||||
|
||||
const AgentPolling: React.FC = () => {
|
||||
const { data, isLoading } = usePollingSettings();
|
||||
const updateSetting = useUpdatePollingSetting();
|
||||
|
||||
const [draft, setDraft] = React.useState<PollingSettings>(POLLING_DEFAULTS);
|
||||
const [saved, setSaved] = React.useState(false);
|
||||
const [errors, setErrors] = React.useState<Partial<Record<keyof PollingSettings, string>>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (data) setDraft(data);
|
||||
}, [data]);
|
||||
|
||||
const validate = (field: Field, value: number): string | null => {
|
||||
if (Number.isNaN(value)) return 'Must be a number';
|
||||
if (value < field.min || value > field.max) {
|
||||
return `Must be between ${field.min} and ${field.max}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleChange = (field: Field, raw: string) => {
|
||||
setSaved(false);
|
||||
const value = parseInt(raw, 10);
|
||||
setDraft((d) => ({ ...d, [field.key]: value }));
|
||||
setErrors((e) => ({ ...e, [field.key]: validate(field, value) || undefined }));
|
||||
};
|
||||
|
||||
const dirty = data
|
||||
? FIELDS.some((f) => draft[f.key] !== data[f.key])
|
||||
: false;
|
||||
const hasErrors = Object.values(errors).some(Boolean);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!data || !dirty || hasErrors) return;
|
||||
const changed = FIELDS.filter((f) => draft[f.key] !== data[f.key]);
|
||||
for (const f of changed) {
|
||||
await updateSetting.mutateAsync({ key: f.key, value: draft[f.key] });
|
||||
}
|
||||
setSaved(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<RadioTower className="w-8 h-8 text-amber-600" />
|
||||
Agent Polling
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Fleet-wide check-in jitter and reconnect backoff. Delivered to agents over their
|
||||
authenticated config channel and merged into local agent config — a change here reaches
|
||||
each agent on its next config refresh (within ~15 minutes), no host edits required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6 pb-2 border-b border-gray-200">
|
||||
Resilience Tuning
|
||||
</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600 mx-auto"></div>
|
||||
<p className="text-sm text-gray-500 mt-2">Loading polling settings...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{FIELDS.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-lg font-medium text-gray-900 mb-1">{field.label}</label>
|
||||
<p className="text-sm text-gray-600 mb-3">{field.description}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
value={Number.isNaN(draft[field.key]) ? '' : draft[field.key]}
|
||||
onChange={(e) => handleChange(field, e.target.value)}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{field.unit}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
(default {POLLING_DEFAULTS[field.key]}, range {field.min}–{field.max})
|
||||
</span>
|
||||
</div>
|
||||
{errors[field.key] && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors[field.key]}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-2 border-t border-gray-200 flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || hasErrors || updateSetting.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{updateSetting.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
{updateSetting.isError && (
|
||||
<span className="text-sm text-red-600">Failed to save. Check the values and try again.</span>
|
||||
)}
|
||||
{saved && !dirty && !updateSetting.isError && (
|
||||
<span className="text-sm text-green-600">Saved. Agents will pick this up on next config refresh.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentPolling;
|
||||
119
web/src/pages/settings/General.tsx
Normal file
119
web/src/pages/settings/General.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import React from 'react';
|
||||
import { useSettingsStore } from '@/lib/store';
|
||||
import { useTimezones, useTimezone, useUpdateTimezone } from '../../hooks/useSettings';
|
||||
|
||||
const General: React.FC = () => {
|
||||
const { autoRefresh, refreshInterval, setAutoRefresh, setRefreshInterval } = useSettingsStore();
|
||||
|
||||
// Timezone settings
|
||||
const { data: timezones, isLoading: isLoadingTimezones } = useTimezones();
|
||||
const { data: currentTimezone } = useTimezone();
|
||||
const updateTimezone = useUpdateTimezone();
|
||||
const [selectedTimezone, setSelectedTimezone] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentTimezone?.timezone) {
|
||||
setSelectedTimezone(currentTimezone.timezone);
|
||||
}
|
||||
}, [currentTimezone]);
|
||||
|
||||
const handleTimezoneChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newTimezone = e.target.value;
|
||||
setSelectedTimezone(newTimezone);
|
||||
try {
|
||||
await updateTimezone.mutateAsync(newTimezone);
|
||||
} catch (error) {
|
||||
console.error('Failed to update timezone:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">General</h1>
|
||||
<p className="mt-2 text-gray-600">Display preferences and dashboard behavior for this session</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6 pb-2 border-b border-gray-200">Account Settings</h2>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Display Preferences */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Display Preferences</h3>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Timezone
|
||||
<span className="ml-1 text-xs text-gray-500">(Note: Changes apply to current session only)</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedTimezone}
|
||||
onChange={handleTimezoneChange}
|
||||
disabled={isLoadingTimezones || updateTimezone.isPending}
|
||||
className="w-full md:w-64 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{isLoadingTimezones ? (
|
||||
<option>Loading...</option>
|
||||
) : (
|
||||
timezones?.map((tz) => (
|
||||
<option key={tz.value} value={tz.value}>{tz.label}</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
{updateTimezone.isPending && (
|
||||
<p className="mt-2 text-sm text-blue-600">Updating timezone...</p>
|
||||
)}
|
||||
{updateTimezone.isSuccess && (
|
||||
<p className="mt-2 text-sm text-green-600">Timezone updated successfully</p>
|
||||
)}
|
||||
{updateTimezone.isError && (
|
||||
<p className="mt-2 text-sm text-red-600">Failed to update timezone</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Behavior */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Dashboard Behavior</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Auto-refresh</div>
|
||||
<div className="text-sm text-gray-600">Automatically refresh dashboard data</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
||||
autoRefresh ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span className={`${autoRefresh ? 'translate-x-5' : 'translate-x-0'} inline-block h-5 w-5 transform rounded-full bg-white transition`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Refresh Interval</label>
|
||||
<select
|
||||
value={refreshInterval}
|
||||
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
||||
disabled={!autoRefresh}
|
||||
className="w-full md:w-64 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<option value={10000}>10 seconds</option>
|
||||
<option value={30000}>30 seconds</option>
|
||||
<option value={60000}>1 minute</option>
|
||||
<option value={300000}>5 minutes</option>
|
||||
<option value={600000}>10 minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default General;
|
||||
Loading…
Reference in a new issue