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:
Fimeg
2025-11-01 09:27:58 -04:00
parent 5fd82e5697
commit 01c09cefab
16 changed files with 1823 additions and 372 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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