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:
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user