Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-05-28 21:45:04 -04:00
commit d18b0f8a02
23 changed files with 714 additions and 197 deletions

View file

@ -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)))

View file

@ -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))
}

View file

@ -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
}

View file

@ -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)

View file

@ -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))

View file

@ -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 {

View file

@ -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 {

View file

@ -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()")

View file

@ -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,
)

View file

@ -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
})
}

View file

@ -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';

View file

@ -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'
);

View file

@ -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;

View file

@ -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")
}

View file

@ -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" {

View file

@ -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 />} />

View file

@ -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

View file

@ -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 {

View 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'] })
},
})
}

View file

@ -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;

View 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;

View 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;