- Move Update Agent button to Subsystem Configuration header - Remove duplicate Compact Summary box with misaligned refresh - Reduce visual separation between sections (same card styling) - Make security status details visible instead of hidden in tooltips - Fix enforced status colors (blue instead of red) - Consolidate enabled/auto-run counts in header - Reduce spacing between sections for cohesive interface The enabled/auto-run toggles now properly align with their subsystems in the table, and critical security information is immediately visible without hover interactions.
882 lines
26 KiB
Go
882 lines
26 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
|
|
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
// UnifiedUpdateHandler processes all update reports (metrics, package updates, etc.)
|
|
type UnifiedUpdateHandler struct {
|
|
db *sqlx.DB
|
|
agentQueries *queries.AgentQueries
|
|
updateQueries *queries.UpdateQueries
|
|
subsystemQueries *queries.SubsystemQueries
|
|
commandQueries *queries.CommandQueries
|
|
agentHandler *AgentHandler
|
|
logger *log.Logger
|
|
}
|
|
|
|
// NewUnifiedUpdateHandler creates a new update handler
|
|
func NewUnifiedUpdateHandler(db *sqlx.DB, logger *log.Logger, ah *AgentHandler) *UnifiedUpdateHandler {
|
|
return &UnifiedUpdateHandler{
|
|
db: db,
|
|
agentQueries: queries.NewAgentQueries(db),
|
|
updateQueries: queries.NewUpdateQueries(db),
|
|
subsystemQueries: queries.NewSubsystemQueries(db),
|
|
commandQueries: queries.NewCommandQueries(db),
|
|
agentHandler: ah,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// Report handles POST /api/v1/agents/:id/updates
|
|
func (h *UnifiedUpdateHandler) Report(c *gin.Context) {
|
|
agentIDStr := c.Param("id")
|
|
agentID, err := uuid.Parse(agentIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
|
|
return
|
|
}
|
|
|
|
// Update last_seen timestamp
|
|
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
|
|
return
|
|
}
|
|
|
|
var report models.UpdateReportRequest
|
|
if err := c.ShouldBindJSON(&report); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Validate report
|
|
if err := h.validateReport(&report); err != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Route to appropriate handler based on report type
|
|
if h.isPackageUpdateReport(&report) {
|
|
if err := h.handlePackageUpdateReport(agentID, &report); err != nil {
|
|
h.logger.Printf("Failed to handle package update report: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process updates"})
|
|
return
|
|
}
|
|
} else {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown report type"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "received",
|
|
"count": len(report.Updates),
|
|
"command_id": report.CommandID,
|
|
})
|
|
}
|
|
|
|
// validateReport validates the update report
|
|
func (h *UnifiedUpdateHandler) validateReport(report *models.UpdateReportRequest) error {
|
|
if report.Timestamp.IsZero() {
|
|
return fmt.Errorf("timestamp is required")
|
|
}
|
|
|
|
// Validate updates
|
|
if len(report.Updates) > 0 {
|
|
for i, update := range report.Updates {
|
|
if update.PackageName == "" {
|
|
return fmt.Errorf("update[%d]: package name is required", i)
|
|
}
|
|
if update.PackageType == "" {
|
|
return fmt.Errorf("update[%d]: package type is required", i)
|
|
}
|
|
if update.AvailableVersion == "" {
|
|
return fmt.Errorf("update[%d]: available version is required", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isPackageUpdateReport determines if the report contains package updates
|
|
func (h *UnifiedUpdateHandler) isPackageUpdateReport(report *models.UpdateReportRequest) bool {
|
|
return len(report.Updates) > 0
|
|
}
|
|
|
|
// handlePackageUpdateReport processes package update data
|
|
func (h *UnifiedUpdateHandler) handlePackageUpdateReport(agentID uuid.UUID, report *models.UpdateReportRequest) error {
|
|
// Convert update report items to events
|
|
events := make([]models.UpdateEvent, 0, len(report.Updates))
|
|
for _, item := range report.Updates {
|
|
event := models.UpdateEvent{
|
|
ID: uuid.New(),
|
|
AgentID: agentID,
|
|
PackageType: item.PackageType,
|
|
PackageName: item.PackageName,
|
|
VersionFrom: item.CurrentVersion,
|
|
VersionTo: item.AvailableVersion,
|
|
Severity: item.Severity,
|
|
RepositorySource: item.RepositorySource,
|
|
Metadata: item.Metadata,
|
|
EventType: "discovered",
|
|
CreatedAt: report.Timestamp,
|
|
}
|
|
events = append(events, event)
|
|
}
|
|
|
|
// Store events in batch
|
|
if err := h.updateQueries.CreateUpdateEventsBatch(events); err != nil {
|
|
return fmt.Errorf("failed to create update events: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListUpdates retrieves updates with filtering
|
|
func (h *UnifiedUpdateHandler) ListUpdates(c *gin.Context) {
|
|
filters := &models.UpdateFilters{
|
|
Status: c.Query("status"),
|
|
Severity: c.Query("severity"),
|
|
PackageType: c.Query("package_type"),
|
|
}
|
|
|
|
if agentIDStr := c.Query("agent_id"); agentIDStr != "" {
|
|
agentID, err := uuid.Parse(agentIDStr)
|
|
if err == nil {
|
|
filters.AgentID = agentID
|
|
}
|
|
}
|
|
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
|
filters.Page = page
|
|
filters.PageSize = pageSize
|
|
|
|
updates, total, err := h.updateQueries.ListUpdatesFromState(filters)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list updates"})
|
|
return
|
|
}
|
|
|
|
stats, err := h.updateQueries.GetAllUpdateStats()
|
|
if err != nil {
|
|
stats = &models.UpdateStats{}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"updates": updates,
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": pageSize,
|
|
"stats": stats,
|
|
})
|
|
}
|
|
|
|
// GetUpdate retrieves a single update by ID
|
|
func (h *UnifiedUpdateHandler) GetUpdate(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
|
|
return
|
|
}
|
|
|
|
update, err := h.updateQueries.GetUpdateByID(id)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "update not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, update)
|
|
}
|
|
|
|
// ApproveUpdate marks an update as approved
|
|
func (h *UnifiedUpdateHandler) ApproveUpdate(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
|
|
return
|
|
}
|
|
|
|
if err := h.updateQueries.ApproveUpdate(id, "admin"); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to approve update: %v", err)})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "update approved"})
|
|
}
|
|
|
|
// isValidResult checks if the result value complies with the database constraint
|
|
func isValidUpdateResult(result string) bool {
|
|
validResults := map[string]bool{
|
|
"success": true,
|
|
"failed": true,
|
|
"partial": true,
|
|
}
|
|
return validResults[result]
|
|
}
|
|
|
|
// ReportLog handles update execution logs from agents
|
|
func (h *UnifiedUpdateHandler) ReportLog(c *gin.Context) {
|
|
agentID := c.MustGet("agent_id").(uuid.UUID)
|
|
|
|
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
|
|
return
|
|
}
|
|
|
|
var req models.UpdateLogRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
validResult := req.Result
|
|
if !isValidUpdateResult(validResult) {
|
|
if validResult == "timed_out" || validResult == "timeout" || validResult == "cancelled" {
|
|
validResult = "failed"
|
|
} else {
|
|
validResult = "failed"
|
|
}
|
|
}
|
|
|
|
logEntry := &models.UpdateLog{
|
|
ID: uuid.New(),
|
|
AgentID: agentID,
|
|
Action: req.Action,
|
|
Result: validResult,
|
|
Stdout: req.Stdout,
|
|
Stderr: req.Stderr,
|
|
ExitCode: req.ExitCode,
|
|
DurationSeconds: req.DurationSeconds,
|
|
ExecutedAt: time.Now(),
|
|
}
|
|
|
|
if err := h.updateQueries.CreateUpdateLog(logEntry); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save log"})
|
|
return
|
|
}
|
|
|
|
// Update command status if command_id is provided
|
|
if req.CommandID != "" {
|
|
commandID, err := uuid.Parse(req.CommandID)
|
|
if err != nil {
|
|
log.Printf("Warning: Invalid command ID format in log request: %s\n", req.CommandID)
|
|
} else {
|
|
result := models.JSONB{
|
|
"stdout": req.Stdout,
|
|
"stderr": req.Stderr,
|
|
"exit_code": req.ExitCode,
|
|
"duration_seconds": req.DurationSeconds,
|
|
"logged_at": time.Now(),
|
|
}
|
|
|
|
if req.Result == "success" || req.Result == "completed" {
|
|
if err := h.commandQueries.MarkCommandCompleted(commandID, result); err != nil {
|
|
log.Printf("Warning: Failed to mark command %s as completed: %v\n", commandID, err)
|
|
}
|
|
|
|
command, err := h.commandQueries.GetCommandByID(commandID)
|
|
if err == nil && command.CommandType == models.CommandTypeConfirmDependencies {
|
|
if packageName, ok := command.Params["package_name"].(string); ok {
|
|
if packageType, ok := command.Params["package_type"].(string); ok {
|
|
var completionTime *time.Time
|
|
if loggedAtStr, ok := command.Result["logged_at"].(string); ok {
|
|
if parsed, err := time.Parse(time.RFC3339Nano, loggedAtStr); err == nil {
|
|
completionTime = &parsed
|
|
}
|
|
}
|
|
|
|
if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, "updated", nil, completionTime); err != nil {
|
|
log.Printf("Warning: Failed to update package status for %s/%s: %v", packageType, packageName, err)
|
|
} else {
|
|
log.Printf("✅ Package %s (%s) marked as updated after successful installation", packageName, packageType)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if req.Result == "failed" || req.Result == "dry_run_failed" {
|
|
if err := h.commandQueries.MarkCommandFailed(commandID, result); err != nil {
|
|
log.Printf("Warning: Failed to mark command %s as failed: %v\n", commandID, err)
|
|
}
|
|
} else {
|
|
if err := h.commandQueries.UpdateCommandResult(commandID, result); err != nil {
|
|
log.Printf("Warning: Failed to update command %s result: %v\n", commandID, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "log recorded"})
|
|
}
|
|
|
|
// GetPackageHistory returns version history for a specific package
|
|
func (h *UnifiedUpdateHandler) GetPackageHistory(c *gin.Context) {
|
|
agentIDStr := c.Param("agent_id")
|
|
agentID, err := uuid.Parse(agentIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
|
|
return
|
|
}
|
|
|
|
packageType := c.Query("package_type")
|
|
packageName := c.Query("package_name")
|
|
|
|
if packageType == "" || packageName == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "package_type and package_name are required"})
|
|
return
|
|
}
|
|
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
|
|
|
history, err := h.updateQueries.GetPackageHistory(agentID, packageType, packageName, limit)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get package history"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"history": history,
|
|
"package_type": packageType,
|
|
"package_name": packageName,
|
|
"count": len(history),
|
|
})
|
|
}
|
|
|
|
// UpdatePackageStatus updates the status of a package
|
|
func (h *UnifiedUpdateHandler) UpdatePackageStatus(c *gin.Context) {
|
|
agentIDStr := c.Param("agent_id")
|
|
agentID, err := uuid.Parse(agentIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
PackageType string `json:"package_type" binding:"required"`
|
|
PackageName string `json:"package_name" binding:"required"`
|
|
Status string `json:"status" binding:"required"`
|
|
Metadata map[string]interface{} `json:"metadata"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := h.updateQueries.UpdatePackageStatus(agentID, req.PackageType, req.PackageName, req.Status, req.Metadata, nil); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "package status updated"})
|
|
}
|
|
|
|
// shouldEnableHeartbeat checks if heartbeat is already active for an agent
|
|
func (h *UnifiedUpdateHandler) shouldEnableHeartbeat(agentID uuid.UUID, durationMinutes int) (bool, error) {
|
|
agent, err := h.agentQueries.GetAgentByID(agentID)
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to get agent %s for heartbeat check: %v", agentID, err)
|
|
return true, nil
|
|
}
|
|
|
|
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(5*time.Minute)) {
|
|
log.Printf("[Heartbeat] Agent %s already has active heartbeat until %s (skipping)", agentID, untilStr)
|
|
return false, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// InstallUpdate marks an update as ready for installation
|
|
func (h *UnifiedUpdateHandler) InstallUpdate(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
|
|
return
|
|
}
|
|
|
|
update, err := h.updateQueries.GetUpdateByID(id)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get update details"})
|
|
return
|
|
}
|
|
|
|
command := &models.AgentCommand{
|
|
ID: uuid.New(),
|
|
AgentID: update.AgentID,
|
|
CommandType: models.CommandTypeDryRunUpdate,
|
|
Params: map[string]interface{}{
|
|
"update_id": id.String(),
|
|
"package_name": update.PackageName,
|
|
"package_type": update.PackageType,
|
|
},
|
|
Status: models.CommandStatusPending,
|
|
Source: models.CommandSourceManual,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if shouldEnable, err := h.shouldEnableHeartbeat(update.AgentID, 10); err == nil && shouldEnable {
|
|
heartbeatCmd := &models.AgentCommand{
|
|
ID: uuid.New(),
|
|
AgentID: update.AgentID,
|
|
CommandType: models.CommandTypeEnableHeartbeat,
|
|
Params: models.JSONB{
|
|
"duration_minutes": 10,
|
|
},
|
|
Status: models.CommandStatusPending,
|
|
Source: models.CommandSourceSystem,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if err := h.commandQueries.CreateCommand(heartbeatCmd); err != nil {
|
|
log.Printf("[Heartbeat] Warning: Failed to create heartbeat command for agent %s: %v", update.AgentID, err)
|
|
}
|
|
}
|
|
|
|
if err := h.commandQueries.CreateCommand(command); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dry run command"})
|
|
return
|
|
}
|
|
|
|
if err := h.updateQueries.SetCheckingDependencies(id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "dry run command created for agent",
|
|
"command_id": command.ID.String(),
|
|
})
|
|
}
|
|
|
|
// ReportDependencies handles dependency reporting from agents after dry run
|
|
func (h *UnifiedUpdateHandler) ReportDependencies(c *gin.Context) {
|
|
agentID := c.MustGet("agent_id").(uuid.UUID)
|
|
|
|
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
|
|
return
|
|
}
|
|
|
|
var req models.DependencyReportRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Auto-approve if no dependencies
|
|
if len(req.Dependencies) == 0 {
|
|
update, err := h.updateQueries.GetUpdateByPackage(agentID, req.PackageType, req.PackageName)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get update details"})
|
|
return
|
|
}
|
|
|
|
command := &models.AgentCommand{
|
|
ID: uuid.New(),
|
|
AgentID: agentID,
|
|
CommandType: models.CommandTypeConfirmDependencies,
|
|
Params: map[string]interface{}{
|
|
"update_id": update.ID.String(),
|
|
"package_name": req.PackageName,
|
|
"package_type": req.PackageType,
|
|
"dependencies": []string{},
|
|
},
|
|
Status: models.CommandStatusPending,
|
|
Source: models.CommandSourceManual,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if shouldEnable, err := h.shouldEnableHeartbeat(agentID, 10); err == nil && shouldEnable {
|
|
heartbeatCmd := &models.AgentCommand{
|
|
ID: uuid.New(),
|
|
AgentID: agentID,
|
|
CommandType: models.CommandTypeEnableHeartbeat,
|
|
Params: models.JSONB{
|
|
"duration_minutes": 10,
|
|
},
|
|
Status: models.CommandStatusPending,
|
|
Source: models.CommandSourceSystem,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if err := h.commandQueries.CreateCommand(heartbeatCmd); err != nil {
|
|
log.Printf("[Heartbeat] Warning: Failed to create heartbeat command for agent %s: %v", agentID, err)
|
|
}
|
|
}
|
|
|
|
if err := h.commandQueries.CreateCommand(command); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create installation command"})
|
|
return
|
|
}
|
|
|
|
if err := h.updateQueries.SetInstallingWithNoDependencies(update.ID, req.Dependencies); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status to installing"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "no dependencies found - installation command created automatically",
|
|
"command_id": command.ID.String(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Require manual approval for dependencies
|
|
if err := h.updateQueries.SetPendingDependencies(agentID, req.PackageType, req.PackageName, req.Dependencies); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "dependencies reported and status updated"})
|
|
}
|
|
|
|
// ConfirmDependencies handles user confirmation to proceed with dependency installation
|
|
func (h *UnifiedUpdateHandler) ConfirmDependencies(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
|
|
return
|
|
}
|
|
|
|
update, err := h.updateQueries.GetUpdateByID(id)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "update not found"})
|
|
return
|
|
}
|
|
|
|
command := &models.AgentCommand{
|
|
ID: uuid.New(),
|
|
AgentID: update.AgentID,
|
|
CommandType: models.CommandTypeConfirmDependencies,
|
|
Params: map[string]interface{}{
|
|
"update_id": id.String(),
|
|
"package_name": update.PackageName,
|
|
"package_type": update.PackageType,
|
|
"dependencies": update.Metadata["dependencies"],
|
|
},
|
|
Status: models.CommandStatusPending,
|
|
Source: models.CommandSourceManual,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if shouldEnable, err := h.shouldEnableHeartbeat(update.AgentID, 10); err == nil && shouldEnable {
|
|
heartbeatCmd := &models.AgentCommand{
|
|
ID: uuid.New(),
|
|
AgentID: update.AgentID,
|
|
CommandType: models.CommandTypeEnableHeartbeat,
|
|
Params: models.JSONB{
|
|
"duration_minutes": 10,
|
|
},
|
|
Status: models.CommandStatusPending,
|
|
Source: models.CommandSourceSystem,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if err := h.commandQueries.CreateCommand(heartbeatCmd); err != nil {
|
|
log.Printf("[Heartbeat] Warning: Failed to create heartbeat command for agent %s: %v", update.AgentID, err)
|
|
}
|
|
}
|
|
|
|
if err := h.commandQueries.CreateCommand(command); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create confirmation command"})
|
|
return
|
|
}
|
|
|
|
if err := h.updateQueries.InstallUpdate(id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "dependency installation confirmed and command created",
|
|
"command_id": command.ID.String(),
|
|
})
|
|
}
|
|
|
|
// ApproveUpdates handles bulk approval of updates
|
|
func (h *UnifiedUpdateHandler) ApproveUpdates(c *gin.Context) {
|
|
var req struct {
|
|
UpdateIDs []string `json:"update_ids" binding:"required"`
|
|
ScheduledAt *string `json:"scheduled_at"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
updateIDs := make([]uuid.UUID, 0, len(req.UpdateIDs))
|
|
for _, idStr := range req.UpdateIDs {
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID: " + idStr})
|
|
return
|
|
}
|
|
updateIDs = append(updateIDs, id)
|
|
}
|
|
|
|
if err := h.updateQueries.BulkApproveUpdates(updateIDs, "admin"); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to approve updates"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "updates approved",
|
|
"count": len(updateIDs),
|
|
})
|
|
}
|
|
|
|
// RejectUpdate rejects a single update
|
|
func (h *UnifiedUpdateHandler) RejectUpdate(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
|
|
return
|
|
}
|
|
|
|
if err := h.updateQueries.RejectUpdate(id, "admin"); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject update"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "update rejected"})
|
|
}
|
|
|
|
// GetAllLogs retrieves logs across all agents with filtering
|
|
func (h *UnifiedUpdateHandler) GetAllLogs(c *gin.Context) {
|
|
filters := &models.LogFilters{
|
|
Action: c.Query("action"),
|
|
Result: c.Query("result"),
|
|
}
|
|
|
|
if agentIDStr := c.Query("agent_id"); agentIDStr != "" {
|
|
agentID, err := uuid.Parse(agentIDStr)
|
|
if err == nil {
|
|
filters.AgentID = agentID
|
|
}
|
|
}
|
|
|
|
if sinceStr := c.Query("since"); sinceStr != "" {
|
|
sinceTime, err := time.Parse(time.RFC3339, sinceStr)
|
|
if err == nil {
|
|
filters.Since = &sinceTime
|
|
}
|
|
}
|
|
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100"))
|
|
filters.Page = page
|
|
filters.PageSize = pageSize
|
|
|
|
items, total, err := h.updateQueries.GetAllUnifiedHistory(filters)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve history"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"logs": items,
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": pageSize,
|
|
})
|
|
}
|
|
|
|
// GetUpdateLogs retrieves installation logs for a specific update
|
|
func (h *UnifiedUpdateHandler) GetUpdateLogs(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
|
|
return
|
|
}
|
|
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
|
|
|
logs, err := h.updateQueries.GetUpdateLogs(id, limit)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve update logs"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"logs": logs,
|
|
"count": len(logs),
|
|
})
|
|
}
|
|
|
|
// RetryCommand retries a failed command
|
|
func (h *UnifiedUpdateHandler) RetryCommand(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"})
|
|
return
|
|
}
|
|
|
|
newCommand, err := h.commandQueries.RetryCommand(id)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to retry command: %v", err)})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "command retry created",
|
|
"command_id": newCommand.ID.String(),
|
|
"new_id": newCommand.ID.String(),
|
|
})
|
|
}
|
|
|
|
// CancelCommand cancels a pending command
|
|
func (h *UnifiedUpdateHandler) CancelCommand(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"})
|
|
return
|
|
}
|
|
|
|
if err := h.commandQueries.CancelCommand(id); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to cancel command: %v", err)})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "command cancelled"})
|
|
}
|
|
|
|
// GetActiveCommands retrieves currently active commands
|
|
func (h *UnifiedUpdateHandler) GetActiveCommands(c *gin.Context) {
|
|
commands, err := h.commandQueries.GetActiveCommands()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve active commands"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"commands": commands,
|
|
"count": len(commands),
|
|
})
|
|
}
|
|
|
|
// GetRecentCommands retrieves recent commands
|
|
func (h *UnifiedUpdateHandler) GetRecentCommands(c *gin.Context) {
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
|
|
|
commands, err := h.commandQueries.GetRecentCommands(limit)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve recent commands"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"commands": commands,
|
|
"count": len(commands),
|
|
"limit": limit,
|
|
})
|
|
}
|
|
|
|
// ClearFailedCommands removes failed commands
|
|
func (h *UnifiedUpdateHandler) ClearFailedCommands(c *gin.Context) {
|
|
olderThanDaysStr := c.Query("older_than_days")
|
|
onlyRetriedStr := c.Query("only_retried")
|
|
allFailedStr := c.Query("all_failed")
|
|
|
|
var count int64
|
|
var err error
|
|
|
|
olderThanDays := 7
|
|
if olderThanDaysStr != "" {
|
|
if days, err := strconv.Atoi(olderThanDaysStr); err == nil && days > 0 {
|
|
olderThanDays = days
|
|
}
|
|
}
|
|
|
|
onlyRetried := onlyRetriedStr == "true"
|
|
allFailed := allFailedStr == "true"
|
|
|
|
if allFailed {
|
|
count, err = h.commandQueries.ClearAllFailedCommandsRegardlessOfAge()
|
|
} else if onlyRetried {
|
|
count, err = h.commandQueries.ClearRetriedFailedCommands(olderThanDays)
|
|
} else {
|
|
count, err = h.commandQueries.ClearOldFailedCommands(olderThanDays)
|
|
}
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "failed to clear failed commands",
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
message := fmt.Sprintf("Archived %d failed commands", count)
|
|
if count > 0 {
|
|
message += ". WARNING: This shouldn't be necessary if the retry logic is working properly"
|
|
message += " (History preserved - commands moved to archived status)"
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": message,
|
|
"count": count,
|
|
})
|
|
}
|
|
|
|
// GetBatchStatus returns recent batch processing status for an agent
|
|
func (h *UnifiedUpdateHandler) GetBatchStatus(c *gin.Context) {
|
|
agentIDStr := c.Param("agent_id")
|
|
agentID, err := uuid.Parse(agentIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
|
|
return
|
|
}
|
|
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
|
|
|
batches, err := h.updateQueries.GetBatchStatus(agentID, limit)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get batch status"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"batches": batches,
|
|
"count": len(batches),
|
|
})
|
|
}
|
|
|
|
// GetActiveOperations retrieves currently running operations
|
|
func (h *UnifiedUpdateHandler) GetActiveOperations(c *gin.Context) {
|
|
operations, err := h.updateQueries.GetActiveOperations()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve active operations"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"operations": operations,
|
|
"count": len(operations),
|
|
})
|
|
}
|