From 01c09cefab9861abc537b23b268bcf6c1e347bad Mon Sep 17 00:00:00 2001 From: Fimeg Date: Sat, 1 Nov 2025 09:27:58 -0400 Subject: [PATCH] feat: agent UI redesign and version bump to 0.1.18 - Redesign AgentUpdatesEnhanced with tab-based workflow (pending/approved/installing/installed) - Add AgentStorage component with disk partition table - Add AgentScanners component for agent health monitoring - Fix agent removal not refreshing list (cache invalidation) - Bump agent version to 0.1.18 (enhanced disk detection) - Update server default version to 0.1.18 - Add command source tracking (system/manual) migration - Improve Linux disk detection for all physical mount points --- aggregator-agent/cmd/agent/main.go | 2 +- aggregator-agent/internal/system/info.go | 157 +++- .../internal/api/handlers/agents.go | 99 ++- .../internal/api/handlers/docker.go | 1 + .../internal/api/handlers/updates.go | 6 + aggregator-server/internal/config/config.go | 2 +- .../009_add_agent_version_tracking.up.sql | 2 +- .../migrations/014_add_command_source.up.sql | 17 + .../internal/database/queries/commands.go | 6 +- aggregator-server/internal/models/command.go | 8 + .../src/components/AgentScanners.tsx | 306 ++++++++ .../src/components/AgentStorage.tsx | 279 +++++++ .../src/components/AgentUpdatesEnhanced.tsx | 536 +++++++++++++ aggregator-web/src/hooks/useAgents.ts | 26 +- aggregator-web/src/pages/Agents.tsx | 743 ++++++++++-------- aggregator-web/src/types/index.ts | 5 +- 16 files changed, 1823 insertions(+), 372 deletions(-) create mode 100644 aggregator-server/internal/database/migrations/014_add_command_source.up.sql create mode 100644 aggregator-web/src/components/AgentScanners.tsx create mode 100644 aggregator-web/src/components/AgentStorage.tsx create mode 100644 aggregator-web/src/components/AgentUpdatesEnhanced.tsx diff --git a/aggregator-agent/cmd/agent/main.go b/aggregator-agent/cmd/agent/main.go index 67ef491..88a1431 100644 --- a/aggregator-agent/cmd/agent/main.go +++ b/aggregator-agent/cmd/agent/main.go @@ -23,7 +23,7 @@ import ( ) const ( - AgentVersion = "0.1.17" // Fixed Linux disk detection to show all physical mount points (/, /home, etc.) + AgentVersion = "0.1.18" // Enhanced disk detection with comprehensive partition reporting ) // getConfigPath returns the platform-specific config path diff --git a/aggregator-agent/internal/system/info.go b/aggregator-agent/internal/system/info.go index 124d81a..5392a3b 100644 --- a/aggregator-agent/internal/system/info.go +++ b/aggregator-agent/internal/system/info.go @@ -2,6 +2,7 @@ package system import ( "os/exec" + "regexp" "runtime" "strconv" "strings" @@ -41,14 +42,18 @@ type MemoryInfo struct { UsedPercent float64 `json:"used_percent"` } -// DiskInfo contains disk information +// DiskInfo contains disk information for modular storage management type DiskInfo struct { - Mountpoint string `json:"mountpoint"` - Total uint64 `json:"total"` - Available uint64 `json:"available"` - Used uint64 `json:"used"` - UsedPercent float64 `json:"used_percent"` - Filesystem string `json:"filesystem"` + Mountpoint string `json:"mountpoint"` + Total uint64 `json:"total"` + Available uint64 `json:"available"` + Used uint64 `json:"used"` + UsedPercent float64 `json:"used_percent"` + Filesystem string `json:"filesystem"` + IsRoot bool `json:"is_root"` // Primary system disk + IsLargest bool `json:"is_largest"` // Largest storage disk + DiskType string `json:"disk_type"` // SSD, HDD, NVMe, etc. + Device string `json:"device"` // Block device name } // GetSystemInfo collects detailed system information @@ -252,7 +257,7 @@ func getMemoryInfo() (*MemoryInfo, error) { return mem, nil } -// getDiskInfo gets disk information for mounted filesystems +// getDiskInfo gets disk information for mounted filesystems with enhanced detection func getDiskInfo() ([]DiskInfo, error) { var disks []DiskInfo @@ -262,6 +267,9 @@ func getDiskInfo() ([]DiskInfo, error) { if cmd, err := exec.LookPath("df"); err == nil { if data, err := exec.Command(cmd, "-h", "--output=target,size,used,avail,pcent,source").Output(); err == nil { lines := strings.Split(string(data), "\n") + + // First pass: collect all valid disks + var rawDisks []DiskInfo for i, line := range lines { if i == 0 || strings.TrimSpace(line) == "" { continue // Skip header and empty lines @@ -305,6 +313,7 @@ func getDiskInfo() ([]DiskInfo, error) { disk := DiskInfo{ Mountpoint: mountpoint, Filesystem: filesystem, + Device: filesystem, } // Parse sizes (df outputs in human readable format, we'll parse the numeric part) @@ -321,9 +330,36 @@ func getDiskInfo() ([]DiskInfo, error) { disk.UsedPercent = total } - disks = append(disks, disk) + rawDisks = append(rawDisks, disk) } } + + // Second pass: enhance with disk type detection and set flags + var largestSize uint64 = 0 + var largestIndex int = -1 + + for i := range rawDisks { + // Detect root filesystem + if rawDisks[i].Mountpoint == "/" || rawDisks[i].Mountpoint == "C:" { + rawDisks[i].IsRoot = true + } + + // Track largest disk + if rawDisks[i].Total > largestSize { + largestSize = rawDisks[i].Total + largestIndex = i + } + + // Detect disk type + rawDisks[i].DiskType = detectDiskType(rawDisks[i].Device) + } + + // Set largest disk flag + if largestIndex >= 0 { + rawDisks[largestIndex].IsLargest = true + } + + disks = rawDisks } } } @@ -331,7 +367,55 @@ func getDiskInfo() ([]DiskInfo, error) { return disks, nil } -// parseSize parses human readable size strings (like "1.5G" or "500M") +// detectDiskType determines the type of storage device (SSD, HDD, NVMe, etc.) +func detectDiskType(device string) string { + if device == "" { + return "Unknown" + } + + // Extract base device name (remove partition numbers like /dev/sda1 -> /dev/sda) + baseDevice := device + if strings.Contains(device, "/dev/") { + parts := strings.Fields(device) + if len(parts) > 0 { + baseDevice = parts[0] + // Remove partition numbers for common patterns + re := strings.NewReplacer("/dev/sda", "/dev/sda", "/dev/sdb", "/dev/sdb", "/dev/nvme0n1", "/dev/nvme0n1") + baseDevice = re.Replace(baseDevice) + + // More robust partition removal + if matches := regexp.MustCompile(`^(/dev/sd[a-z]|/dev/nvme\d+n\d|/dev/hd[a-z])\d*$`).FindStringSubmatch(baseDevice); len(matches) > 1 { + baseDevice = matches[1] + } + } + } + + // Check for NVMe + if strings.Contains(baseDevice, "nvme") { + return "NVMe" + } + + // Check for SSD indicators using lsblk + if cmd, err := exec.LookPath("lsblk"); err == nil { + if data, err := exec.Command(cmd, "-d", "-o", "rota,NAME", baseDevice).Output(); err == nil { + output := string(data) + if strings.Contains(output, "0") && strings.Contains(output, baseDevice[strings.LastIndex(baseDevice, "/")+1:]) { + return "SSD" // rota=0 indicates non-rotating (SSD) + } else if strings.Contains(output, "1") && strings.Contains(output, baseDevice[strings.LastIndex(baseDevice, "/")+1:]) { + return "HDD" // rota=1 indicates rotating (HDD) + } + } + } + + // Fallback detection based on device name patterns + if strings.Contains(baseDevice, "sd") || strings.Contains(baseDevice, "hd") { + return "HDD" // Traditional naming for SATA/IDE drives + } + + return "Unknown" +} + +// parseSize parses human readable size strings (like "1.5G", "500M", "3.7T") func parseSize(sizeStr string) (uint64, error) { sizeStr = strings.TrimSpace(sizeStr) if len(sizeStr) == 0 { @@ -340,14 +424,17 @@ func parseSize(sizeStr string) (uint64, error) { multiplier := uint64(1) unit := sizeStr[len(sizeStr)-1:] - if unit == "G" || unit == "g" { - multiplier = 1024 * 1024 * 1024 + if unit == "T" || unit == "t" { + multiplier = 1024 * 1024 * 1024 * 1024 // Terabyte + sizeStr = sizeStr[:len(sizeStr)-1] + } else if unit == "G" || unit == "g" { + multiplier = 1024 * 1024 * 1024 // Gigabyte sizeStr = sizeStr[:len(sizeStr)-1] } else if unit == "M" || unit == "m" { - multiplier = 1024 * 1024 + multiplier = 1024 * 1024 // Megabyte sizeStr = sizeStr[:len(sizeStr)-1] } else if unit == "K" || unit == "k" { - multiplier = 1024 + multiplier = 1024 // Kilobyte sizeStr = sizeStr[:len(sizeStr)-1] } @@ -433,9 +520,15 @@ type LightweightMetrics struct { MemoryPercent float64 MemoryUsedGB float64 MemoryTotalGB float64 + // Root filesystem disk info (primary disk) DiskUsedGB float64 DiskTotalGB float64 DiskPercent float64 + // Largest disk info (for systems with separate data partitions) + LargestDiskUsedGB float64 + LargestDiskTotalGB float64 + LargestDiskPercent float64 + LargestDiskMount string Uptime string } @@ -451,16 +544,36 @@ func GetLightweightMetrics() (*LightweightMetrics, error) { metrics.MemoryTotalGB = float64(mem.Total) / (1024 * 1024 * 1024) } - // Get primary disk info (root filesystem) + // Get disk info (both root and largest) if disks, err := getDiskInfo(); err == nil { - for _, disk := range disks { - // Look for root filesystem or first mountpoint - if disk.Mountpoint == "/" || disk.Mountpoint == "C:" || len(metrics.Uptime) == 0 { - metrics.DiskUsedGB = float64(disk.Used) / (1024 * 1024 * 1024) - metrics.DiskTotalGB = float64(disk.Total) / (1024 * 1024 * 1024) - metrics.DiskPercent = disk.UsedPercent - break + var rootDisk *DiskInfo + var largestDisk *DiskInfo + + for i, disk := range disks { + // Find root filesystem + if disk.Mountpoint == "/" || disk.Mountpoint == "C:" { + rootDisk = &disks[i] } + + // Track largest disk + if largestDisk == nil || disk.Total > largestDisk.Total { + largestDisk = &disks[i] + } + } + + // Set root disk metrics (primary disk) + if rootDisk != nil { + metrics.DiskUsedGB = float64(rootDisk.Used) / (1024 * 1024 * 1024) + metrics.DiskTotalGB = float64(rootDisk.Total) / (1024 * 1024 * 1024) + metrics.DiskPercent = rootDisk.UsedPercent + } + + // Set largest disk metrics (for data partitions like /home) + if largestDisk != nil && (rootDisk == nil || largestDisk.Total > rootDisk.Total) { + metrics.LargestDiskUsedGB = float64(largestDisk.Used) / (1024 * 1024 * 1024) + metrics.LargestDiskTotalGB = float64(largestDisk.Total) / (1024 * 1024 * 1024) + metrics.LargestDiskPercent = largestDisk.UsedPercent + metrics.LargestDiskMount = largestDisk.Mountpoint } } diff --git a/aggregator-server/internal/api/handlers/agents.go b/aggregator-server/internal/api/handlers/agents.go index c92b1e3..4cd8dc2 100644 --- a/aggregator-server/internal/api/handlers/agents.go +++ b/aggregator-server/internal/api/handlers/agents.go @@ -412,6 +412,7 @@ func (h *AgentHandler) GetCommands(c *gin.Context) { CommandType: models.CommandTypeDisableHeartbeat, Params: models.JSONB{}, Status: models.CommandStatusCompleted, + Source: models.CommandSourceSystem, Result: models.JSONB{ "message": "Heartbeat cleared - agent restarted without active heartbeat mode", }, @@ -492,6 +493,13 @@ func (h *AgentHandler) TriggerScan(c *gin.Context) { return } + // Trigger system heartbeat before scan (5 minutes should be enough for most scans) + if created, err := h.triggerSystemHeartbeat(agentID, 5); err != nil { + log.Printf("Warning: Failed to trigger system heartbeat for scan: %v", err) + } else if created { + log.Printf("[Scan] Heartbeat initiated for 'Scan Updates' operation on agent %s", agentID) + } + // Create scan command cmd := &models.AgentCommand{ ID: uuid.New(), @@ -499,6 +507,7 @@ func (h *AgentHandler) TriggerScan(c *gin.Context) { CommandType: models.CommandTypeScanUpdates, Params: models.JSONB{}, Status: models.CommandStatusPending, + Source: models.CommandSourceManual, } if err := h.commandQueries.CreateCommand(cmd); err != nil { @@ -534,7 +543,7 @@ func (h *AgentHandler) TriggerHeartbeat(c *gin.Context) { commandType = models.CommandTypeEnableHeartbeat } - // Create heartbeat command with duration parameter + // Create heartbeat command with duration parameter (manual = user-initiated) cmd := &models.AgentCommand{ ID: uuid.New(), AgentID: agentID, @@ -543,6 +552,7 @@ func (h *AgentHandler) TriggerHeartbeat(c *gin.Context) { "duration_minutes": request.DurationMinutes, }, Status: models.CommandStatusPending, + Source: models.CommandSourceManual, } if err := h.commandQueries.CreateCommand(cmd); err != nil { @@ -550,23 +560,26 @@ func (h *AgentHandler) TriggerHeartbeat(c *gin.Context) { return } - // TODO: Clean up previous heartbeat commands for this agent (only for enable commands) - // if request.Enabled { - // // Mark previous heartbeat commands as 'replaced' to clean up Live Operations view - // if err := h.commandQueries.MarkPreviousHeartbeatCommandsReplaced(agentID, cmd.ID); err != nil { - // log.Printf("Warning: Failed to mark previous heartbeat commands as replaced: %v", err) - // // Don't fail the request, just log the warning - // } else { - // log.Printf("[Heartbeat] Cleaned up previous heartbeat commands for agent %s", agentID) - // } - // } + // Store heartbeat source in agent metadata immediately + if request.Enabled { + agent, err := h.agentQueries.GetAgentByID(agentID) + if err == nil { + if agent.Metadata == nil { + agent.Metadata = models.JSONB{} + } + agent.Metadata["heartbeat_source"] = models.CommandSourceManual + if err := h.agentQueries.UpdateAgent(agent); err != nil { + log.Printf("Warning: Failed to update agent metadata with heartbeat source: %v", err) + } + } + } action := "disabled" if request.Enabled { action = "enabled" } - log.Printf("💓 Heartbeat %s command created for agent %s (duration: %d minutes)", + log.Printf("[Heartbeat] Manual heartbeat %s command created for agent %s (duration: %d minutes)", action, agentID, request.DurationMinutes) c.JSON(http.StatusOK, gin.H{ @@ -576,6 +589,60 @@ func (h *AgentHandler) TriggerHeartbeat(c *gin.Context) { }) } +// triggerSystemHeartbeat creates a system-initiated heartbeat command +// Returns true if heartbeat was created, false if skipped (already active) +func (h *AgentHandler) triggerSystemHeartbeat(agentID uuid.UUID, durationMinutes int) (bool, error) { + // Check if heartbeat should be enabled (not already active) + agent, err := h.agentQueries.GetAgentByID(agentID) + if err != nil { + log.Printf("Warning: Failed to get agent %s for heartbeat check: %v", agentID, err) + // Enable heartbeat by default if we can't check + } else { + // Check if rapid polling is already enabled and not expired + if enabled, ok := agent.Metadata["rapid_polling_enabled"].(bool); ok && enabled { + if untilStr, ok := agent.Metadata["rapid_polling_until"].(string); ok { + until, err := time.Parse(time.RFC3339, untilStr) + if err == nil && until.After(time.Now().Add(time.Duration(durationMinutes)*time.Minute)) { + // Heartbeat is already active for sufficient time + log.Printf("[Heartbeat] Agent %s already has active heartbeat until %s (skipping system heartbeat)", agentID, untilStr) + return false, nil + } + } + } + } + + // Create system heartbeat command + cmd := &models.AgentCommand{ + ID: uuid.New(), + AgentID: agentID, + CommandType: models.CommandTypeEnableHeartbeat, + Params: models.JSONB{ + "duration_minutes": durationMinutes, + }, + Status: models.CommandStatusPending, + Source: models.CommandSourceSystem, + } + + if err := h.commandQueries.CreateCommand(cmd); err != nil { + return false, fmt.Errorf("failed to create system heartbeat command: %w", err) + } + + // Store heartbeat source in agent metadata immediately + agent, err = h.agentQueries.GetAgentByID(agentID) + if err == nil { + if agent.Metadata == nil { + agent.Metadata = models.JSONB{} + } + agent.Metadata["heartbeat_source"] = models.CommandSourceSystem + if err := h.agentQueries.UpdateAgent(agent); err != nil { + log.Printf("Warning: Failed to update agent metadata with heartbeat source: %v", err) + } + } + + log.Printf("[Heartbeat] System heartbeat initiated for agent %s - Scan operation (duration: %d minutes)", agentID, durationMinutes) + return true, nil +} + // GetHeartbeatStatus returns the current heartbeat status for an agent func (h *AgentHandler) GetHeartbeatStatus(c *gin.Context) { idStr := c.Param("id") @@ -598,6 +665,7 @@ func (h *AgentHandler) GetHeartbeatStatus(c *gin.Context) { "until": nil, "active": false, "duration_minutes": 0, + "source": nil, } if agent.Metadata != nil { @@ -620,6 +688,11 @@ func (h *AgentHandler) GetHeartbeatStatus(c *gin.Context) { if duration, exists := agent.Metadata["rapid_polling_duration_minutes"]; exists { response["duration_minutes"] = duration.(float64) } + + // Get source if available + if source, exists := agent.Metadata["heartbeat_source"]; exists { + response["source"] = source.(string) + } } } } @@ -674,6 +747,7 @@ func (h *AgentHandler) TriggerUpdate(c *gin.Context) { CommandType: models.CommandTypeInstallUpdate, Params: params, Status: models.CommandStatusPending, + Source: models.CommandSourceManual, } if err := h.commandQueries.CreateCommand(cmd); err != nil { @@ -1008,6 +1082,7 @@ func (h *AgentHandler) TriggerReboot(c *gin.Context) { "message": req.Message, }, Status: models.CommandStatusPending, + Source: models.CommandSourceManual, CreatedAt: time.Now(), } diff --git a/aggregator-server/internal/api/handlers/docker.go b/aggregator-server/internal/api/handlers/docker.go index cd8c692..abdbebe 100644 --- a/aggregator-server/internal/api/handlers/docker.go +++ b/aggregator-server/internal/api/handlers/docker.go @@ -427,6 +427,7 @@ func (h *DockerHandler) InstallUpdate(c *gin.Context) { "container_id": containerID, }, Status: models.CommandStatusPending, + Source: models.CommandSourceManual, // User-initiated Docker update } if err := h.commandQueries.CreateCommand(command); err != nil { diff --git a/aggregator-server/internal/api/handlers/updates.go b/aggregator-server/internal/api/handlers/updates.go index 48d4fcc..610705e 100644 --- a/aggregator-server/internal/api/handlers/updates.go +++ b/aggregator-server/internal/api/handlers/updates.go @@ -443,6 +443,7 @@ func (h *UpdateHandler) InstallUpdate(c *gin.Context) { "package_type": update.PackageType, }, Status: models.CommandStatusPending, + Source: models.CommandSourceManual, CreatedAt: time.Now(), } @@ -456,6 +457,7 @@ func (h *UpdateHandler) InstallUpdate(c *gin.Context) { "duration_minutes": 10, }, Status: models.CommandStatusPending, + Source: models.CommandSourceSystem, CreatedAt: time.Now(), } @@ -548,6 +550,7 @@ func (h *UpdateHandler) ReportDependencies(c *gin.Context) { "dependencies": []string{}, // Empty dependencies array }, Status: models.CommandStatusPending, + Source: models.CommandSourceManual, CreatedAt: time.Now(), } @@ -561,6 +564,7 @@ func (h *UpdateHandler) ReportDependencies(c *gin.Context) { "duration_minutes": 10, }, Status: models.CommandStatusPending, + Source: models.CommandSourceSystem, CreatedAt: time.Now(), } @@ -628,6 +632,7 @@ func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) { "dependencies": update.Metadata["dependencies"], // Dependencies stored in metadata }, Status: models.CommandStatusPending, + Source: models.CommandSourceManual, CreatedAt: time.Now(), } @@ -641,6 +646,7 @@ func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) { "duration_minutes": 10, }, Status: models.CommandStatusPending, + Source: models.CommandSourceSystem, CreatedAt: time.Now(), } diff --git a/aggregator-server/internal/config/config.go b/aggregator-server/internal/config/config.go index c9378ae..7e20fa7 100644 --- a/aggregator-server/internal/config/config.go +++ b/aggregator-server/internal/config/config.go @@ -84,7 +84,7 @@ func Load() (*Config, error) { cfg.CheckInInterval = checkInInterval cfg.OfflineThreshold = offlineThreshold cfg.Timezone = getEnv("TIMEZONE", "UTC") - cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.1.16") + cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.1.18") // Handle missing secrets if cfg.Admin.Password == "" || cfg.Admin.JWTSecret == "" || cfg.Database.Password == "" { diff --git a/aggregator-server/internal/database/migrations/009_add_agent_version_tracking.up.sql b/aggregator-server/internal/database/migrations/009_add_agent_version_tracking.up.sql index e9e9b01..ebe9516 100644 --- a/aggregator-server/internal/database/migrations/009_add_agent_version_tracking.up.sql +++ b/aggregator-server/internal/database/migrations/009_add_agent_version_tracking.up.sql @@ -2,7 +2,7 @@ -- This enables the hybrid version tracking system ALTER TABLE agents -ADD COLUMN current_version VARCHAR(50) DEFAULT '0.1.3', +ADD COLUMN current_version VARCHAR(50) DEFAULT '0.0.0', ADD COLUMN update_available BOOLEAN DEFAULT FALSE, ADD COLUMN last_version_check TIMESTAMP DEFAULT CURRENT_TIMESTAMP; diff --git a/aggregator-server/internal/database/migrations/014_add_command_source.up.sql b/aggregator-server/internal/database/migrations/014_add_command_source.up.sql new file mode 100644 index 0000000..60b67bc --- /dev/null +++ b/aggregator-server/internal/database/migrations/014_add_command_source.up.sql @@ -0,0 +1,17 @@ +-- Add source field to agent_commands table to track command origin +-- 'manual' = user-initiated via UI +-- 'system' = automatically triggered by system operations (scans, installs, etc) + +ALTER TABLE agent_commands +ADD COLUMN source VARCHAR(20) DEFAULT 'manual' NOT NULL; + +-- Add check constraint to ensure valid source values +ALTER TABLE agent_commands +ADD CONSTRAINT agent_commands_source_check +CHECK (source IN ('manual', 'system')); + +-- Add index for filtering commands by source +CREATE INDEX idx_agent_commands_source ON agent_commands(source); + +-- Update comment +COMMENT ON COLUMN agent_commands.source IS 'Command origin: manual (user-initiated) or system (auto-triggered)'; diff --git a/aggregator-server/internal/database/queries/commands.go b/aggregator-server/internal/database/queries/commands.go index e65818f..8bb43ce 100644 --- a/aggregator-server/internal/database/queries/commands.go +++ b/aggregator-server/internal/database/queries/commands.go @@ -21,9 +21,9 @@ func NewCommandQueries(db *sqlx.DB) *CommandQueries { func (q *CommandQueries) CreateCommand(cmd *models.AgentCommand) error { query := ` INSERT INTO agent_commands ( - id, agent_id, command_type, params, status, retried_from_id + id, agent_id, command_type, params, status, source, retried_from_id ) VALUES ( - :id, :agent_id, :command_type, :params, :status, :retried_from_id + :id, :agent_id, :command_type, :params, :status, :source, :retried_from_id ) ` _, err := q.db.NamedExec(query, cmd) @@ -183,6 +183,7 @@ func (q *CommandQueries) GetActiveCommands() ([]models.ActiveCommandInfo, error) c.command_type, c.params, c.status, + c.source, c.created_at, c.sent_at, c.result, @@ -244,6 +245,7 @@ func (q *CommandQueries) GetRecentCommands(limit int) ([]models.ActiveCommandInf c.agent_id, c.command_type, c.status, + c.source, c.created_at, c.sent_at, c.completed_at, diff --git a/aggregator-server/internal/models/command.go b/aggregator-server/internal/models/command.go index 31584cb..f31ec94 100644 --- a/aggregator-server/internal/models/command.go +++ b/aggregator-server/internal/models/command.go @@ -13,6 +13,7 @@ type AgentCommand struct { CommandType string `json:"command_type" db:"command_type"` Params JSONB `json:"params" db:"params"` Status string `json:"status" db:"status"` + Source string `json:"source" db:"source"` CreatedAt time.Time `json:"created_at" db:"created_at"` SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` @@ -64,6 +65,12 @@ const ( CommandStatusRunning = "running" ) +// Command sources +const ( + CommandSourceManual = "manual" // User-initiated via UI + CommandSourceSystem = "system" // Auto-triggered by system operations +) + // ActiveCommandInfo represents information about an active command for UI display type ActiveCommandInfo struct { ID uuid.UUID `json:"id" db:"id"` @@ -71,6 +78,7 @@ type ActiveCommandInfo struct { CommandType string `json:"command_type" db:"command_type"` Params JSONB `json:"params" db:"params"` Status string `json:"status" db:"status"` + Source string `json:"source" db:"source"` CreatedAt time.Time `json:"created_at" db:"created_at"` SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` diff --git a/aggregator-web/src/components/AgentScanners.tsx b/aggregator-web/src/components/AgentScanners.tsx new file mode 100644 index 0000000..95e6698 --- /dev/null +++ b/aggregator-web/src/components/AgentScanners.tsx @@ -0,0 +1,306 @@ +import React, { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { + MonitorPlay, + RefreshCw, + Settings, + Activity, + Clock, + CheckCircle, + XCircle, + Play, + Square, + Database, + Shield, + Search, +} from 'lucide-react'; +import { formatRelativeTime } from '@/lib/utils'; +import { agentApi } from '@/lib/api'; +import toast from 'react-hot-toast'; +import { cn } from '@/lib/utils'; + +interface AgentScannersProps { + agentId: string; +} + +interface ScannerConfig { + id: string; + name: string; + description: string; + icon: React.ReactNode; + enabled: boolean; + frequency: number; // minutes + last_run?: string; + next_run?: string; + status: 'idle' | 'running' | 'completed' | 'failed'; + category: 'storage' | 'security' | 'system' | 'network'; +} + +interface ScannerResponse { + scanner_id: string; + status: string; + message: string; + next_run?: string; +} + +export function AgentScanners({ agentId }: AgentScannersProps) { + // Mock agent health monitoring configs - in real implementation, these would come from the backend + const [scanners, setScanners] = useState([ + { + id: 'disk-reporter', + name: 'Disk Usage Reporter', + description: 'Agent reports disk usage metrics to server', + icon: , + enabled: true, + frequency: 15, // 15 minutes + last_run: new Date(Date.now() - 10 * 60 * 1000).toISOString(), // 10 minutes ago + status: 'completed', + category: 'storage', + }, + { + id: 'docker-check', + name: 'Docker Check-in', + description: 'Agent checks for Docker container status', + icon: , + enabled: true, + frequency: 60, // 1 hour + last_run: new Date(Date.now() - 45 * 60 * 1000).toISOString(), // 45 minutes ago + status: 'completed', + category: 'system', + }, + { + id: 'security-check', + name: 'Security Check-in (Coming Soon)', + description: 'CVE scanning & security advisory checks - not yet implemented', + icon: , + enabled: false, + frequency: 240, // 4 hours + status: 'idle', + category: 'security', + }, + { + id: 'agent-heartbeat', + name: 'Agent Heartbeat', + description: 'Agent check-in interval and health reporting', + icon: , + enabled: true, + frequency: 30, // 30 minutes + last_run: new Date(Date.now() - 5 * 60 * 1000).toISOString(), // 5 minutes ago + status: 'running', + category: 'system', + }, + ]); + + // Toggle scanner mutation + const toggleScannerMutation = useMutation({ + mutationFn: async ({ scannerId, enabled, frequency }: { scannerId: string; enabled: boolean; frequency: number }) => { + const response = await agentApi.toggleScanner(agentId, scannerId, enabled, frequency); + return response; + }, + onSuccess: (data: ScannerResponse, variables) => { + toast.success(`Scanner ${variables.enabled ? 'enabled' : 'disabled'} successfully`); + // Update local state + setScanners(prev => prev.map(scanner => + scanner.id === variables.scannerId + ? { + ...scanner, + enabled: variables.enabled, + frequency: variables.frequency, + status: variables.enabled ? 'idle' : 'disabled' as any, + next_run: data.next_run + } + : scanner + )); + }, + onError: (error: any, variables) => { + toast.error(`Failed to ${variables.enabled ? 'enable' : 'disable'} scanner: ${error.message || 'Unknown error'}`); + }, + }); + + // Run scanner mutation + const runScannerMutation = useMutation({ + mutationFn: async (scannerId: string) => { + const response = await agentApi.runScanner(agentId, scannerId); + return response; + }, + onSuccess: (data: ScannerResponse, scannerId) => { + toast.success('Scanner execution initiated'); + // Update local state + setScanners(prev => prev.map(scanner => + scanner.id === scannerId + ? { ...scanner, status: 'running', last_run: new Date().toISOString() } + : scanner + )); + }, + onError: (error: any) => { + toast.error(`Failed to run scanner: ${error.message || 'Unknown error'}`); + }, + }); + + const handleToggleScanner = (scannerId: string, enabled: boolean, frequency: number) => { + toggleScannerMutation.mutate({ scannerId, enabled, frequency }); + }; + + const handleRunScanner = (scannerId: string) => { + runScannerMutation.mutate(scannerId); + }; + + const handleFrequencyChange = (scannerId: string, frequency: number) => { + const scanner = scanners.find(s => s.id === scannerId); + if (scanner) { + handleToggleScanner(scannerId, scanner.enabled, frequency); + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'running': + return ; + case 'completed': + return ; + case 'failed': + return ; + default: + return ; + } + }; + + const getFrequencyLabel = (frequency: number) => { + if (frequency < 60) return `${frequency}m`; + if (frequency < 1440) return `${frequency / 60}h`; + return `${frequency / 1440}d`; + }; + + 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 = scanners.filter(s => s.enabled).length; + const runningCount = scanners.filter(s => s.status === 'running').length; + const failedCount = scanners.filter(s => s.status === 'failed').length; + + return ( +
+ {/* Compact Summary */} +
+
+
+
+ Active: + {enabledCount}/{scanners.length} +
+
+ Running: + {runningCount} +
+ {failedCount > 0 && ( +
+ Failed: + {failedCount} +
+ )} +
+
+
+ + {/* Agent Health Monitoring Table */} +
+
+

Agent Check-in Configuration

+
+ +
+ + + + + + + + + + + + + + {scanners.map((scanner) => ( + + {/* Scanner Name */} + + + {/* Category */} + + + {/* Status */} + + + {/* Enabled Toggle */} + + + {/* Frequency */} + + + {/* Last Run */} + + + {/* Actions */} + + + ))} + +
Check TypeCategoryStatusEnabledCheck IntervalLast CheckActions
+
+ {scanner.icon} +
+
{scanner.name}
+
{scanner.description}
+
+
+
{scanner.category} +
+ {getStatusIcon(scanner.status)} + + {scanner.status} + +
+
+ + {scanner.enabled ? 'ON' : 'OFF'} + + + {scanner.enabled ? ( + {getFrequencyLabel(scanner.frequency)} + ) : ( + - + )} + + {scanner.last_run ? formatRelativeTime(scanner.last_run) : '-'} + + Auto +
+
+
+ + {/* Compact note */} +
+ Agent check-ins report system state to the server on scheduled intervals. The agent initiates all communication - the server never "scans" your machine. +
+
+ ); +} diff --git a/aggregator-web/src/components/AgentStorage.tsx b/aggregator-web/src/components/AgentStorage.tsx new file mode 100644 index 0000000..e033063 --- /dev/null +++ b/aggregator-web/src/components/AgentStorage.tsx @@ -0,0 +1,279 @@ +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + HardDrive, + RefreshCw, + Database, + Search, + Activity, + Monitor, + AlertTriangle, + CheckCircle, + Info, + TrendingUp, + Server, +} from 'lucide-react'; +import { formatBytes, formatRelativeTime } from '@/lib/utils'; +import { agentApi } from '@/lib/api'; +import toast from 'react-hot-toast'; +import { cn } from '@/lib/utils'; + +interface AgentStorageProps { + agentId: string; +} + +interface DiskInfo { + mountpoint: string; + total: number; + available: number; + used: number; + used_percent: number; + filesystem: string; + is_root: boolean; + is_largest: boolean; + disk_type: string; + device: string; +} + +interface StorageMetrics { + cpu_percent: number; + memory_percent: number; + memory_used_gb: number; + memory_total_gb: number; + disk_used_gb: number; + disk_total_gb: number; + disk_percent: number; + largest_disk_used_gb: number; + largest_disk_total_gb: number; + largest_disk_percent: number; + largest_disk_mount: string; + uptime: string; +} + +export function AgentStorage({ agentId }: AgentStorageProps) { + const [isScanning, setIsScanning] = useState(false); + + // Fetch agent's latest system info with enhanced disk data + const { data: agentData, refetch: refetchAgent } = useQuery({ + queryKey: ['agent', agentId], + queryFn: async () => { + return await agentApi.getAgent(agentId); + }, + refetchInterval: 30000, // Refresh every 30 seconds + }); + + const handleFullStorageScan = async () => { + setIsScanning(true); + try { + // Trigger a system scan to get full disk inventory + await agentApi.scanAgent(agentId); + toast.success('Full storage scan initiated'); + + // Refresh data after a short delay + setTimeout(() => { + refetchAgent(); + setIsScanning(false); + }, 3000); + } catch (error) { + toast.error('Failed to initiate storage scan'); + setIsScanning(false); + } + }; + + // Extract storage metrics from agent metadata + const storageMetrics: StorageMetrics | null = agentData ? { + cpu_percent: 0, + memory_percent: agentData.metadata?.memory_percent || 0, + memory_used_gb: agentData.metadata?.memory_used_gb || 0, + memory_total_gb: agentData.metadata?.memory_total_gb || 0, + disk_used_gb: agentData.metadata?.disk_used_gb || 0, + disk_total_gb: agentData.metadata?.disk_total_gb || 0, + disk_percent: agentData.metadata?.disk_percent || 0, + largest_disk_used_gb: agentData.metadata?.largest_disk_used_gb || 0, + largest_disk_total_gb: agentData.metadata?.largest_disk_total_gb || 0, + largest_disk_percent: agentData.metadata?.largest_disk_percent || 0, + largest_disk_mount: agentData.metadata?.largest_disk_mount || '', + uptime: agentData.metadata?.uptime || '', + } : null; + + // Parse disk info from system information if available + const parseDiskInfo = (): DiskInfo[] => { + const systemInfo = agentData?.system_info; + if (!systemInfo?.disk_info) return []; + + return systemInfo.disk_info.map((disk: any) => ({ + 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, + })); + }; + + const getDiskTypeIcon = (diskType: string) => { + switch (diskType.toLowerCase()) { + case 'nvme': return ; + case 'ssd': return ; + case 'hdd': return ; + default: return ; + } + }; + + if (!agentData) { + return ( +
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+
+ ))} +
+
+
+ ); + } + + const disks = parseDiskInfo(); + + return ( +
+ {/* Clean minimal header */} +
+

System Resources

+ +
+ + {/* Simple list - no boxes, just clean rows */} +
+ {/* Memory */} + {storageMetrics && storageMetrics.memory_total_gb > 0 && ( +
+
+ Memory + + {storageMetrics.memory_used_gb.toFixed(1)} / {storageMetrics.memory_total_gb.toFixed(1)} GB + ({storageMetrics.memory_percent.toFixed(0)}%) + +
+
+
+
+
+ )} + + {/* Root Disk */} + {storageMetrics && storageMetrics.disk_total_gb > 0 && ( +
+
+ Root filesystem + + {storageMetrics.disk_used_gb.toFixed(1)} / {storageMetrics.disk_total_gb.toFixed(1)} GB + ({storageMetrics.disk_percent.toFixed(0)}%) + +
+
+
+
+
+ )} + + {/* Largest disk if different */} + {storageMetrics && storageMetrics.largest_disk_total_gb > 0 && storageMetrics.largest_disk_mount !== '/' && ( +
+
+ {storageMetrics.largest_disk_mount} + + {storageMetrics.largest_disk_used_gb.toFixed(1)} / {storageMetrics.largest_disk_total_gb.toFixed(1)} GB + ({storageMetrics.largest_disk_percent.toFixed(0)}%) + +
+
+
+
+
+ )} +
+ + {/* All partitions - minimal table */} + {disks.length > 0 && ( +
+

All partitions

+
+ + + + + + + + + + + + + {disks.map((disk, index) => ( + + + + + + + + + ))} + +
MountDeviceTypeUsedTotalUsage
+
+ {disk.mountpoint} + {disk.is_root && root} +
+
{disk.device}{disk.disk_type}{formatBytes(disk.used)}{formatBytes(disk.total)} +
+ {disk.used_percent.toFixed(0)}% +
+
+
+
+
+
+
+ )} + + {/* Last updated - minimal */} + {agentData && ( +
+ Last updated {agentData.last_seen ? formatRelativeTime(agentData.last_seen) : 'unknown'} +
+ )} +
+ ); +} diff --git a/aggregator-web/src/components/AgentUpdatesEnhanced.tsx b/aggregator-web/src/components/AgentUpdatesEnhanced.tsx new file mode 100644 index 0000000..4bcee6d --- /dev/null +++ b/aggregator-web/src/components/AgentUpdatesEnhanced.tsx @@ -0,0 +1,536 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Search, + Package, + Download, + CheckCircle, + RefreshCw, + Terminal, + Filter, + ChevronDown, + ChevronRight, + Check, + X, +} from 'lucide-react'; +import { formatRelativeTime, formatBytes } from '@/lib/utils'; +import { updateApi, agentApi } from '@/lib/api'; +import toast from 'react-hot-toast'; +import { cn } from '@/lib/utils'; +import type { UpdatePackage } from '@/types'; + +interface AgentUpdatesEnhancedProps { + agentId: string; +} + +interface AgentUpdateResponse { + updates: UpdatePackage[]; + total: number; +} + +interface CommandResponse { + command_id: string; + status: string; + message: string; +} + +interface LogResponse { + stdout: string; + stderr: string; + exit_code: number; + duration_seconds: number; + result: string; +} + +type StatusTab = 'pending' | 'approved' | 'installing' | 'installed'; + +export function AgentUpdatesEnhanced({ agentId }: AgentUpdatesEnhancedProps) { + const [activeStatus, setActiveStatus] = useState('pending'); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedSeverity, setSelectedSeverity] = useState('all'); + const [showLogsModal, setShowLogsModal] = useState(false); + const [logsData, setLogsData] = useState(null); + const [isLoadingLogs, setIsLoadingLogs] = useState(false); + const [expandedUpdates, setExpandedUpdates] = useState>(new Set()); + const [selectedUpdates, setSelectedUpdates] = useState([]); + + const queryClient = useQueryClient(); + + // Fetch updates with status filter + const { data: updateData, isLoading, error, refetch } = useQuery({ + queryKey: ['agent-updates', agentId, activeStatus, currentPage, pageSize, searchTerm, selectedSeverity], + queryFn: async () => { + const params = { + page: currentPage, + page_size: pageSize, + agent_id: agentId, + status: activeStatus, + ...(searchTerm && { search: searchTerm }), + ...(selectedSeverity !== 'all' && { severity: selectedSeverity }), + }; + + const response = await updateApi.getUpdates(params); + return response; + }, + refetchInterval: 30000, + }); + + // Mutations + const approveMutation = useMutation({ + mutationFn: async (updateId: string) => { + const response = await updateApi.approveUpdate(updateId); + return response; + }, + onSuccess: () => { + toast.success('Update approved'); + refetch(); + queryClient.invalidateQueries({ queryKey: ['agent-updates'] }); + }, + onError: (error: any) => { + toast.error(`Failed to approve: ${error.message || 'Unknown error'}`); + }, + }); + + const installMutation = useMutation({ + mutationFn: async (updateId: string) => { + const response = await agentApi.installUpdate(agentId, updateId); + return response; + }, + onSuccess: () => { + toast.success('Installation started'); + setTimeout(() => { + refetch(); + queryClient.invalidateQueries({ queryKey: ['active-commands'] }); + }, 2000); + }, + onError: (error: any) => { + toast.error(`Failed to install: ${error.message || 'Unknown error'}`); + }, + }); + + const bulkApproveMutation = useMutation({ + mutationFn: async (updateIds: string[]) => { + const response = await updateApi.approveMultiple(updateIds); + return response; + }, + onSuccess: () => { + toast.success(`${selectedUpdates.length} updates approved`); + setSelectedUpdates([]); + refetch(); + }, + onError: (error: any) => { + toast.error(`Failed to approve: ${error.message || 'Unknown error'}`); + }, + }); + + const getLogsMutation = useMutation({ + mutationFn: async (commandId: string) => { + setIsLoadingLogs(true); + const response = await agentApi.getCommandLogs(agentId, commandId); + return response; + }, + onSuccess: (data: LogResponse) => { + setLogsData(data); + setShowLogsModal(true); + }, + onError: (error: any) => { + toast.error(`Failed to fetch logs: ${error.message || 'Unknown error'}`); + }, + onSettled: () => { + setIsLoadingLogs(false); + }, + }); + + const updates = updateData?.updates || []; + const totalCount = updateData?.total || 0; + const totalPages = Math.ceil(totalCount / pageSize); + + const getSeverityColor = (severity: string) => { + switch (severity.toLowerCase()) { + case 'critical': return 'text-red-600 bg-red-50'; + case 'important': + case 'high': return 'text-orange-600 bg-orange-50'; + case 'moderate': + case 'medium': return 'text-yellow-600 bg-yellow-50'; + case 'low': + case 'none': return 'text-blue-600 bg-blue-50'; + default: return 'text-gray-600 bg-gray-50'; + } + }; + + const handleSelectUpdate = (updateId: string, checked: boolean) => { + if (checked) { + setSelectedUpdates([...selectedUpdates, updateId]); + } else { + setSelectedUpdates(selectedUpdates.filter(id => id !== updateId)); + } + }; + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedUpdates(updates.map((update: UpdatePackage) => update.id)); + } else { + setSelectedUpdates([]); + } + }; + + const handleApprove = async (updateId: string) => { + approveMutation.mutate(updateId); + }; + + const handleInstall = async (updateId: string) => { + installMutation.mutate(updateId); + }; + + const handleBulkApprove = async () => { + if (selectedUpdates.length === 0) { + toast.error('Select at least one update'); + return; + } + bulkApproveMutation.mutate(selectedUpdates); + }; + + const handleViewLogs = async (update: UpdatePackage) => { + const recentCommand = update.recent_command_id; + if (recentCommand) { + getLogsMutation.mutate(recentCommand); + } else { + toast.error('No recent command logs available for this package'); + } + }; + + const toggleExpanded = (updateId: string) => { + const newExpanded = new Set(expandedUpdates); + if (newExpanded.has(updateId)) { + newExpanded.delete(updateId); + } else { + newExpanded.add(updateId); + } + setExpandedUpdates(newExpanded); + }; + + if (isLoading) { + return ( +
+
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+ ))} +
+
+ ); + } + + if (error) { + return ( +
+ Error loading updates: {(error as Error).message} +
+ ); + } + + return ( +
+ {/* Tabs */} +
+ {[ + { key: 'pending', label: 'Pending' }, + { key: 'approved', label: 'Approved' }, + { key: 'installing', label: 'Installing' }, + { key: 'installed', label: 'Installed' }, + ].map((tab) => ( + + ))} +
+ + {/* Filters and Actions */} +
+
+ + {totalCount} update{totalCount !== 1 ? 's' : ''} + + {['critical', 'high', 'medium', 'low'].map((severity) => { + const count = updates.filter(u => u.severity?.toLowerCase() === severity).length; + if (count === 0) return null; + return ( + + {count} {severity} + + ); + })} +
+ + {selectedUpdates.length > 0 && activeStatus === 'pending' && ( + + )} +
+ + {/* Search and Filters */} +
+
+
+ + setSearchTerm(e.target.value)} + placeholder="Search packages..." + className="pl-9 pr-3 py-1.5 w-full border border-gray-300 rounded text-sm" + /> +
+
+ + +
+ + {/* Updates List */} + {updates.length === 0 ? ( +
+ {activeStatus === 'installed' ? ( +
+

Installed updates are shown in History

+ +
+ ) : ( + `No ${activeStatus} updates` + )} +
+ ) : ( +
+ {updates.map((update) => { + const isExpanded = expandedUpdates.has(update.id); + return ( +
+
+ {/* Checkbox for pending */} + {activeStatus === 'pending' && ( + handleSelectUpdate(update.id, e.target.checked)} + onClick={(e) => e.stopPropagation()} + className="h-4 w-4 rounded border-gray-300" + /> + )} + + {/* Main content */} +
toggleExpanded(update.id)} + > +
+ + {update.severity.toUpperCase()} + + {update.package_name} + {update.current_version} → {update.available_version} +
+ +
+ {activeStatus === 'pending' && ( + + )} + {activeStatus === 'approved' && ( + + )} + {update.recent_command_id && ( + + )} + {isExpanded ? ( + + ) : ( + + )} +
+
+
+ + {/* Expanded Details */} + {isExpanded && ( +
+
+ {update.metadata?.description && ( +

{update.metadata.description}

+ )} +
+
Type: {update.package_type}
+
Severity: {update.severity}
+ {update.metadata?.size_bytes && ( +
Size: {formatBytes(update.metadata.size_bytes)}
+ )} + {update.last_discovered_at && ( +
Discovered: {formatRelativeTime(update.last_discovered_at)}
+ )} + {update.approved_at && ( +
Approved: {formatRelativeTime(update.approved_at)}
+ )} + {update.installed_at && ( +
Installed: {formatRelativeTime(update.installed_at)}
+ )} +
+
+
+ )} +
+ ); + })} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + {Math.min((currentPage - 1) * pageSize + 1, totalCount)} - {Math.min(currentPage * pageSize, totalCount)} of {totalCount} + +
+ + Page {currentPage} of {totalPages} + +
+
+ )} + + {/* Logs Modal */} + {showLogsModal && logsData && ( +
+
+
+

+ + Installation Logs +

+ +
+ +
+
+
+ Result: + + {logsData.result || 'Unknown'} + +
+
+ Exit Code: + {logsData.exit_code} +
+
+ Duration: + {logsData.duration_seconds}s +
+
+ + {logsData.stdout && ( +
+

Standard Output

+
+                    {logsData.stdout}
+                  
+
+ )} + + {logsData.stderr && ( +
+

Standard Error

+
+                    {logsData.stderr}
+                  
+
+ )} +
+
+
+ )} +
+ ); +} diff --git a/aggregator-web/src/hooks/useAgents.ts b/aggregator-web/src/hooks/useAgents.ts index f87c6f8..24a4621 100644 --- a/aggregator-web/src/hooks/useAgents.ts +++ b/aggregator-web/src/hooks/useAgents.ts @@ -1,4 +1,4 @@ -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { agentApi } from '@/lib/api'; import type { Agent, ListQueryParams, AgentListResponse, ScanRequest } from '@/types'; import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query'; @@ -27,19 +27,43 @@ export const useAgent = (id: string, enabled: boolean = true): UseQueryResult => { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: agentApi.scanAgent, + onSuccess: () => { + // Invalidate all agents queries to trigger refetch + queryClient.invalidateQueries({ queryKey: ['agents'] }); + // Also invalidate specific agent queries + queryClient.invalidateQueries({ queryKey: ['agent'] }); + }, }); }; export const useScanMultipleAgents = (): UseMutationResult => { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: agentApi.triggerScan, + onSuccess: () => { + // Invalidate all agents queries to trigger refetch + queryClient.invalidateQueries({ queryKey: ['agents'] }); + // Also invalidate specific agent queries + queryClient.invalidateQueries({ queryKey: ['agent'] }); + }, }); }; export const useUnregisterAgent = (): UseMutationResult => { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: agentApi.unregisterAgent, + onSuccess: () => { + // Invalidate all agents queries to trigger immediate refetch + queryClient.invalidateQueries({ queryKey: ['agents'] }); + // Also invalidate specific agent queries + queryClient.invalidateQueries({ queryKey: ['agent'] }); + }, }); }; \ No newline at end of file diff --git a/aggregator-web/src/pages/Agents.tsx b/aggregator-web/src/pages/Agents.tsx index bd73e18..899ccce 100644 --- a/aggregator-web/src/pages/Agents.tsx +++ b/aggregator-web/src/pages/Agents.tsx @@ -22,6 +22,9 @@ import { AlertCircle, XCircle, Power, + Database, + Settings, + MonitorPlay, } from 'lucide-react'; import { useAgents, useAgent, useScanAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents'; import { useActiveCommands, useCancelCommand } from '@/hooks/useCommands'; @@ -32,6 +35,9 @@ import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib import { cn } from '@/lib/utils'; 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 ChatTimeline from '@/components/ChatTimeline'; const Agents: React.FC = () => { @@ -44,7 +50,7 @@ const Agents: React.FC = () => { const [osFilter, setOsFilter] = useState('all'); const [showFilters, setShowFilters] = useState(false); const [selectedAgents, setSelectedAgents] = useState([]); - const [activeTab, setActiveTab] = useState<'overview' | 'history'>('overview'); + const [activeTab, setActiveTab] = useState<'overview' | 'storage' | 'updates' | 'scanners' | 'history'>('overview'); const [currentTime, setCurrentTime] = useState(new Date()); const [heartbeatDuration, setHeartbeatDuration] = useState(10); // Default 10 minutes const [showDurationDropdown, setShowDurationDropdown] = useState(false); @@ -522,28 +528,64 @@ const Agents: React.FC = () => {
)} - {/* Tabs */} + {/* Enhanced Tabs */}
-
-
- - {/* Compact Timeline Display */} -
- {(() => { - const agentCommands = getAgentActiveCommands(); - - // Separate heartbeat commands from other commands - const heartbeatCommands = agentCommands.filter(cmd => - cmd.command_type === 'enable_heartbeat' || cmd.command_type === 'disable_heartbeat' - ); - const otherCommands = agentCommands.filter(cmd => - cmd.command_type !== 'enable_heartbeat' && cmd.command_type !== 'disable_heartbeat' - ); - - // For heartbeat commands: only show the MOST RECENT one, but exclude old completed ones - const recentHeartbeatCommands = heartbeatCommands.filter(cmd => { - const createdTime = new Date(cmd.created_at); - const now = new Date(); - const hoursOld = (now.getTime() - createdTime.getTime()) / (1000 * 60 * 60); - - // Exclude completed/failed heartbeat commands older than 30 minutes - if ((cmd.status === 'completed' || cmd.status === 'failed' || cmd.status === 'timed_out') && hoursOld > 0.5) { - return false; - } - return true; - }); - - const latestHeartbeatCommand = recentHeartbeatCommands.length > 0 - ? [recentHeartbeatCommands.reduce((latest, cmd) => - new Date(cmd.created_at) > new Date(latest.created_at) ? cmd : latest - )] - : []; - - // For other commands: show active ones normally - const activeOtherCommands = otherCommands.filter(cmd => - cmd.status === 'running' || cmd.status === 'sent' || cmd.status === 'pending' - ); - const completedOtherCommands = otherCommands.filter(cmd => - cmd.status === 'completed' || cmd.status === 'failed' || cmd.status === 'timed_out' - ).slice(0, 1); // Only show last completed - - const displayCommands = [ - ...latestHeartbeatCommand.slice(0, 1), // Max 1 heartbeat (latest only) - ...activeOtherCommands.slice(0, 2), // Max 2 active other commands - ...completedOtherCommands.slice(0, 1) // Max 1 completed other command - ].slice(0, 3); // Total max 3 entries - - if (displayCommands.length === 0) { - return ( -
- No active operations -
- ); - } - - return displayCommands.map((command, index) => { - const displayInfo = getCommandDisplayInfo(command); - const statusInfo = getCommandStatus(command); - const isActive = command.status === 'running' || command.status === 'sent' || command.status === 'pending'; - - return ( -
-
- {displayInfo.icon} -
-
-
- - {isActive ? ( - - - {command.status === 'running' && } - {command.status === 'pending' && } - {isActive ? command.status.replace('_', ' ') : statusInfo.text} - - {displayInfo.label} - - ) : ( - - - {command.status === 'completed' && } - {command.status === 'failed' && } - {statusInfo.text} - - {displayInfo.label} - - )} - -
-
- - {(() => { - const createdTime = new Date(command.created_at); - const now = new Date(); - const hoursOld = (now.getTime() - createdTime.getTime()) / (1000 * 60 * 60); - - // Show exact time for commands older than 1 hour, relative time for recent ones - if (hoursOld > 1) { - return createdTime.toLocaleString(); - } else { - return formatRelativeTime(command.created_at); - } - })()} - - {isActive && (command.status === 'pending' || command.status === 'sent') && ( - - )} -
-
-
- ); - }); - })()} -
- - {/* Basic Status Info */} -
- Last seen: {formatRelativeTime(selectedAgent.last_seen)} - Last scan: {selectedAgent.last_scan ? formatRelativeTime(selectedAgent.last_scan) : 'Never'} -
- - {/* Heartbeat Status Info */} - {heartbeatStatus?.enabled && heartbeatStatus?.active && ( -
- Heartbeat active for {formatHeartExpiration(heartbeatStatus.until)} -
- )} - - {/* Action Button */} -
- -
-
- - {/* System info */} -
-

System Information

- -
- {/* Basic System Info */} -
-
-

Platform

-

+ {/* Heartbeat Status Indicator */} +

{(() => { - const osInfo = parseOSInfo(selectedAgent); - return osInfo.platform; + // Use dedicated heartbeat status instead of general agent metadata + const isRapidPolling = heartbeatStatus?.enabled && heartbeatStatus?.active; + + // Get source from heartbeat status (stored in agent metadata) + const heartbeatSource = heartbeatStatus?.source; + + // Debug: Log the source field + console.log('[Heartbeat Debug]', { + isRapidPolling, + source: heartbeatSource, + sourceType: typeof heartbeatSource, + heartbeatStatus + }); + + // Check if heartbeat is system-initiated (blue) or manual (pink) + const isSystemHeartbeat = heartbeatSource === 'system'; + const isManualHeartbeat = heartbeatSource === 'manual'; + + return ( + + ); })()} -

+
-
-

Distribution

-

- {(() => { - const osInfo = parseOSInfo(selectedAgent); - return osInfo.distribution; - })()} -

+ {/* Compact Timeline Display */} +
{(() => { - const osInfo = parseOSInfo(selectedAgent); - if (osInfo.version) { + const agentCommands = getAgentActiveCommands(); + + // Separate heartbeat commands from other commands + const heartbeatCommands = agentCommands.filter(cmd => + cmd.command_type === 'enable_heartbeat' || cmd.command_type === 'disable_heartbeat' + ); + const otherCommands = agentCommands.filter(cmd => + cmd.command_type !== 'enable_heartbeat' && cmd.command_type !== 'disable_heartbeat' + ); + + // For heartbeat commands: only show the MOST RECENT one, but exclude old completed ones + const recentHeartbeatCommands = heartbeatCommands.filter(cmd => { + const createdTime = new Date(cmd.created_at); + const now = new Date(); + const hoursOld = (now.getTime() - createdTime.getTime()) / (1000 * 60 * 60); + + // Exclude completed/failed heartbeat commands older than 30 minutes + if ((cmd.status === 'completed' || cmd.status === 'failed' || cmd.status === 'timed_out') && hoursOld > 0.5) { + return false; + } + return true; + }); + + const latestHeartbeatCommand = recentHeartbeatCommands.length > 0 + ? [recentHeartbeatCommands.reduce((latest, cmd) => + new Date(cmd.created_at) > new Date(latest.created_at) ? cmd : latest + )] + : []; + + // For other commands: show active ones normally + const activeOtherCommands = otherCommands.filter(cmd => + cmd.status === 'running' || cmd.status === 'sent' || cmd.status === 'pending' + ); + const completedOtherCommands = otherCommands.filter(cmd => + cmd.status === 'completed' || cmd.status === 'failed' || cmd.status === 'timed_out' + ).slice(0, 1); // Only show last completed + + const displayCommands = [ + ...latestHeartbeatCommand.slice(0, 1), // Max 1 heartbeat (latest only) + ...activeOtherCommands.slice(0, 2), // Max 2 active other commands + ...completedOtherCommands.slice(0, 1) // Max 1 completed other command + ].slice(0, 3); // Total max 3 entries + + if (displayCommands.length === 0) { return ( -

- Version: {osInfo.version} -

+
+ No active operations +
); } - return null; + + return displayCommands.map((command, index) => { + const displayInfo = getCommandDisplayInfo(command); + const statusInfo = getCommandStatus(command); + const isActive = command.status === 'running' || command.status === 'sent' || command.status === 'pending'; + + return ( +
+
+ {displayInfo.icon} +
+
+
+ + {isActive ? ( + + + {command.status === 'running' && } + {command.status === 'pending' && } + {isActive ? command.status.replace('_', ' ') : statusInfo.text} + + {displayInfo.label} + + ) : ( + + + {command.status === 'completed' && } + {command.status === 'failed' && } + {statusInfo.text} + + {displayInfo.label} + + )} + +
+
+ + {(() => { + const createdTime = new Date(command.created_at); + const now = new Date(); + const hoursOld = (now.getTime() - createdTime.getTime()) / (1000 * 60 * 60); + + // Show exact time for commands older than 1 hour, relative time for recent ones + if (hoursOld > 1) { + return createdTime.toLocaleString(); + } else { + return formatRelativeTime(command.created_at); + } + })()} + + {isActive && (command.status === 'pending' || command.status === 'sent') && ( + + )} +
+
+
+ ); + }); })()}
-
-

Architecture

-

- {selectedAgent.os_architecture || selectedAgent.architecture} -

+ {/* Basic Status Info */} +
+ Last seen: {formatRelativeTime(selectedAgent.last_seen)} + Last scan: {selectedAgent.last_scan ? formatRelativeTime(selectedAgent.last_scan) : 'Never'} +
+ + {/* Heartbeat Status Info */} + {heartbeatStatus?.enabled && heartbeatStatus?.active && ( + (() => { + // Get source from heartbeat status (stored in agent metadata) + const heartbeatSource = heartbeatStatus?.source; + const isSystemHeartbeat = heartbeatSource === 'system'; + + return ( +
+ {isSystemHeartbeat ? 'System ' : 'Manual '}heartbeat active for {formatHeartExpiration(heartbeatStatus.until)} +
+ ); + })() + )} + + {/* Action Button */} +
+
- {/* Hardware Specs */} -
- {(() => { - const meta = getSystemMetadata(selectedAgent); - return ( - <> -
-

- - CPU -

-

- {meta.cpuModel} -

-

- {meta.cpuCores} cores -

-
+ {/* System info */} +
+

System Information

- {meta.memoryTotal > 0 && ( -
-

- - Memory -

-

- {formatBytes(meta.memoryTotal)} -

-
- )} +
+ {/* Basic System Info */} +
+
+

Platform

+

+ {(() => { + const osInfo = parseOSInfo(selectedAgent); + return osInfo.platform; + })()} +

+
- {meta.diskTotal > 0 && ( -
-

- - Disk ({meta.diskMount}) -

-

- {formatBytes(meta.diskUsed)} / {formatBytes(meta.diskTotal)} -

-
-
+
+

Distribution

+

+ {(() => { + const osInfo = parseOSInfo(selectedAgent); + return osInfo.distribution; + })()} +

+ {(() => { + const osInfo = parseOSInfo(selectedAgent); + if (osInfo.version) { + return ( +

+ Version: {osInfo.version} +

+ ); + } + return null; + })()} +
+ +
+

Architecture

+

+ {selectedAgent.os_architecture || selectedAgent.architecture} +

+
+
+ + {/* Hardware Specs */} +
+ {(() => { + const meta = getSystemMetadata(selectedAgent); + return ( + <> +
+

+ + CPU +

+

+ {meta.cpuModel} +

+

+ {meta.cpuCores} cores +

-

- {Math.round((meta.diskUsed / meta.diskTotal) * 100)}% used -

-
- )} - {meta.processes !== 'Unknown' && ( -
-

- - Running Processes -

-

- {meta.processes} -

-
- )} + {meta.memoryTotal > 0 && ( +
+

+ + Memory +

+

+ {formatBytes(meta.memoryTotal)} +

+
+ )} - {meta.uptime !== 'Unknown' && ( -
-

- - Uptime -

-

- {meta.uptime} -

-
- )} - - ); - })()} + {meta.diskTotal > 0 && ( +
+

+ + Disk ({meta.diskMount}) +

+

+ {formatBytes(meta.diskUsed)} / {formatBytes(meta.diskTotal)} +

+
+
+
+

+ {Math.round((meta.diskUsed / meta.diskTotal) * 100)}% used +

+
+ )} + + {meta.processes !== 'Unknown' && ( +
+

+ + Running Processes +

+

+ {meta.processes} +

+
+ )} + + {meta.uptime !== 'Unknown' && ( +
+

+ + Uptime +

+

+ {meta.uptime} +

+
+ )} + + ); + })()} +
+
+ )} -
+ {activeTab === 'storage' && ( + + )} - {/* System Updates */} - + {activeTab === 'updates' && ( + + )} -
- ) : ( -
- -
+ {activeTab === 'scanners' && ( + + )} + + {activeTab === 'history' && ( + )}
diff --git a/aggregator-web/src/types/index.ts b/aggregator-web/src/types/index.ts index c1984c3..ff325c9 100644 --- a/aggregator-web/src/types/index.ts +++ b/aggregator-web/src/types/index.ts @@ -176,9 +176,10 @@ export interface AptUpdateInfo { export interface Command { id: string; agent_id: string; - command_type: 'scan' | 'install' | 'update' | 'reboot'; + command_type: 'scan' | 'install' | 'update' | 'reboot' | 'enable_heartbeat' | 'disable_heartbeat'; payload: Record; - status: 'pending' | 'running' | 'completed' | 'failed'; + status: 'pending' | 'running' | 'completed' | 'failed' | 'sent' | 'timed_out' | 'cancelled'; + source: 'manual' | 'system'; // manual = user-initiated, system = auto-triggered created_at: string; updated_at: string; executed_at: string | null;