From a90692f1d81d102fc2634abb8d75dd5efc2d2e65 Mon Sep 17 00:00:00 2001 From: Fimeg Date: Wed, 17 Dec 2025 21:08:38 -0500 Subject: [PATCH] fix: Complete AgentHealth improvements and build fixes - Update Update scanner default from 15min to 12 hours (backend) - Add 1 week and 2 week frequency options (frontend) - Rename AgentScanners to AgentHealth component - Add OS-aware package manager badges (APT, DNF, Windows/Winget, Docker) - Fix all build errors (types, imports, storage metrics) - Add useMemo optimization for enabled/auto-run counts --- .../cmd/agent/subsystem_handlers.go | 29 +- aggregator-agent/internal/client/client.go | 182 ++--- .../internal/config/subsystems.go | 28 +- .../internal/orchestrator/storage_scanner.go | 1 - .../internal/api/handlers/storage_metrics.go | 71 +- .../database/queries/storage_metrics.go | 40 +- aggregator-web/src/components/AgentHealth.tsx | 8 +- .../src/components/AgentScanners.tsx | 624 ------------------ .../src/components/AgentStorage.tsx | 16 +- aggregator-web/src/pages/Agents.tsx | 4 +- aggregator-web/src/types/index.ts | 31 + 11 files changed, 207 insertions(+), 827 deletions(-) delete mode 100644 aggregator-web/src/components/AgentScanners.tsx diff --git a/aggregator-agent/cmd/agent/subsystem_handlers.go b/aggregator-agent/cmd/agent/subsystem_handlers.go index cc724b5..64ea332 100644 --- a/aggregator-agent/cmd/agent/subsystem_handlers.go +++ b/aggregator-agent/cmd/agent/subsystem_handlers.go @@ -19,6 +19,7 @@ import ( "github.com/Fimeg/RedFlag/aggregator-agent/internal/acknowledgment" "github.com/Fimeg/RedFlag/aggregator-agent/internal/client" "github.com/Fimeg/RedFlag/aggregator-agent/internal/config" + "github.com/Fimeg/RedFlag/aggregator-agent/internal/models" "github.com/Fimeg/RedFlag/aggregator-agent/internal/orchestrator" ) @@ -223,13 +224,13 @@ func handleScanSystem(apiClient *client.Client, cfg *config.Config, ackTracker * metricItems := make([]client.MetricsReportItem, 0, len(metrics)) for _, metric := range metrics { item := client.MetricsReportItem{ - PackageType: "system", - PackageName: metric.MetricName, - CurrentVersion: metric.CurrentValue, - AvailableVersion: metric.AvailableValue, - Severity: metric.Severity, - RepositorySource: metric.MetricType, - Metadata: metric.Metadata, + PackageType: "system", + PackageName: metric.MetricName, + CurrentVersion: metric.CurrentValue, + AvailableVersion: metric.AvailableValue, + Severity: metric.Severity, + RepositorySource: metric.MetricType, + Metadata: metric.Metadata, } metricItems = append(metricItems, item) } @@ -311,13 +312,13 @@ func handleScanDocker(apiClient *client.Client, cfg *config.Config, ackTracker * imageItems := make([]client.DockerReportItem, 0, len(images)) for _, image := range images { item := client.DockerReportItem{ - PackageType: "docker_image", - PackageName: image.ImageName, - CurrentVersion: image.ImageID, - AvailableVersion: image.LatestImageID, - Severity: image.Severity, - RepositorySource: image.RepositorySource, - Metadata: image.Metadata, + PackageType: "docker_image", + PackageName: image.ImageName, + CurrentVersion: image.ImageID, + AvailableVersion: image.LatestImageID, + Severity: image.Severity, + RepositorySource: image.RepositorySource, + Metadata: image.Metadata, } imageItems = append(imageItems, item) } diff --git a/aggregator-agent/internal/client/client.go b/aggregator-agent/internal/client/client.go index 80f1f69..acb7bc5 100644 --- a/aggregator-agent/internal/client/client.go +++ b/aggregator-agent/internal/client/client.go @@ -20,14 +20,14 @@ import ( // Client handles API communication with the server type Client struct { - baseURL string - token string - http *http.Client - RapidPollingEnabled bool - RapidPollingUntil time.Time - machineID string // Cached machine ID for security binding - eventBuffer *event.Buffer - agentID uuid.UUID + baseURL string + token string + http *http.Client + RapidPollingEnabled bool + RapidPollingUntil time.Time + machineID string // Cached machine ID for security binding + eventBuffer *event.Buffer + agentID uuid.UUID } // NewClient creates a new API client @@ -54,13 +54,13 @@ func NewClient(baseURL, token string) *Client { func NewClientWithEventBuffer(baseURL, token string, statePath string, agentID uuid.UUID) *Client { client := NewClient(baseURL, token) client.agentID = agentID - + // Initialize event buffer if state path is provided if statePath != "" { eventBufferPath := filepath.Join(statePath, "events_buffer.json") client.eventBuffer = event.NewBuffer(eventBufferPath) } - + return client } @@ -121,22 +121,22 @@ func (c *Client) SetToken(token string) { // RegisterRequest is the payload for agent registration type RegisterRequest struct { - Hostname string `json:"hostname"` - OSType string `json:"os_type"` - OSVersion string `json:"os_version"` - OSArchitecture string `json:"os_architecture"` - AgentVersion string `json:"agent_version"` - RegistrationToken string `json:"registration_token,omitempty"` // Fallback method - MachineID string `json:"machine_id"` - PublicKeyFingerprint string `json:"public_key_fingerprint"` - Metadata map[string]string `json:"metadata"` + Hostname string `json:"hostname"` + OSType string `json:"os_type"` + OSVersion string `json:"os_version"` + OSArchitecture string `json:"os_architecture"` + AgentVersion string `json:"agent_version"` + RegistrationToken string `json:"registration_token,omitempty"` // Fallback method + MachineID string `json:"machine_id"` + PublicKeyFingerprint string `json:"public_key_fingerprint"` + Metadata map[string]string `json:"metadata"` } // RegisterResponse is returned after successful registration type RegisterResponse struct { AgentID uuid.UUID `json:"agent_id"` - Token string `json:"token"` // Short-lived access token (24h) - RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d) + Token string `json:"token"` // Short-lived access token (24h) + RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d) Config map[string]interface{} `json:"config"` } @@ -156,7 +156,7 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) { c.bufferEvent("registration_failure", "marshal_error", "error", "client", fmt.Sprintf("Failed to marshal registration request: %v", err), map[string]interface{}{ - "error": err.Error(), + "error": err.Error(), "hostname": req.Hostname, }) return nil, err @@ -168,7 +168,7 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) { c.bufferEvent("registration_failure", "request_creation_error", "error", "client", fmt.Sprintf("Failed to create registration request: %v", err), map[string]interface{}{ - "error": err.Error(), + "error": err.Error(), "hostname": req.Hostname, }) return nil, err @@ -187,8 +187,8 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) { c.bufferEvent("registration_failure", "network_error", "error", "client", fmt.Sprintf("Registration request failed: %v", err), map[string]interface{}{ - "error": err.Error(), - "hostname": req.Hostname, + "error": err.Error(), + "hostname": req.Hostname, "server_url": c.baseURL, }) return nil, err @@ -198,15 +198,15 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) { if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) errorMsg := fmt.Sprintf("registration failed: %s - %s", resp.Status, string(bodyBytes)) - + // Buffer registration failure event c.bufferEvent("registration_failure", "api_error", "error", "client", errorMsg, map[string]interface{}{ - "status_code": resp.StatusCode, + "status_code": resp.StatusCode, "response_body": string(bodyBytes), - "hostname": req.Hostname, - "server_url": c.baseURL, + "hostname": req.Hostname, + "server_url": c.baseURL, }) return nil, fmt.Errorf(errorMsg) } @@ -217,7 +217,7 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) { c.bufferEvent("registration_failure", "decode_error", "error", "client", fmt.Sprintf("Failed to decode registration response: %v", err), map[string]interface{}{ - "error": err.Error(), + "error": err.Error(), "hostname": req.Hostname, }) return nil, err @@ -239,7 +239,7 @@ type TokenRenewalRequest struct { // TokenRenewalResponse is returned after successful token renewal type TokenRenewalResponse struct { - Token string `json:"token"` // New short-lived access token (24h) + Token string `json:"token"` // New short-lived access token (24h) } // RenewToken uses refresh token to get a new access token (proper implementation) @@ -258,7 +258,7 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion c.bufferEvent("token_renewal_failure", "marshal_error", "error", "client", fmt.Sprintf("Failed to marshal token renewal request: %v", err), map[string]interface{}{ - "error": err.Error(), + "error": err.Error(), "agent_id": agentID.String(), }) return err @@ -270,7 +270,7 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion c.bufferEvent("token_renewal_failure", "request_creation_error", "error", "client", fmt.Sprintf("Failed to create token renewal request: %v", err), map[string]interface{}{ - "error": err.Error(), + "error": err.Error(), "agent_id": agentID.String(), }) return err @@ -283,8 +283,8 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion c.bufferEvent("token_renewal_failure", "network_error", "error", "client", fmt.Sprintf("Token renewal request failed: %v", err), map[string]interface{}{ - "error": err.Error(), - "agent_id": agentID.String(), + "error": err.Error(), + "agent_id": agentID.String(), "server_url": c.baseURL, }) return err @@ -294,15 +294,15 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) errorMsg := fmt.Sprintf("token renewal failed: %s - %s", resp.Status, string(bodyBytes)) - + // Buffer token renewal failure event c.bufferEvent("token_renewal_failure", "api_error", "error", "client", errorMsg, map[string]interface{}{ - "status_code": resp.StatusCode, + "status_code": resp.StatusCode, "response_body": string(bodyBytes), - "agent_id": agentID.String(), - "server_url": c.baseURL, + "agent_id": agentID.String(), + "server_url": c.baseURL, }) return fmt.Errorf(errorMsg) } @@ -313,7 +313,7 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion c.bufferEvent("token_renewal_failure", "decode_error", "error", "client", fmt.Sprintf("Failed to decode token renewal response: %v", err), map[string]interface{}{ - "error": err.Error(), + "error": err.Error(), "agent_id": agentID.String(), }) return err @@ -327,10 +327,10 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion // Command represents a command from the server type Command struct { - ID string `json:"id"` - Type string `json:"type"` - Params map[string]interface{} `json:"params"` - Signature string `json:"signature,omitempty"` // Ed25519 signature of the command + ID string `json:"id"` + Type string `json:"type"` + Params map[string]interface{} `json:"params"` + Signature string `json:"signature,omitempty"` // Ed25519 signature of the command } // CommandItem is an alias for Command for consistency with server models @@ -338,9 +338,9 @@ type CommandItem = Command // CommandsResponse contains pending commands type CommandsResponse struct { - Commands []Command `json:"commands"` - RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"` - AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // IDs server has received + Commands []Command `json:"commands"` + RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"` + AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // IDs server has received } // RapidPollingConfig contains rapid polling configuration from server @@ -351,16 +351,16 @@ type RapidPollingConfig struct { // SystemMetrics represents lightweight system metrics sent with check-ins type SystemMetrics struct { - CPUPercent float64 `json:"cpu_percent,omitempty"` - MemoryPercent float64 `json:"memory_percent,omitempty"` - MemoryUsedGB float64 `json:"memory_used_gb,omitempty"` - MemoryTotalGB float64 `json:"memory_total_gb,omitempty"` - DiskUsedGB float64 `json:"disk_used_gb,omitempty"` - DiskTotalGB float64 `json:"disk_total_gb,omitempty"` - DiskPercent float64 `json:"disk_percent,omitempty"` - Uptime string `json:"uptime,omitempty"` - Version string `json:"version,omitempty"` // Agent version - Metadata map[string]interface{} `json:"metadata,omitempty"` // Additional metadata + CPUPercent float64 `json:"cpu_percent,omitempty"` + MemoryPercent float64 `json:"memory_percent,omitempty"` + MemoryUsedGB float64 `json:"memory_used_gb,omitempty"` + MemoryTotalGB float64 `json:"memory_total_gb,omitempty"` + DiskUsedGB float64 `json:"disk_used_gb,omitempty"` + DiskTotalGB float64 `json:"disk_total_gb,omitempty"` + DiskPercent float64 `json:"disk_percent,omitempty"` + Uptime string `json:"uptime,omitempty"` + Version string `json:"version,omitempty"` // Agent version + Metadata map[string]interface{} `json:"metadata,omitempty"` // Additional metadata // Command acknowledgment tracking PendingAcknowledgments []string `json:"pending_acknowledgments,omitempty"` // Command IDs awaiting ACK @@ -427,9 +427,9 @@ func (c *Client) GetCommands(agentID uuid.UUID, metrics *SystemMetrics) (*Comman // UpdateReport represents discovered updates type UpdateReport struct { - CommandID string `json:"command_id"` - Timestamp time.Time `json:"timestamp"` - Updates []UpdateReportItem `json:"updates"` + CommandID string `json:"command_id"` + Timestamp time.Time `json:"timestamp"` + Updates []UpdateReportItem `json:"updates"` } // UpdateReportItem represents a single update @@ -480,20 +480,20 @@ func (c *Client) ReportUpdates(agentID uuid.UUID, report UpdateReport) error { // MetricsReport represents metrics data (storage, system, CPU, memory) type MetricsReport struct { - CommandID string `json:"command_id"` - Timestamp time.Time `json:"timestamp"` + CommandID string `json:"command_id"` + Timestamp time.Time `json:"timestamp"` Metrics []MetricsReportItem `json:"metrics"` } // MetricsReportItem represents a single metric type MetricsReportItem struct { - PackageType string `json:"package_type"` - PackageName string `json:"package_name"` - CurrentVersion string `json:"current_version"` - AvailableVersion string `json:"available_version"` - Severity string `json:"severity"` - RepositorySource string `json:"repository_source"` - Metadata map[string]interface{} `json:"metadata"` + PackageType string `json:"package_type"` + PackageName string `json:"package_name"` + CurrentVersion string `json:"current_version"` + AvailableVersion string `json:"available_version"` + Severity string `json:"severity"` + RepositorySource string `json:"repository_source"` + Metadata map[string]interface{} `json:"metadata"` } // ReportMetrics sends metrics data to the server @@ -529,20 +529,20 @@ func (c *Client) ReportMetrics(agentID uuid.UUID, report MetricsReport) error { // DockerReport represents Docker image information type DockerReport struct { - CommandID string `json:"command_id"` - Timestamp time.Time `json:"timestamp"` + CommandID string `json:"command_id"` + Timestamp time.Time `json:"timestamp"` Images []DockerReportItem `json:"images"` } // DockerReportItem represents a single Docker image type DockerReportItem struct { - PackageType string `json:"package_type"` - PackageName string `json:"package_name"` - CurrentVersion string `json:"current_version"` - AvailableVersion string `json:"available_version"` - Severity string `json:"severity"` - RepositorySource string `json:"repository_source"` - Metadata map[string]interface{} `json:"metadata"` + PackageType string `json:"package_type"` + PackageName string `json:"package_name"` + CurrentVersion string `json:"current_version"` + AvailableVersion string `json:"available_version"` + Severity string `json:"severity"` + RepositorySource string `json:"repository_source"` + Metadata map[string]interface{} `json:"metadata"` } // ReportDockerImages sends Docker image information to the server @@ -577,7 +577,7 @@ func (c *Client) ReportDockerImages(agentID uuid.UUID, report DockerReport) erro } // ReportStorageMetrics sends storage metrics to the server via dedicated endpoint -func (c *Client) ReportStorageMetrics(agentID uuid.UUID, report StorageMetricReport) error { +func (c *Client) ReportStorageMetrics(agentID uuid.UUID, report models.StorageMetricReport) error { url := fmt.Sprintf("%s/api/v1/agents/%s/storage-metrics", c.baseURL, agentID) body, err := json.Marshal(report) @@ -652,26 +652,26 @@ func (c *Client) ReportLog(agentID uuid.UUID, report LogReport) error { // DependencyReport represents a dependency report after dry run type DependencyReport struct { - PackageName string `json:"package_name"` - PackageType string `json:"package_type"` - Dependencies []string `json:"dependencies"` - UpdateID string `json:"update_id"` - DryRunResult *InstallResult `json:"dry_run_result,omitempty"` + PackageName string `json:"package_name"` + PackageType string `json:"package_type"` + Dependencies []string `json:"dependencies"` + UpdateID string `json:"update_id"` + DryRunResult *InstallResult `json:"dry_run_result,omitempty"` } // InstallResult represents the result of a package installation attempt type InstallResult struct { Success bool `json:"success"` ErrorMessage string `json:"error_message,omitempty"` - Stdout string `json:"stdout,omitempty"` - Stderr string `json:"stderr,omitempty"` - ExitCode int `json:"exit_code"` - DurationSeconds int `json:"duration_seconds"` - Action string `json:"action,omitempty"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + ExitCode int `json:"exit_code"` + DurationSeconds int `json:"duration_seconds"` + Action string `json:"action,omitempty"` PackagesInstalled []string `json:"packages_installed,omitempty"` ContainersUpdated []string `json:"containers_updated,omitempty"` Dependencies []string `json:"dependencies,omitempty"` - IsDryRun bool `json:"is_dry_run"` + IsDryRun bool `json:"is_dry_run"` } // ReportDependencies sends dependency report to the server @@ -707,7 +707,7 @@ func (c *Client) ReportDependencies(agentID uuid.UUID, report DependencyReport) // SystemInfoReport represents system information updates type SystemInfoReport struct { - Timestamp time.Time `json:"timestamp"` + Timestamp time.Time `json:"timestamp"` CPUModel string `json:"cpu_model,omitempty"` CPUCores int `json:"cpu_cores,omitempty"` CPUThreads int `json:"cpu_threads,omitempty"` diff --git a/aggregator-agent/internal/config/subsystems.go b/aggregator-agent/internal/config/subsystems.go index 8e44b00..5e9e82c 100644 --- a/aggregator-agent/internal/config/subsystems.go +++ b/aggregator-agent/internal/config/subsystems.go @@ -7,7 +7,7 @@ type SubsystemConfig struct { // Execution settings Enabled bool `json:"enabled"` Timeout time.Duration `json:"timeout"` // Timeout for this subsystem - + // Interval for this subsystem (in minutes) // This controls how often the server schedules scans for this subsystem IntervalMinutes int `json:"interval_minutes,omitempty"` @@ -51,16 +51,16 @@ func GetDefaultSubsystemsConfig() SubsystemsConfig { // Default circuit breaker config defaultCB := CircuitBreakerConfig{ Enabled: true, - FailureThreshold: 3, // 3 consecutive failures + FailureThreshold: 3, // 3 consecutive failures FailureWindow: 10 * time.Minute, // within 10 minutes OpenDuration: 30 * time.Minute, // circuit open for 30 min - HalfOpenAttempts: 2, // 2 successful attempts to close circuit + HalfOpenAttempts: 2, // 2 successful attempts to close circuit } // Aggressive circuit breaker for Windows Update (known to be slow/problematic) windowsCB := CircuitBreakerConfig{ Enabled: true, - FailureThreshold: 2, // Only 2 failures + FailureThreshold: 2, // Only 2 failures FailureWindow: 15 * time.Minute, OpenDuration: 60 * time.Minute, // Open for 1 hour HalfOpenAttempts: 3, @@ -68,15 +68,15 @@ func GetDefaultSubsystemsConfig() SubsystemsConfig { return SubsystemsConfig{ System: SubsystemConfig{ - Enabled: true, // System scanner always available + Enabled: true, // System scanner always available Timeout: 10 * time.Second, // System info should be fast - IntervalMinutes: 5, // Default: 5 minutes + IntervalMinutes: 5, // Default: 5 minutes CircuitBreaker: defaultCB, }, Updates: SubsystemConfig{ - Enabled: true, // Virtual subsystem for package update scheduling - Timeout: 0, // Not used - delegates to individual package scanners - IntervalMinutes: 15, // Default: 15 minutes + Enabled: true, // Virtual subsystem for package update scheduling + Timeout: 0, // Not used - delegates to individual package scanners + IntervalMinutes: 720, // Default: 12 hours (more reasonable for update checks) CircuitBreaker: CircuitBreakerConfig{Enabled: false}, // No circuit breaker for virtual subsystem }, APT: SubsystemConfig{ @@ -88,31 +88,31 @@ func GetDefaultSubsystemsConfig() SubsystemsConfig { DNF: SubsystemConfig{ Enabled: true, Timeout: 15 * time.Minute, // TODO: Make scanner timeouts user-adjustable via settings. DNF operations can take a long time on large systems - IntervalMinutes: 15, // Default: 15 minutes + IntervalMinutes: 15, // Default: 15 minutes CircuitBreaker: defaultCB, }, Docker: SubsystemConfig{ Enabled: true, Timeout: 60 * time.Second, // Registry queries can be slow - IntervalMinutes: 15, // Default: 15 minutes + IntervalMinutes: 15, // Default: 15 minutes CircuitBreaker: defaultCB, }, Windows: SubsystemConfig{ Enabled: true, Timeout: 10 * time.Minute, // Windows Update can be VERY slow - IntervalMinutes: 15, // Default: 15 minutes + IntervalMinutes: 15, // Default: 15 minutes CircuitBreaker: windowsCB, }, Winget: SubsystemConfig{ Enabled: true, Timeout: 2 * time.Minute, // Winget has multiple retry strategies - IntervalMinutes: 15, // Default: 15 minutes + IntervalMinutes: 15, // Default: 15 minutes CircuitBreaker: defaultCB, }, Storage: SubsystemConfig{ Enabled: true, Timeout: 10 * time.Second, // Disk info should be fast - IntervalMinutes: 5, // Default: 5 minutes + IntervalMinutes: 5, // Default: 5 minutes CircuitBreaker: defaultCB, }, } diff --git a/aggregator-agent/internal/orchestrator/storage_scanner.go b/aggregator-agent/internal/orchestrator/storage_scanner.go index f3ce773..ac2ca49 100644 --- a/aggregator-agent/internal/orchestrator/storage_scanner.go +++ b/aggregator-agent/internal/orchestrator/storage_scanner.go @@ -4,7 +4,6 @@ import ( "fmt" "time" - "github.com/Fimeg/RedFlag/aggregator-agent/internal/client" "github.com/Fimeg/RedFlag/aggregator-agent/internal/system" ) diff --git a/aggregator-server/internal/api/handlers/storage_metrics.go b/aggregator-server/internal/api/handlers/storage_metrics.go index 6d99d6b..eeb5f23 100644 --- a/aggregator-server/internal/api/handlers/storage_metrics.go +++ b/aggregator-server/internal/api/handlers/storage_metrics.go @@ -1,16 +1,14 @@ package handlers import ( - "encoding/json" - "fmt" + "log" "net/http" "time" "github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries" "github.com/Fimeg/RedFlag/aggregator-server/internal/models" + "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/gorilla/mux" - "github.com/lib/pq" ) // StorageMetricsHandler handles storage metrics endpoints @@ -25,28 +23,21 @@ func NewStorageMetricsHandler(queries *queries.StorageMetricsQueries) *StorageMe } } -// ReportStorageMetrics handles POST /api/v1/agents/{id}/storage-metrics -func (h *StorageMetricsHandler) ReportStorageMetrics(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - agentIDStr := vars["id"] - - // Parse agent ID - agentID, err := uuid.Parse(agentIDStr) - if err != nil { - http.Error(w, "Invalid agent ID", http.StatusBadRequest) - return - } +// ReportStorageMetrics handles POST /api/v1/agents/:id/storage-metrics +func (h *StorageMetricsHandler) ReportStorageMetrics(c *gin.Context) { + // Get agent ID from context (set by middleware) + agentID := c.MustGet("agent_id").(uuid.UUID) // Parse request body var req models.StorageMetricRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } // Validate agent ID matches if req.AgentID != agentID { - http.Error(w, "Agent ID mismatch", http.StatusBadRequest) + c.JSON(http.StatusBadRequest, gin.H{"error": "Agent ID mismatch"}) return } @@ -68,58 +59,34 @@ func (h *StorageMetricsHandler) ReportStorageMetrics(w http.ResponseWriter, r *h CreatedAt: time.Now(), } - if err := h.queries.InsertStorageMetric(r.Context(), dbMetric); err != nil { + if err := h.queries.InsertStorageMetric(c.Request.Context(), dbMetric); err != nil { log.Printf("[ERROR] Failed to insert storage metric for agent %s: %v\n", agentID, err) - http.Error(w, "Failed to insert storage metric", http.StatusInternalServerError) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert storage metric"}) return } } - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{ + c.JSON(http.StatusOK, gin.H{ "status": "success", "message": "Storage metrics reported successfully", }) } -// GetStorageMetrics handles GET /api/v1/agents/{id}/storage-metrics -func (h *StorageMetricsHandler) GetStorageMetrics(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - agentIDStr := vars["id"] - - // Parse agent ID - agentID, err := uuid.Parse(agentIDStr) - if err != nil { - http.Error(w, "Invalid agent ID", http.StatusBadRequest) - return - } - - // Optional query parameters for pagination/limit - limit := parseIntQueryParam(r, "limit", 100) - offset := parseIntQueryParam(r, "offset", 0) +// GetStorageMetrics handles GET /api/v1/agents/:id/storage-metrics +func (h *StorageMetricsHandler) GetStorageMetrics(c *gin.Context) { + // Get agent ID from context (set by middleware) + agentID := c.MustGet("agent_id").(uuid.UUID) // Get storage metrics - metrics, err := h.queries.GetStorageMetricsByAgentID(r.Context(), agentID, limit, offset) + metrics, err := h.queries.GetStorageMetricsByAgentID(c.Request.Context(), agentID, 100, 0) if err != nil { log.Printf("[ERROR] Failed to retrieve storage metrics for agent %s: %v\n", agentID, err) - http.Error(w, "Failed to retrieve storage metrics", http.StatusInternalServerError) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve storage metrics"}) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + c.JSON(http.StatusOK, gin.H{ "metrics": metrics, "total": len(metrics), }) } - -// parseIntQueryParam safely parses integer query parameters with defaults -func parseIntQueryParam(r *http.Request, key string, defaultValue int) int { - if val := r.URL.Query().Get(key); val != "" { - var result int - if _, err := fmt.Sscanf(val, "%d", &result); err == nil && result > 0 { - return result - } - } - return defaultValue -} \ No newline at end of file diff --git a/aggregator-server/internal/database/queries/storage_metrics.go b/aggregator-server/internal/database/queries/storage_metrics.go index 88ba20e..c9e7fcd 100644 --- a/aggregator-server/internal/database/queries/storage_metrics.go +++ b/aggregator-server/internal/database/queries/storage_metrics.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "fmt" - "time" "github.com/Fimeg/RedFlag/aggregator-server/internal/models" "github.com/google/uuid" @@ -136,7 +135,7 @@ func (q *StorageMetricsQueries) GetLatestStorageMetrics(ctx context.Context, age // GetStorageMetricsSummary returns summary statistics for an agent func (q *StorageMetricsQueries) GetStorageMetricsSummary(ctx context.Context, agentID uuid.UUID) (map[string]interface{}, error) { query := ` - SELECT + SELECT COUNT(*) as total_disks, COUNT(CASE WHEN severity = 'critical' THEN 1 END) as critical_disks, COUNT(CASE WHEN severity = 'important' THEN 1 END) as important_disks, @@ -149,19 +148,38 @@ func (q *StorageMetricsQueries) GetStorageMetricsSummary(ctx context.Context, ag AND created_at >= NOW() - INTERVAL '24 hours' ` - var summary map[string]interface{} + var ( + totalDisks int + criticalDisks int + importantDisks int + avgUsedPercent sql.NullFloat64 + maxUsedPercent sql.NullFloat64 + firstCollectedAt sql.NullTime + lastCollectedAt sql.NullTime + ) + err := q.db.QueryRowContext(ctx, query, agentID).Scan( - &summary["total_disks"], - &summary["critical_disks"], - &summary["important_disks"], - &summary["avg_used_percent"], - &summary["max_used_percent"], - &summary["first_collected_at"], - &summary["last_collected_at"], + &totalDisks, + &criticalDisks, + &importantDisks, + &avgUsedPercent, + &maxUsedPercent, + &firstCollectedAt, + &lastCollectedAt, ) if err != nil { return nil, fmt.Errorf("failed to get storage metrics summary: %w", err) } + summary := map[string]interface{}{ + "total_disks": totalDisks, + "critical_disks": criticalDisks, + "important_disks": importantDisks, + "avg_used_percent": avgUsedPercent.Float64, + "max_used_percent": maxUsedPercent.Float64, + "first_collected_at": firstCollectedAt.Time, + "last_collected_at": lastCollectedAt.Time, + } + return summary, nil -} \ No newline at end of file +} diff --git a/aggregator-web/src/components/AgentHealth.tsx b/aggregator-web/src/components/AgentHealth.tsx index 3c3fff8..72067dd 100644 --- a/aggregator-web/src/components/AgentHealth.tsx +++ b/aggregator-web/src/components/AgentHealth.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { RefreshCw, @@ -266,9 +266,9 @@ export function AgentHealth({ agentId }: AgentHealthProps) { { value: 20160, label: '2 weeks' }, ]; - - const enabledCount = useMemo(() => subsystems.filter(s => s.enabled).length, [subsystems]); - const autoRunCount = useMemo(() => subsystems.filter(s => s.auto_run && s.enabled).length, [subsystems]); + // Calculate counts directly without useMemo + const enabledCount = subsystems.filter(s => s.enabled).length; + const autoRunCount = subsystems.filter(s => s.auto_run && s.enabled).length; return (
diff --git a/aggregator-web/src/components/AgentScanners.tsx b/aggregator-web/src/components/AgentScanners.tsx deleted file mode 100644 index df129f5..0000000 --- a/aggregator-web/src/components/AgentScanners.tsx +++ /dev/null @@ -1,624 +0,0 @@ -import React, { useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { - RefreshCw, - Activity, - Play, - HardDrive, - Cpu, - Container, - Package, - Shield, - Fingerprint, - CheckCircle, - AlertCircle, - XCircle, - Upload, -} from 'lucide-react'; -import { formatRelativeTime } from '@/lib/utils'; -import { agentApi, securityApi } from '@/lib/api'; -import toast from 'react-hot-toast'; -import { cn } from '@/lib/utils'; -import { AgentSubsystem } from '@/types'; -import { AgentUpdatesModal } from './AgentUpdatesModal'; - -interface AgentScannersProps { - agentId: string; -} - -// Map subsystem types to icons and display names -const subsystemConfig: Record = { - updates: { - icon: , - name: 'Package Update Scanner', - description: 'Scans for available package updates (APT, DNF, Windows Update, etc.)', - category: 'system', - }, - storage: { - icon: , - name: 'Disk Usage Reporter', - description: 'Reports disk usage metrics and storage availability', - category: 'storage', - }, - system: { - icon: , - name: 'System Metrics Scanner', - description: 'Reports CPU, memory, processes, and system uptime', - category: 'system', - }, - docker: { - icon: , - name: 'Docker Image Scanner', - description: 'Scans Docker containers for available image updates', - category: 'system', - }, -}; - -export function AgentScanners({ agentId }: AgentScannersProps) { - const [showUpdateModal, setShowUpdateModal] = useState(false); - const queryClient = useQueryClient(); - - // Fetch subsystems from API - const { data: subsystems = [], isLoading, refetch } = useQuery({ - queryKey: ['subsystems', agentId], - queryFn: async () => { - const data = await agentApi.getSubsystems(agentId); - return data; - }, - refetchInterval: 30000, // Refresh every 30 seconds - }); - - // Fetch agent data for Update Agent button - const { data: agent } = useQuery({ - queryKey: ['agent', agentId], - queryFn: () => agentApi.getAgent(agentId), - refetchInterval: 30000, - }); - - // Fetch security health status - const { data: securityOverview, isLoading: securityLoading } = useQuery({ - queryKey: ['security-overview'], - queryFn: async () => { - const data = await securityApi.getOverview(); - return data; - }, - refetchInterval: 60000, // Refresh every minute - }); - - // Helper function to get security status color and icon - const getSecurityStatusDisplay = (status: string) => { - switch (status) { - case 'healthy': - case 'operational': - return { - color: 'text-green-600 bg-green-100 border-green-200', - icon: - }; - case 'enforced': - return { - color: 'text-blue-600 bg-blue-100 border-blue-200', - icon: - }; - case 'degraded': - return { - color: 'text-amber-600 bg-amber-100 border-amber-200', - icon: - }; - case 'unhealthy': - case 'unavailable': - return { - color: 'text-red-600 bg-red-100 border-red-200', - icon: - }; - default: - return { - color: 'text-gray-600 bg-gray-100 border-gray-200', - icon: - }; - } - }; - - // Get security icon for subsystem type - const getSecurityIcon = (type: string) => { - switch (type) { - case 'ed25519_signing': - return ; - case 'nonce_validation': - return ; - case 'machine_binding': - return ; - case 'command_validation': - return ; - default: - return ; - } - }; - - // Get display name for security subsystem - const getSecurityDisplayName = (type: string) => { - switch (type) { - case 'ed25519_signing': - return 'Ed25519 Signing'; - case 'nonce_validation': - return 'Nonce Protection'; - case 'machine_binding': - return 'Machine Binding'; - case 'command_validation': - return 'Command Validation'; - default: - return type; - } - }; - - // Toggle subsystem enabled/disabled - const toggleSubsystemMutation = useMutation({ - mutationFn: async ({ subsystem, enabled }: { subsystem: string; enabled: boolean }) => { - if (enabled) { - return await agentApi.enableSubsystem(agentId, subsystem); - } else { - return await agentApi.disableSubsystem(agentId, subsystem); - } - }, - onSuccess: (_, variables) => { - toast.success(`${subsystemConfig[variables.subsystem]?.name || variables.subsystem} ${variables.enabled ? 'enabled' : 'disabled'}`); - queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] }); - }, - onError: (error: any, variables) => { - toast.error(`Failed to ${variables.enabled ? 'enable' : 'disable'} subsystem: ${error.response?.data?.error || error.message}`); - }, - }); - - // Update subsystem interval - const updateIntervalMutation = useMutation({ - mutationFn: async ({ subsystem, intervalMinutes }: { subsystem: string; intervalMinutes: number }) => { - return await agentApi.setSubsystemInterval(agentId, subsystem, intervalMinutes); - }, - onSuccess: (_, variables) => { - toast.success(`Interval updated to ${variables.intervalMinutes} minutes`); - queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] }); - }, - onError: (error: any) => { - toast.error(`Failed to update interval: ${error.response?.data?.error || error.message}`); - }, - }); - - // Toggle auto-run - const toggleAutoRunMutation = useMutation({ - mutationFn: async ({ subsystem, autoRun }: { subsystem: string; autoRun: boolean }) => { - return await agentApi.setSubsystemAutoRun(agentId, subsystem, autoRun); - }, - onSuccess: (_, variables) => { - toast.success(`Auto-run ${variables.autoRun ? 'enabled' : 'disabled'}`); - queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] }); - }, - onError: (error: any) => { - toast.error(`Failed to toggle auto-run: ${error.response?.data?.error || error.message}`); - }, - }); - - // Trigger manual scan - const triggerScanMutation = useMutation({ - mutationFn: async (subsystem: string) => { - return await agentApi.triggerSubsystem(agentId, subsystem); - }, - onSuccess: (_, subsystem) => { - toast.success(`${subsystemConfig[subsystem]?.name || subsystem} scan triggered`); - queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] }); - }, - onError: (error: any) => { - toast.error(`Failed to trigger scan: ${error.response?.data?.error || error.message}`); - }, - }); - - const handleToggleEnabled = (subsystem: string, currentEnabled: boolean) => { - toggleSubsystemMutation.mutate({ subsystem, enabled: !currentEnabled }); - }; - - const handleIntervalChange = (subsystem: string, intervalMinutes: number) => { - updateIntervalMutation.mutate({ subsystem, intervalMinutes }); - }; - - const handleToggleAutoRun = (subsystem: string, currentAutoRun: boolean) => { - toggleAutoRunMutation.mutate({ subsystem, autoRun: !currentAutoRun }); - }; - - const handleTriggerScan = (subsystem: string) => { - triggerScanMutation.mutate(subsystem); - }; - - const frequencyOptions = [ - { value: 5, label: '5 min' }, - { value: 15, label: '15 min' }, - { value: 30, label: '30 min' }, - { value: 60, label: '1 hour' }, - { value: 240, label: '4 hours' }, - { value: 720, label: '12 hours' }, - { value: 1440, label: '24 hours' }, - ]; - - - const enabledCount = subsystems.filter(s => s.enabled).length; - const autoRunCount = subsystems.filter(s => s.auto_run && s.enabled).length; - - return ( -
- {/* Subsystems Section - Continuous Surface */} -
-
-
-

Subsystems

-

- {enabledCount} enabled • {autoRunCount} auto-running • {subsystems.length} total -

-
- -
- - {isLoading ? ( -
- - Loading subsystems... -
- ) : subsystems.length === 0 ? ( -
- -

No subsystems found

-

- Subsystems will be created automatically when the agent checks in. -

-
- ) : ( -
- - - - - - - - - - - - - - - {subsystems.map((subsystem: AgentSubsystem) => { - const config = subsystemConfig[subsystem.subsystem] || { - icon: , - name: subsystem.subsystem, - description: 'Custom subsystem', - category: 'system', - }; - - return ( - - {/* Subsystem Name */} - - - {/* Category */} - - - {/* Enabled Toggle */} - - - {/* Auto-Run Toggle */} - - - {/* Interval Selector */} - - - {/* Last Run */} - - - {/* Next Run */} - - - {/* Actions */} - - - ); - })} - -
SubsystemCategoryEnabledAuto-RunIntervalLast RunNext RunActions
-
- {config.icon} -
-
{config.name}
-
{config.description}
-
-
-
{config.category} - - - - - {subsystem.enabled ? ( - - ) : ( - - - )} - - {subsystem.last_run_at ? formatRelativeTime(subsystem.last_run_at) : '-'} - - {subsystem.next_run_at && subsystem.auto_run ? formatRelativeTime(subsystem.next_run_at) : '-'} - - -
-
- )} -
- - {/* Security Health Section - Continuous Surface */} -
-
-
- -

Security Health

-
- -
- - {securityLoading ? ( -
- - Loading security status... -
- ) : securityOverview ? ( -
- {/* Overall Status - Compact */} -
-
-
-
-

Overall Status

-

- {securityOverview.overall_status === 'healthy' ? 'All systems nominal' : - securityOverview.overall_status === 'degraded' ? `${securityOverview.alerts.length} issue(s)` : - 'Critical issues'} -

-
-
-
- {securityOverview.overall_status === 'healthy' && } - {securityOverview.overall_status === 'degraded' && } - {securityOverview.overall_status === 'unhealthy' && } - {securityOverview.overall_status.toUpperCase()} -
-
- - {/* Security Grid - 2x2 Layout */} -
- {Object.entries(securityOverview.subsystems).map(([key, subsystem]) => { - const statusColors = { - healthy: 'bg-green-100 text-green-700 border-green-200', - enforced: 'bg-blue-100 text-blue-700 border-blue-200', - degraded: 'bg-amber-100 text-amber-700 border-amber-200', - unhealthy: 'bg-red-100 text-red-700 border-red-200' - }; - - return ( -
-
-
-
-
- {getSecurityIcon(key)} -
-
-

- {getSecurityDisplayName(key)} -

-

- {key === 'command_validation' ? - `${subsystem.metrics?.total_pending_commands || 0} pending` : - key === 'ed25519_signing' ? - 'Key valid' : - key === 'machine_binding' ? - `${subsystem.checks?.recent_violations || 0} violations` : - key === 'nonce_validation' ? - `${subsystem.checks?.validation_failures || 0} blocked` : - subsystem.status} -

-
-
-
- {subsystem.status === 'healthy' && } - {subsystem.status === 'enforced' && } - {subsystem.status === 'degraded' && } - {subsystem.status === 'unhealthy' && } -
-
-
-
- ); - })} -
- - {/* Detailed Info Panel */} -
- {Object.entries(securityOverview.subsystems).map(([key, subsystem]) => { - const checks = subsystem.checks || {}; - - return ( -
-
-

- {key === 'nonce_validation' ? - `Nonces: ${subsystem.metrics?.total_pending_commands || 0} | Max: ${checks.max_age_minutes || 5}m | Failures: ${checks.validation_failures || 0}` : - key === 'machine_binding' ? - `Bound: ${checks.bound_agents || 'N/A'} | Violations: ${checks.recent_violations || 0} | Method: Hardware` : - key === 'ed25519_signing' ? - `Key: ${checks.public_key_fingerprint?.substring(0, 16) || 'N/A'}... | Algo: ${checks.algorithm || 'Ed25519'}` : - key === 'command_validation' ? - `Processed: ${subsystem.metrics?.commands_last_hour || 0}/hr | Pending: ${subsystem.metrics?.total_pending_commands || 0}` : - `Status: ${subsystem.status}`} -

-
-
- ); - })} -
- - {/* Security Alerts & Recommendations */} - {(securityOverview.alerts.length > 0 || securityOverview.recommendations.length > 0) && ( -
- {securityOverview.alerts.length > 0 && ( -
-
- -

Alerts ({securityOverview.alerts.length})

-
-
    - {securityOverview.alerts.slice(0, 1).map((alert, index) => ( -
  • • {alert}
  • - ))} - {securityOverview.alerts.length > 1 && ( -
  • +{securityOverview.alerts.length - 1} more
  • - )} -
-
- )} - - {securityOverview.recommendations.length > 0 && ( -
-
- -

Recs ({securityOverview.recommendations.length})

-
-
    - {securityOverview.recommendations.slice(0, 1).map((rec, index) => ( -
  • • {rec}
  • - ))} - {securityOverview.recommendations.length > 1 && ( -
  • +{securityOverview.recommendations.length - 1} more
  • - )} -
-
- )} -
- )} - - {/* Stats Row */} -
-
-

{Object.keys(securityOverview.subsystems).length}

-

Systems

-
-
-

- {Object.values(securityOverview.subsystems).filter(s => s.status === 'healthy' || s.status === 'enforced').length} -

-

Healthy

-
-
-

{securityOverview.alerts.length}

-

Alerts

-
-
-

- {new Date(securityOverview.timestamp).toLocaleTimeString()} -

-

Updated

-
-
-
- ) : ( -
- -

Unable to load security status

-
- )} -
- - {/* Agent Updates Modal */} - { - setShowUpdateModal(false); - }} - selectedAgentIds={[agentId]} // Single agent for this scanner view - onAgentsUpdated={() => { - // Refresh agent and subsystems data after update - queryClient.invalidateQueries({ queryKey: ['agent', agentId] }); - queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] }); - }} - /> -
- ); -} diff --git a/aggregator-web/src/components/AgentStorage.tsx b/aggregator-web/src/components/AgentStorage.tsx index 93bef07..c833053 100644 --- a/aggregator-web/src/components/AgentStorage.tsx +++ b/aggregator-web/src/components/AgentStorage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { HardDrive, @@ -58,7 +58,7 @@ export function AgentStorage({ agentId }: AgentStorageProps) { const { data: storageData, refetch: refetchStorage } = useQuery({ queryKey: ['storage-metrics', agentId], queryFn: async () => { - return await storageMetricsApi.getStorageMetrics(agentId); + return await agentApi.getStorageMetrics(agentId); }, refetchInterval: 30000, // Refresh every 30 seconds }); @@ -124,18 +124,6 @@ export function AgentStorage({ agentId }: AgentStorageProps) { is_largest: disk.is_largest || false, })); }; - mountpoint: disk.mountpoint, - total: disk.total, - available: disk.available, - used: disk.used, - used_percent: disk.used_percent, - filesystem: disk.filesystem, - is_root: disk.is_root || false, - is_largest: disk.is_largest || false, - disk_type: disk.disk_type || 'Unknown', - device: disk.device || disk.filesystem, - })); - }; if (!agentData) { diff --git a/aggregator-web/src/pages/Agents.tsx b/aggregator-web/src/pages/Agents.tsx index d7a1075..138c2e9 100644 --- a/aggregator-web/src/pages/Agents.tsx +++ b/aggregator-web/src/pages/Agents.tsx @@ -39,7 +39,7 @@ import toast from 'react-hot-toast'; import { AgentSystemUpdates } from '@/components/AgentUpdates'; import { AgentStorage } from '@/components/AgentStorage'; import { AgentUpdatesEnhanced } from '@/components/AgentUpdatesEnhanced'; -import { AgentScanners } from '@/components/AgentScanners'; +import { AgentHealth } from '@/components/AgentHealth'; import { AgentUpdatesModal } from '@/components/AgentUpdatesModal'; import { BulkAgentUpdate } from '@/components/RelayList'; import ChatTimeline from '@/components/ChatTimeline'; @@ -955,7 +955,7 @@ const Agents: React.FC = () => { )} {activeTab === 'scanners' && ( - + )} {activeTab === 'history' && ( diff --git a/aggregator-web/src/types/index.ts b/aggregator-web/src/types/index.ts index d8429e2..a19b93c 100644 --- a/aggregator-web/src/types/index.ts +++ b/aggregator-web/src/types/index.ts @@ -414,4 +414,35 @@ export interface SubsystemStats { run_count: number; last_status: string; last_duration: number; +} + +// Security subsystem types +export interface SecuritySubsystem { + status: string; + enabled: boolean; + metrics?: { + total_pending_commands?: number; + commands_last_hour?: number; + }; + checks?: { + recent_violations?: number; + validation_failures?: number; + max_age_minutes?: number; + bound_agents?: number; + public_key_fingerprint?: string; + algorithm?: string; + }; +} + +export interface SecurityOverview { + overall_status: 'healthy' | 'degraded' | 'unhealthy'; + timestamp: string; + subsystems: { + ed25519_signing: SecuritySubsystem; + nonce_validation: SecuritySubsystem; + machine_binding: SecuritySubsystem; + command_validation: SecuritySubsystem; + }; + alerts: string[]; + recommendations: string[]; } \ No newline at end of file