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
This commit is contained in:
@@ -23,7 +23,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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
|
// getConfigPath returns the platform-specific config path
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package system
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -41,7 +42,7 @@ type MemoryInfo struct {
|
|||||||
UsedPercent float64 `json:"used_percent"`
|
UsedPercent float64 `json:"used_percent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiskInfo contains disk information
|
// DiskInfo contains disk information for modular storage management
|
||||||
type DiskInfo struct {
|
type DiskInfo struct {
|
||||||
Mountpoint string `json:"mountpoint"`
|
Mountpoint string `json:"mountpoint"`
|
||||||
Total uint64 `json:"total"`
|
Total uint64 `json:"total"`
|
||||||
@@ -49,6 +50,10 @@ type DiskInfo struct {
|
|||||||
Used uint64 `json:"used"`
|
Used uint64 `json:"used"`
|
||||||
UsedPercent float64 `json:"used_percent"`
|
UsedPercent float64 `json:"used_percent"`
|
||||||
Filesystem string `json:"filesystem"`
|
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
|
// GetSystemInfo collects detailed system information
|
||||||
@@ -252,7 +257,7 @@ func getMemoryInfo() (*MemoryInfo, error) {
|
|||||||
return mem, nil
|
return mem, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDiskInfo gets disk information for mounted filesystems
|
// getDiskInfo gets disk information for mounted filesystems with enhanced detection
|
||||||
func getDiskInfo() ([]DiskInfo, error) {
|
func getDiskInfo() ([]DiskInfo, error) {
|
||||||
var disks []DiskInfo
|
var disks []DiskInfo
|
||||||
|
|
||||||
@@ -262,6 +267,9 @@ func getDiskInfo() ([]DiskInfo, error) {
|
|||||||
if cmd, err := exec.LookPath("df"); err == nil {
|
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 {
|
if data, err := exec.Command(cmd, "-h", "--output=target,size,used,avail,pcent,source").Output(); err == nil {
|
||||||
lines := strings.Split(string(data), "\n")
|
lines := strings.Split(string(data), "\n")
|
||||||
|
|
||||||
|
// First pass: collect all valid disks
|
||||||
|
var rawDisks []DiskInfo
|
||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
if i == 0 || strings.TrimSpace(line) == "" {
|
if i == 0 || strings.TrimSpace(line) == "" {
|
||||||
continue // Skip header and empty lines
|
continue // Skip header and empty lines
|
||||||
@@ -305,6 +313,7 @@ func getDiskInfo() ([]DiskInfo, error) {
|
|||||||
disk := DiskInfo{
|
disk := DiskInfo{
|
||||||
Mountpoint: mountpoint,
|
Mountpoint: mountpoint,
|
||||||
Filesystem: filesystem,
|
Filesystem: filesystem,
|
||||||
|
Device: filesystem,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse sizes (df outputs in human readable format, we'll parse the numeric part)
|
// 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
|
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
|
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) {
|
func parseSize(sizeStr string) (uint64, error) {
|
||||||
sizeStr = strings.TrimSpace(sizeStr)
|
sizeStr = strings.TrimSpace(sizeStr)
|
||||||
if len(sizeStr) == 0 {
|
if len(sizeStr) == 0 {
|
||||||
@@ -340,14 +424,17 @@ func parseSize(sizeStr string) (uint64, error) {
|
|||||||
|
|
||||||
multiplier := uint64(1)
|
multiplier := uint64(1)
|
||||||
unit := sizeStr[len(sizeStr)-1:]
|
unit := sizeStr[len(sizeStr)-1:]
|
||||||
if unit == "G" || unit == "g" {
|
if unit == "T" || unit == "t" {
|
||||||
multiplier = 1024 * 1024 * 1024
|
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]
|
sizeStr = sizeStr[:len(sizeStr)-1]
|
||||||
} else if unit == "M" || unit == "m" {
|
} else if unit == "M" || unit == "m" {
|
||||||
multiplier = 1024 * 1024
|
multiplier = 1024 * 1024 // Megabyte
|
||||||
sizeStr = sizeStr[:len(sizeStr)-1]
|
sizeStr = sizeStr[:len(sizeStr)-1]
|
||||||
} else if unit == "K" || unit == "k" {
|
} else if unit == "K" || unit == "k" {
|
||||||
multiplier = 1024
|
multiplier = 1024 // Kilobyte
|
||||||
sizeStr = sizeStr[:len(sizeStr)-1]
|
sizeStr = sizeStr[:len(sizeStr)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,9 +520,15 @@ type LightweightMetrics struct {
|
|||||||
MemoryPercent float64
|
MemoryPercent float64
|
||||||
MemoryUsedGB float64
|
MemoryUsedGB float64
|
||||||
MemoryTotalGB float64
|
MemoryTotalGB float64
|
||||||
|
// Root filesystem disk info (primary disk)
|
||||||
DiskUsedGB float64
|
DiskUsedGB float64
|
||||||
DiskTotalGB float64
|
DiskTotalGB float64
|
||||||
DiskPercent float64
|
DiskPercent float64
|
||||||
|
// Largest disk info (for systems with separate data partitions)
|
||||||
|
LargestDiskUsedGB float64
|
||||||
|
LargestDiskTotalGB float64
|
||||||
|
LargestDiskPercent float64
|
||||||
|
LargestDiskMount string
|
||||||
Uptime string
|
Uptime string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,16 +544,36 @@ func GetLightweightMetrics() (*LightweightMetrics, error) {
|
|||||||
metrics.MemoryTotalGB = float64(mem.Total) / (1024 * 1024 * 1024)
|
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 {
|
if disks, err := getDiskInfo(); err == nil {
|
||||||
for _, disk := range disks {
|
var rootDisk *DiskInfo
|
||||||
// Look for root filesystem or first mountpoint
|
var largestDisk *DiskInfo
|
||||||
if disk.Mountpoint == "/" || disk.Mountpoint == "C:" || len(metrics.Uptime) == 0 {
|
|
||||||
metrics.DiskUsedGB = float64(disk.Used) / (1024 * 1024 * 1024)
|
for i, disk := range disks {
|
||||||
metrics.DiskTotalGB = float64(disk.Total) / (1024 * 1024 * 1024)
|
// Find root filesystem
|
||||||
metrics.DiskPercent = disk.UsedPercent
|
if disk.Mountpoint == "/" || disk.Mountpoint == "C:" {
|
||||||
break
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
|
|||||||
CommandType: models.CommandTypeDisableHeartbeat,
|
CommandType: models.CommandTypeDisableHeartbeat,
|
||||||
Params: models.JSONB{},
|
Params: models.JSONB{},
|
||||||
Status: models.CommandStatusCompleted,
|
Status: models.CommandStatusCompleted,
|
||||||
|
Source: models.CommandSourceSystem,
|
||||||
Result: models.JSONB{
|
Result: models.JSONB{
|
||||||
"message": "Heartbeat cleared - agent restarted without active heartbeat mode",
|
"message": "Heartbeat cleared - agent restarted without active heartbeat mode",
|
||||||
},
|
},
|
||||||
@@ -492,6 +493,13 @@ func (h *AgentHandler) TriggerScan(c *gin.Context) {
|
|||||||
return
|
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
|
// Create scan command
|
||||||
cmd := &models.AgentCommand{
|
cmd := &models.AgentCommand{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
@@ -499,6 +507,7 @@ func (h *AgentHandler) TriggerScan(c *gin.Context) {
|
|||||||
CommandType: models.CommandTypeScanUpdates,
|
CommandType: models.CommandTypeScanUpdates,
|
||||||
Params: models.JSONB{},
|
Params: models.JSONB{},
|
||||||
Status: models.CommandStatusPending,
|
Status: models.CommandStatusPending,
|
||||||
|
Source: models.CommandSourceManual,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.commandQueries.CreateCommand(cmd); err != nil {
|
if err := h.commandQueries.CreateCommand(cmd); err != nil {
|
||||||
@@ -534,7 +543,7 @@ func (h *AgentHandler) TriggerHeartbeat(c *gin.Context) {
|
|||||||
commandType = models.CommandTypeEnableHeartbeat
|
commandType = models.CommandTypeEnableHeartbeat
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create heartbeat command with duration parameter
|
// Create heartbeat command with duration parameter (manual = user-initiated)
|
||||||
cmd := &models.AgentCommand{
|
cmd := &models.AgentCommand{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
AgentID: agentID,
|
AgentID: agentID,
|
||||||
@@ -543,6 +552,7 @@ func (h *AgentHandler) TriggerHeartbeat(c *gin.Context) {
|
|||||||
"duration_minutes": request.DurationMinutes,
|
"duration_minutes": request.DurationMinutes,
|
||||||
},
|
},
|
||||||
Status: models.CommandStatusPending,
|
Status: models.CommandStatusPending,
|
||||||
|
Source: models.CommandSourceManual,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.commandQueries.CreateCommand(cmd); err != nil {
|
if err := h.commandQueries.CreateCommand(cmd); err != nil {
|
||||||
@@ -550,23 +560,26 @@ func (h *AgentHandler) TriggerHeartbeat(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Clean up previous heartbeat commands for this agent (only for enable commands)
|
// Store heartbeat source in agent metadata immediately
|
||||||
// if request.Enabled {
|
if request.Enabled {
|
||||||
// // Mark previous heartbeat commands as 'replaced' to clean up Live Operations view
|
agent, err := h.agentQueries.GetAgentByID(agentID)
|
||||||
// if err := h.commandQueries.MarkPreviousHeartbeatCommandsReplaced(agentID, cmd.ID); err != nil {
|
if err == nil {
|
||||||
// log.Printf("Warning: Failed to mark previous heartbeat commands as replaced: %v", err)
|
if agent.Metadata == nil {
|
||||||
// // Don't fail the request, just log the warning
|
agent.Metadata = models.JSONB{}
|
||||||
// } else {
|
}
|
||||||
// log.Printf("[Heartbeat] Cleaned up previous heartbeat commands for agent %s", agentID)
|
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"
|
action := "disabled"
|
||||||
if request.Enabled {
|
if request.Enabled {
|
||||||
action = "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)
|
action, agentID, request.DurationMinutes)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
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
|
// GetHeartbeatStatus returns the current heartbeat status for an agent
|
||||||
func (h *AgentHandler) GetHeartbeatStatus(c *gin.Context) {
|
func (h *AgentHandler) GetHeartbeatStatus(c *gin.Context) {
|
||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
@@ -598,6 +665,7 @@ func (h *AgentHandler) GetHeartbeatStatus(c *gin.Context) {
|
|||||||
"until": nil,
|
"until": nil,
|
||||||
"active": false,
|
"active": false,
|
||||||
"duration_minutes": 0,
|
"duration_minutes": 0,
|
||||||
|
"source": nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
if agent.Metadata != 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 {
|
if duration, exists := agent.Metadata["rapid_polling_duration_minutes"]; exists {
|
||||||
response["duration_minutes"] = duration.(float64)
|
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,
|
CommandType: models.CommandTypeInstallUpdate,
|
||||||
Params: params,
|
Params: params,
|
||||||
Status: models.CommandStatusPending,
|
Status: models.CommandStatusPending,
|
||||||
|
Source: models.CommandSourceManual,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.commandQueries.CreateCommand(cmd); err != nil {
|
if err := h.commandQueries.CreateCommand(cmd); err != nil {
|
||||||
@@ -1008,6 +1082,7 @@ func (h *AgentHandler) TriggerReboot(c *gin.Context) {
|
|||||||
"message": req.Message,
|
"message": req.Message,
|
||||||
},
|
},
|
||||||
Status: models.CommandStatusPending,
|
Status: models.CommandStatusPending,
|
||||||
|
Source: models.CommandSourceManual,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ func (h *DockerHandler) InstallUpdate(c *gin.Context) {
|
|||||||
"container_id": containerID,
|
"container_id": containerID,
|
||||||
},
|
},
|
||||||
Status: models.CommandStatusPending,
|
Status: models.CommandStatusPending,
|
||||||
|
Source: models.CommandSourceManual, // User-initiated Docker update
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.commandQueries.CreateCommand(command); err != nil {
|
if err := h.commandQueries.CreateCommand(command); err != nil {
|
||||||
|
|||||||
@@ -443,6 +443,7 @@ func (h *UpdateHandler) InstallUpdate(c *gin.Context) {
|
|||||||
"package_type": update.PackageType,
|
"package_type": update.PackageType,
|
||||||
},
|
},
|
||||||
Status: models.CommandStatusPending,
|
Status: models.CommandStatusPending,
|
||||||
|
Source: models.CommandSourceManual,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,6 +457,7 @@ func (h *UpdateHandler) InstallUpdate(c *gin.Context) {
|
|||||||
"duration_minutes": 10,
|
"duration_minutes": 10,
|
||||||
},
|
},
|
||||||
Status: models.CommandStatusPending,
|
Status: models.CommandStatusPending,
|
||||||
|
Source: models.CommandSourceSystem,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,6 +550,7 @@ func (h *UpdateHandler) ReportDependencies(c *gin.Context) {
|
|||||||
"dependencies": []string{}, // Empty dependencies array
|
"dependencies": []string{}, // Empty dependencies array
|
||||||
},
|
},
|
||||||
Status: models.CommandStatusPending,
|
Status: models.CommandStatusPending,
|
||||||
|
Source: models.CommandSourceManual,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,6 +564,7 @@ func (h *UpdateHandler) ReportDependencies(c *gin.Context) {
|
|||||||
"duration_minutes": 10,
|
"duration_minutes": 10,
|
||||||
},
|
},
|
||||||
Status: models.CommandStatusPending,
|
Status: models.CommandStatusPending,
|
||||||
|
Source: models.CommandSourceSystem,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,6 +632,7 @@ func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
|
|||||||
"dependencies": update.Metadata["dependencies"], // Dependencies stored in metadata
|
"dependencies": update.Metadata["dependencies"], // Dependencies stored in metadata
|
||||||
},
|
},
|
||||||
Status: models.CommandStatusPending,
|
Status: models.CommandStatusPending,
|
||||||
|
Source: models.CommandSourceManual,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,6 +646,7 @@ func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
|
|||||||
"duration_minutes": 10,
|
"duration_minutes": 10,
|
||||||
},
|
},
|
||||||
Status: models.CommandStatusPending,
|
Status: models.CommandStatusPending,
|
||||||
|
Source: models.CommandSourceSystem,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func Load() (*Config, error) {
|
|||||||
cfg.CheckInInterval = checkInInterval
|
cfg.CheckInInterval = checkInInterval
|
||||||
cfg.OfflineThreshold = offlineThreshold
|
cfg.OfflineThreshold = offlineThreshold
|
||||||
cfg.Timezone = getEnv("TIMEZONE", "UTC")
|
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
|
// Handle missing secrets
|
||||||
if cfg.Admin.Password == "" || cfg.Admin.JWTSecret == "" || cfg.Database.Password == "" {
|
if cfg.Admin.Password == "" || cfg.Admin.JWTSecret == "" || cfg.Database.Password == "" {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
-- This enables the hybrid version tracking system
|
-- This enables the hybrid version tracking system
|
||||||
|
|
||||||
ALTER TABLE agents
|
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 update_available BOOLEAN DEFAULT FALSE,
|
||||||
ADD COLUMN last_version_check TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
ADD COLUMN last_version_check TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
|||||||
@@ -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)';
|
||||||
@@ -21,9 +21,9 @@ func NewCommandQueries(db *sqlx.DB) *CommandQueries {
|
|||||||
func (q *CommandQueries) CreateCommand(cmd *models.AgentCommand) error {
|
func (q *CommandQueries) CreateCommand(cmd *models.AgentCommand) error {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO agent_commands (
|
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 (
|
) 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)
|
_, err := q.db.NamedExec(query, cmd)
|
||||||
@@ -183,6 +183,7 @@ func (q *CommandQueries) GetActiveCommands() ([]models.ActiveCommandInfo, error)
|
|||||||
c.command_type,
|
c.command_type,
|
||||||
c.params,
|
c.params,
|
||||||
c.status,
|
c.status,
|
||||||
|
c.source,
|
||||||
c.created_at,
|
c.created_at,
|
||||||
c.sent_at,
|
c.sent_at,
|
||||||
c.result,
|
c.result,
|
||||||
@@ -244,6 +245,7 @@ func (q *CommandQueries) GetRecentCommands(limit int) ([]models.ActiveCommandInf
|
|||||||
c.agent_id,
|
c.agent_id,
|
||||||
c.command_type,
|
c.command_type,
|
||||||
c.status,
|
c.status,
|
||||||
|
c.source,
|
||||||
c.created_at,
|
c.created_at,
|
||||||
c.sent_at,
|
c.sent_at,
|
||||||
c.completed_at,
|
c.completed_at,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type AgentCommand struct {
|
|||||||
CommandType string `json:"command_type" db:"command_type"`
|
CommandType string `json:"command_type" db:"command_type"`
|
||||||
Params JSONB `json:"params" db:"params"`
|
Params JSONB `json:"params" db:"params"`
|
||||||
Status string `json:"status" db:"status"`
|
Status string `json:"status" db:"status"`
|
||||||
|
Source string `json:"source" db:"source"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
|
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
|
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
|
||||||
@@ -64,6 +65,12 @@ const (
|
|||||||
CommandStatusRunning = "running"
|
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
|
// ActiveCommandInfo represents information about an active command for UI display
|
||||||
type ActiveCommandInfo struct {
|
type ActiveCommandInfo struct {
|
||||||
ID uuid.UUID `json:"id" db:"id"`
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
@@ -71,6 +78,7 @@ type ActiveCommandInfo struct {
|
|||||||
CommandType string `json:"command_type" db:"command_type"`
|
CommandType string `json:"command_type" db:"command_type"`
|
||||||
Params JSONB `json:"params" db:"params"`
|
Params JSONB `json:"params" db:"params"`
|
||||||
Status string `json:"status" db:"status"`
|
Status string `json:"status" db:"status"`
|
||||||
|
Source string `json:"source" db:"source"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
|
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
|
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
|
||||||
|
|||||||
306
aggregator-web/src/components/AgentScanners.tsx
Normal file
306
aggregator-web/src/components/AgentScanners.tsx
Normal file
@@ -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<ScannerConfig[]>([
|
||||||
|
{
|
||||||
|
id: 'disk-reporter',
|
||||||
|
name: 'Disk Usage Reporter',
|
||||||
|
description: 'Agent reports disk usage metrics to server',
|
||||||
|
icon: <Database className="h-4 w-4" />,
|
||||||
|
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: <Search className="h-4 w-4" />,
|
||||||
|
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: <Shield className="h-4 w-4" />,
|
||||||
|
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: <Activity className="h-4 w-4" />,
|
||||||
|
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 <RefreshCw className="h-3 w-3 animate-spin text-blue-500" />;
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle className="h-3 w-3 text-green-500" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle className="h-3 w-3 text-red-500" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="h-3 w-3 text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Compact Summary */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center space-x-6">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Active:</span>
|
||||||
|
<span className="ml-2 font-medium text-green-600">{enabledCount}/{scanners.length}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Running:</span>
|
||||||
|
<span className="ml-2 font-medium text-blue-600">{runningCount}</span>
|
||||||
|
</div>
|
||||||
|
{failedCount > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Failed:</span>
|
||||||
|
<span className="ml-2 font-medium text-red-600">{failedCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent Health Monitoring Table */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">Agent Check-in Configuration</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left py-2 pr-4 font-medium text-gray-700">Check Type</th>
|
||||||
|
<th className="text-left py-2 pr-4 font-medium text-gray-700">Category</th>
|
||||||
|
<th className="text-center py-2 pr-4 font-medium text-gray-700">Status</th>
|
||||||
|
<th className="text-center py-2 pr-4 font-medium text-gray-700">Enabled</th>
|
||||||
|
<th className="text-right py-2 pr-4 font-medium text-gray-700">Check Interval</th>
|
||||||
|
<th className="text-right py-2 pr-4 font-medium text-gray-700">Last Check</th>
|
||||||
|
<th className="text-center py-2 font-medium text-gray-700">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{scanners.map((scanner) => (
|
||||||
|
<tr key={scanner.id} className="hover:bg-gray-50">
|
||||||
|
{/* Scanner Name */}
|
||||||
|
<td className="py-2 pr-4 text-gray-900">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-gray-600">{scanner.icon}</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{scanner.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{scanner.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<td className="py-2 pr-4 text-gray-600 capitalize text-xs">{scanner.category}</td>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<td className="py-2 pr-4 text-center">
|
||||||
|
<div className="flex items-center justify-center space-x-1">
|
||||||
|
{getStatusIcon(scanner.status)}
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs',
|
||||||
|
scanner.status === 'running' ? 'text-blue-600' :
|
||||||
|
scanner.status === 'completed' ? 'text-green-600' :
|
||||||
|
scanner.status === 'failed' ? 'text-red-600' : 'text-gray-500'
|
||||||
|
)}>
|
||||||
|
{scanner.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Enabled Toggle */}
|
||||||
|
<td className="py-2 pr-4 text-center">
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs px-2 py-1 rounded',
|
||||||
|
scanner.enabled
|
||||||
|
? 'text-green-700 bg-green-50'
|
||||||
|
: 'text-gray-600 bg-gray-50'
|
||||||
|
)}>
|
||||||
|
{scanner.enabled ? 'ON' : 'OFF'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Frequency */}
|
||||||
|
<td className="py-2 pr-4 text-right">
|
||||||
|
{scanner.enabled ? (
|
||||||
|
<span className="text-xs text-gray-600">{getFrequencyLabel(scanner.frequency)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Last Run */}
|
||||||
|
<td className="py-2 pr-4 text-right text-xs text-gray-600">
|
||||||
|
{scanner.last_run ? formatRelativeTime(scanner.last_run) : '-'}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<td className="py-2 text-center">
|
||||||
|
<span className="text-xs text-gray-400">Auto</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact note */}
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Agent check-ins report system state to the server on scheduled intervals. The agent initiates all communication - the server never "scans" your machine.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
aggregator-web/src/components/AgentStorage.tsx
Normal file
279
aggregator-web/src/components/AgentStorage.tsx
Normal file
@@ -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 <Database className="h-4 w-4 text-purple-500" />;
|
||||||
|
case 'ssd': return <Server className="h-4 w-4 text-blue-500" />;
|
||||||
|
case 'hdd': return <HardDrive className="h-4 w-4 text-gray-500" />;
|
||||||
|
default: return <Monitor className="h-4 w-4 text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!agentData) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="bg-white p-6 rounded-lg border border-gray-200">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-1/3 mb-3"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const disks = parseDiskInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Clean minimal header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-medium text-gray-900">System Resources</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleFullStorageScan}
|
||||||
|
disabled={isScanning}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-900 flex items-center space-x-1.5"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', isScanning && 'animate-spin')} />
|
||||||
|
<span>{isScanning ? 'Scanning...' : 'Refresh'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simple list - no boxes, just clean rows */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Memory */}
|
||||||
|
{storageMetrics && storageMetrics.memory_total_gb > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Memory</span>
|
||||||
|
<span className="text-gray-900 font-mono">
|
||||||
|
{storageMetrics.memory_used_gb.toFixed(1)} / {storageMetrics.memory_total_gb.toFixed(1)} GB
|
||||||
|
<span className="text-gray-500 ml-2">({storageMetrics.memory_percent.toFixed(0)}%)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gray-900 transition-all"
|
||||||
|
style={{ width: `${Math.min(storageMetrics.memory_percent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Root Disk */}
|
||||||
|
{storageMetrics && storageMetrics.disk_total_gb > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Root filesystem</span>
|
||||||
|
<span className="text-gray-900 font-mono">
|
||||||
|
{storageMetrics.disk_used_gb.toFixed(1)} / {storageMetrics.disk_total_gb.toFixed(1)} GB
|
||||||
|
<span className="text-gray-500 ml-2">({storageMetrics.disk_percent.toFixed(0)}%)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gray-900 transition-all"
|
||||||
|
style={{ width: `${Math.min(storageMetrics.disk_percent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Largest disk if different */}
|
||||||
|
{storageMetrics && storageMetrics.largest_disk_total_gb > 0 && storageMetrics.largest_disk_mount !== '/' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">{storageMetrics.largest_disk_mount}</span>
|
||||||
|
<span className="text-gray-900 font-mono">
|
||||||
|
{storageMetrics.largest_disk_used_gb.toFixed(1)} / {storageMetrics.largest_disk_total_gb.toFixed(1)} GB
|
||||||
|
<span className="text-gray-500 ml-2">({storageMetrics.largest_disk_percent.toFixed(0)}%)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gray-900 transition-all"
|
||||||
|
style={{ width: `${Math.min(storageMetrics.largest_disk_percent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All partitions - minimal table */}
|
||||||
|
{disks.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600">All partitions</h3>
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<table className="min-w-full text-sm divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500">Mount</th>
|
||||||
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500">Device</th>
|
||||||
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500">Type</th>
|
||||||
|
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500">Used</th>
|
||||||
|
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500">Total</th>
|
||||||
|
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500">Usage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 bg-white">
|
||||||
|
{disks.map((disk, index) => (
|
||||||
|
<tr key={index} className="hover:bg-gray-50 transition-colors">
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="font-mono">{disk.mountpoint}</span>
|
||||||
|
{disk.is_root && <span className="text-xs text-gray-500">root</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-gray-500 font-mono">{disk.device}</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-gray-500">{disk.disk_type}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right text-gray-900">{formatBytes(disk.used)}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right text-gray-500">{formatBytes(disk.total)}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
<span className="text-sm text-gray-900">{disk.used_percent.toFixed(0)}%</span>
|
||||||
|
<div className="w-16 h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gray-900"
|
||||||
|
style={{ width: `${Math.min(disk.used_percent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Last updated - minimal */}
|
||||||
|
{agentData && (
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
Last updated {agentData.last_seen ? formatRelativeTime(agentData.last_seen) : 'unknown'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
536
aggregator-web/src/components/AgentUpdatesEnhanced.tsx
Normal file
536
aggregator-web/src/components/AgentUpdatesEnhanced.tsx
Normal file
@@ -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<StatusTab>('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<LogResponse | null>(null);
|
||||||
|
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||||
|
const [expandedUpdates, setExpandedUpdates] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedUpdates, setSelectedUpdates] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch updates with status filter
|
||||||
|
const { data: updateData, isLoading, error, refetch } = useQuery<AgentUpdateResponse>({
|
||||||
|
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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="animate-pulse space-y-2">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="p-3 bg-white rounded border border-gray-100">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-1/3 mb-2"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-red-50 border border-red-200 rounded text-sm text-red-600">
|
||||||
|
Error loading updates: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex items-center space-x-1 border-b border-gray-200 text-sm">
|
||||||
|
{[
|
||||||
|
{ key: 'pending', label: 'Pending' },
|
||||||
|
{ key: 'approved', label: 'Approved' },
|
||||||
|
{ key: 'installing', label: 'Installing' },
|
||||||
|
{ key: 'installed', label: 'Installed' },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveStatus(tab.key as StatusTab)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 border-b-2 transition-colors',
|
||||||
|
activeStatus === tab.key
|
||||||
|
? 'border-gray-900 text-gray-900'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters and Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3 text-sm">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{totalCount} update{totalCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{['critical', 'high', 'medium', 'low'].map((severity) => {
|
||||||
|
const count = updates.filter(u => u.severity?.toLowerCase() === severity).length;
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<span key={severity} className="text-gray-500">
|
||||||
|
<span className={cn(
|
||||||
|
'font-medium',
|
||||||
|
severity === 'critical' ? 'text-red-600' :
|
||||||
|
severity === 'high' ? 'text-orange-600' :
|
||||||
|
severity === 'medium' ? 'text-yellow-600' : 'text-blue-600'
|
||||||
|
)}>{count}</span> {severity}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedUpdates.length > 0 && activeStatus === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={handleBulkApprove}
|
||||||
|
disabled={bulkApproveMutation.isPending}
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-900 flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
{bulkApproveMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
<span>Approving...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
<span>Approve {selectedUpdates.length}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="flex items-center space-x-3 text-sm">
|
||||||
|
<div className="flex-1 max-w-xs">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Search packages..."
|
||||||
|
className="pl-9 pr-3 py-1.5 w-full border border-gray-300 rounded text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedSeverity}
|
||||||
|
onChange={(e) => setSelectedSeverity(e.target.value)}
|
||||||
|
className="px-3 py-1.5 border border-gray-300 rounded text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All Severities</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Updates List */}
|
||||||
|
{updates.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-sm text-gray-500">
|
||||||
|
{activeStatus === 'installed' ? (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">Installed updates are shown in History</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.href = `/agents/${agentId}?tab=history`}
|
||||||
|
className="text-gray-600 hover:text-gray-900 underline"
|
||||||
|
>
|
||||||
|
View History
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
`No ${activeStatus} updates`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-px">
|
||||||
|
{updates.map((update) => {
|
||||||
|
const isExpanded = expandedUpdates.has(update.id);
|
||||||
|
return (
|
||||||
|
<div key={update.id} className="bg-white border-b border-gray-100 last:border-0">
|
||||||
|
<div className="flex items-center p-2 gap-3">
|
||||||
|
{/* Checkbox for pending */}
|
||||||
|
{activeStatus === 'pending' && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedUpdates.includes(update.id)}
|
||||||
|
onChange={(e) => handleSelectUpdate(update.id, e.target.checked)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div
|
||||||
|
className="flex-1 flex items-center justify-between gap-3 cursor-pointer"
|
||||||
|
onClick={() => toggleExpanded(update.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||||
|
<span className={cn('px-2 py-0.5 rounded text-xs font-medium', getSeverityColor(update.severity))}>
|
||||||
|
{update.severity.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-900 truncate">{update.package_name}</span>
|
||||||
|
<span className="text-xs text-gray-500">{update.current_version} → {update.available_version}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||||
|
{activeStatus === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleApprove(update.id); }}
|
||||||
|
className="text-xs text-gray-600 hover:text-gray-900 px-2 py-1"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{activeStatus === 'approved' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleInstall(update.id); }}
|
||||||
|
className="text-xs text-gray-600 hover:text-gray-900 px-2 py-1"
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{update.recent_command_id && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleViewLogs(update); }}
|
||||||
|
className="text-xs text-gray-600 hover:text-gray-900 px-2 py-1"
|
||||||
|
>
|
||||||
|
Logs
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Details */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-2 pb-3 ml-8">
|
||||||
|
<div className="bg-white/90 backdrop-blur-md rounded border border-gray-200 p-3 text-xs space-y-2">
|
||||||
|
{update.metadata?.description && (
|
||||||
|
<p className="text-gray-700">{update.metadata.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-gray-600">
|
||||||
|
<div><span className="font-medium">Type:</span> {update.package_type}</div>
|
||||||
|
<div><span className="font-medium">Severity:</span> {update.severity}</div>
|
||||||
|
{update.metadata?.size_bytes && (
|
||||||
|
<div><span className="font-medium">Size:</span> {formatBytes(update.metadata.size_bytes)}</div>
|
||||||
|
)}
|
||||||
|
{update.last_discovered_at && (
|
||||||
|
<div><span className="font-medium">Discovered:</span> {formatRelativeTime(update.last_discovered_at)}</div>
|
||||||
|
)}
|
||||||
|
{update.approved_at && (
|
||||||
|
<div><span className="font-medium">Approved:</span> {formatRelativeTime(update.approved_at)}</div>
|
||||||
|
)}
|
||||||
|
{update.installed_at && (
|
||||||
|
<div><span className="font-medium">Installed:</span> {formatRelativeTime(update.installed_at)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||||
|
<span>
|
||||||
|
{Math.min((currentPage - 1) * pageSize + 1, totalCount)} - {Math.min(currentPage * pageSize, totalCount)} of {totalCount}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span>Page {currentPage} of {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logs Modal */}
|
||||||
|
{showLogsModal && logsData && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 flex items-center space-x-2">
|
||||||
|
<Terminal className="h-4 w-4" />
|
||||||
|
<span>Installation Logs</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLogsModal(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 overflow-y-auto max-h-[60vh] space-y-3 text-xs">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">Result:</span>
|
||||||
|
<span className={cn(
|
||||||
|
'ml-2 px-2 py-0.5 rounded',
|
||||||
|
logsData.result === 'success' ? 'bg-green-100 text-green-800' :
|
||||||
|
logsData.result === 'failed' ? 'bg-red-100 text-red-800' :
|
||||||
|
'bg-gray-100 text-gray-800'
|
||||||
|
)}>
|
||||||
|
{logsData.result || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">Exit Code:</span>
|
||||||
|
<span className="ml-2">{logsData.exit_code}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">Duration:</span>
|
||||||
|
<span className="ml-2">{logsData.duration_seconds}s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logsData.stdout && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-900 mb-1">Standard Output</h4>
|
||||||
|
<pre className="bg-gray-50 border border-gray-200 rounded p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{logsData.stdout}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{logsData.stderr && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-900 mb-1">Standard Error</h4>
|
||||||
|
<pre className="bg-red-50 border border-red-200 rounded p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{logsData.stderr}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { agentApi } from '@/lib/api';
|
||||||
import type { Agent, ListQueryParams, AgentListResponse, ScanRequest } from '@/types';
|
import type { Agent, ListQueryParams, AgentListResponse, ScanRequest } from '@/types';
|
||||||
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
||||||
@@ -27,19 +27,43 @@ export const useAgent = (id: string, enabled: boolean = true): UseQueryResult<Ag
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useScanAgent = (): UseMutationResult<void, Error, string, unknown> => {
|
export const useScanAgent = (): UseMutationResult<void, Error, string, unknown> => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: agentApi.scanAgent,
|
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<void, Error, ScanRequest, unknown> => {
|
export const useScanMultipleAgents = (): UseMutationResult<void, Error, ScanRequest, unknown> => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: agentApi.triggerScan,
|
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<void, Error, string, unknown> => {
|
export const useUnregisterAgent = (): UseMutationResult<void, Error, string, unknown> => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: agentApi.unregisterAgent,
|
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'] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -22,6 +22,9 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
Power,
|
Power,
|
||||||
|
Database,
|
||||||
|
Settings,
|
||||||
|
MonitorPlay,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
||||||
import { useActiveCommands, useCancelCommand } from '@/hooks/useCommands';
|
import { useActiveCommands, useCancelCommand } from '@/hooks/useCommands';
|
||||||
@@ -32,6 +35,9 @@ import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { AgentSystemUpdates } from '@/components/AgentUpdates';
|
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';
|
import ChatTimeline from '@/components/ChatTimeline';
|
||||||
|
|
||||||
const Agents: React.FC = () => {
|
const Agents: React.FC = () => {
|
||||||
@@ -44,7 +50,7 @@ const Agents: React.FC = () => {
|
|||||||
const [osFilter, setOsFilter] = useState<string>('all');
|
const [osFilter, setOsFilter] = useState<string>('all');
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
|
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'history'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'storage' | 'updates' | 'scanners' | 'history'>('overview');
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [heartbeatDuration, setHeartbeatDuration] = useState<number>(10); // Default 10 minutes
|
const [heartbeatDuration, setHeartbeatDuration] = useState<number>(10); // Default 10 minutes
|
||||||
const [showDurationDropdown, setShowDurationDropdown] = useState(false);
|
const [showDurationDropdown, setShowDurationDropdown] = useState(false);
|
||||||
@@ -522,28 +528,64 @@ const Agents: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Enhanced Tabs */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<nav className="-mb-px flex space-x-8">
|
<nav className="-mb-px flex space-x-1 overflow-x-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('overview')}
|
onClick={() => setActiveTab('overview')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
'py-3 px-4 border-b-2 font-medium text-sm transition-colors whitespace-nowrap',
|
||||||
activeTab === 'overview'
|
activeTab === 'overview'
|
||||||
? 'border-primary-500 text-primary-600'
|
? 'border-primary-500 text-primary-600 bg-primary-50 rounded-t-lg'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 hover:bg-gray-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Overview
|
<span>Overview</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('storage')}
|
||||||
|
className={cn(
|
||||||
|
'py-3 px-4 border-b-2 font-medium text-sm transition-colors whitespace-nowrap flex items-center space-x-2',
|
||||||
|
activeTab === 'storage'
|
||||||
|
? 'border-primary-500 text-primary-600 bg-primary-50 rounded-t-lg'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HardDrive className="h-4 w-4" />
|
||||||
|
<span>Storage & Disks</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('updates')}
|
||||||
|
className={cn(
|
||||||
|
'py-3 px-4 border-b-2 font-medium text-sm transition-colors whitespace-nowrap flex items-center space-x-2',
|
||||||
|
activeTab === 'updates'
|
||||||
|
? 'border-primary-500 text-primary-600 bg-primary-50 rounded-t-lg'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Package className="h-4 w-4" />
|
||||||
|
<span>Updates & Packages</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('scanners')}
|
||||||
|
className={cn(
|
||||||
|
'py-3 px-4 border-b-2 font-medium text-sm transition-colors whitespace-nowrap flex items-center space-x-2',
|
||||||
|
activeTab === 'scanners'
|
||||||
|
? 'border-primary-500 text-primary-600 bg-primary-50 rounded-t-lg'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MonitorPlay className="h-4 w-4" />
|
||||||
|
<span>Agent Health</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('history')}
|
onClick={() => setActiveTab('history')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center space-x-2',
|
'py-3 px-4 border-b-2 font-medium text-sm transition-colors whitespace-nowrap flex items-center space-x-2',
|
||||||
activeTab === 'history'
|
activeTab === 'history'
|
||||||
? 'border-primary-500 text-primary-600'
|
? 'border-primary-500 text-primary-600 bg-primary-50 rounded-t-lg'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 hover:bg-gray-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HistoryIcon className="h-4 w-4" />
|
<HistoryIcon className="h-4 w-4" />
|
||||||
@@ -556,7 +598,7 @@ const Agents: React.FC = () => {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
{activeTab === 'overview' ? (
|
{activeTab === 'overview' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Agent Status Card - Compact Timeline Style */}
|
{/* Agent Status Card - Compact Timeline Style */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -578,6 +620,21 @@ const Agents: React.FC = () => {
|
|||||||
// Use dedicated heartbeat status instead of general agent metadata
|
// Use dedicated heartbeat status instead of general agent metadata
|
||||||
const isRapidPolling = heartbeatStatus?.enabled && heartbeatStatus?.active;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRapidPollingToggle(selectedAgent.id, !isRapidPolling)}
|
onClick={() => handleRapidPollingToggle(selectedAgent.id, !isRapidPolling)}
|
||||||
@@ -586,8 +643,12 @@ const Agents: React.FC = () => {
|
|||||||
'flex items-center space-x-1 px-2 py-1 rounded-md text-xs font-medium transition-colors',
|
'flex items-center space-x-1 px-2 py-1 rounded-md text-xs font-medium transition-colors',
|
||||||
heartbeatLoading
|
heartbeatLoading
|
||||||
? 'bg-gray-100 text-gray-400 border border-gray-200 cursor-not-allowed'
|
? 'bg-gray-100 text-gray-400 border border-gray-200 cursor-not-allowed'
|
||||||
: isRapidPolling
|
: isRapidPolling && isSystemHeartbeat
|
||||||
|
? 'bg-blue-100 text-blue-800 border border-blue-200 hover:bg-blue-200 cursor-pointer'
|
||||||
|
: isRapidPolling && isManualHeartbeat
|
||||||
? 'bg-pink-100 text-pink-800 border border-pink-200 hover:bg-pink-200 cursor-pointer'
|
? 'bg-pink-100 text-pink-800 border border-pink-200 hover:bg-pink-200 cursor-pointer'
|
||||||
|
: isRapidPolling
|
||||||
|
? 'bg-gray-100 text-gray-800 border border-gray-200 hover:bg-gray-200 cursor-pointer'
|
||||||
: 'bg-gray-100 text-gray-600 border border-gray-200 hover:bg-gray-200 cursor-pointer'
|
: 'bg-gray-100 text-gray-600 border border-gray-200 hover:bg-gray-200 cursor-pointer'
|
||||||
)}
|
)}
|
||||||
title={heartbeatLoading ? 'Sending command...' : `Click to toggle ${isRapidPolling ? 'normal' : 'heartbeat'} mode`}
|
title={heartbeatLoading ? 'Sending command...' : `Click to toggle ${isRapidPolling ? 'normal' : 'heartbeat'} mode`}
|
||||||
@@ -597,7 +658,9 @@ const Agents: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Activity className={cn(
|
<Activity className={cn(
|
||||||
'h-3 w-3',
|
'h-3 w-3',
|
||||||
isRapidPolling ? 'text-pink-600 animate-pulse' : 'text-gray-400'
|
isRapidPolling && isSystemHeartbeat ? 'text-blue-600 animate-pulse' :
|
||||||
|
isRapidPolling && isManualHeartbeat ? 'text-pink-600 animate-pulse' :
|
||||||
|
isRapidPolling ? 'text-gray-600 animate-pulse' : 'text-gray-400'
|
||||||
)} />
|
)} />
|
||||||
)}
|
)}
|
||||||
<span>
|
<span>
|
||||||
@@ -743,9 +806,22 @@ const Agents: React.FC = () => {
|
|||||||
|
|
||||||
{/* Heartbeat Status Info */}
|
{/* Heartbeat Status Info */}
|
||||||
{heartbeatStatus?.enabled && heartbeatStatus?.active && (
|
{heartbeatStatus?.enabled && heartbeatStatus?.active && (
|
||||||
<div className="text-xs text-pink-600 bg-pink-50 px-2 py-1 rounded-md mt-2">
|
(() => {
|
||||||
Heartbeat active for {formatHeartExpiration(heartbeatStatus.until)}
|
// Get source from heartbeat status (stored in agent metadata)
|
||||||
|
const heartbeatSource = heartbeatStatus?.source;
|
||||||
|
const isSystemHeartbeat = heartbeatSource === 'system';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"text-xs px-2 py-1 rounded-md mt-2",
|
||||||
|
isSystemHeartbeat
|
||||||
|
? "text-blue-600 bg-blue-50"
|
||||||
|
: "text-pink-600 bg-pink-50"
|
||||||
|
)}>
|
||||||
|
{isSystemHeartbeat ? 'System ' : 'Manual '}heartbeat active for {formatHeartExpiration(heartbeatStatus.until)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Button */}
|
||||||
@@ -891,17 +967,24 @@ const Agents: React.FC = () => {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* System Updates */}
|
|
||||||
<AgentSystemUpdates agentId={selectedAgent.id} />
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<div>
|
|
||||||
|
{activeTab === 'storage' && (
|
||||||
|
<AgentStorage agentId={selectedAgent.id} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'updates' && (
|
||||||
|
<AgentUpdatesEnhanced agentId={selectedAgent.id} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'scanners' && (
|
||||||
|
<AgentScanners agentId={selectedAgent.id} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'history' && (
|
||||||
<ChatTimeline agentId={selectedAgent.id} isScopedView={true} />
|
<ChatTimeline agentId={selectedAgent.id} isScopedView={true} />
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -176,9 +176,10 @@ export interface AptUpdateInfo {
|
|||||||
export interface Command {
|
export interface Command {
|
||||||
id: string;
|
id: string;
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
command_type: 'scan' | 'install' | 'update' | 'reboot';
|
command_type: 'scan' | 'install' | 'update' | 'reboot' | 'enable_heartbeat' | 'disable_heartbeat';
|
||||||
payload: Record<string, any>;
|
payload: Record<string, any>;
|
||||||
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;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
executed_at: string | null;
|
executed_at: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user