ui: improve Agent Health layout and fix misaligned controls

- 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.
This commit is contained in:
Fimeg
2025-11-10 23:08:17 -05:00
parent 3f0838affc
commit 8b9a314200
5 changed files with 1008 additions and 32 deletions

View File

@@ -16,6 +16,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// AgentUpdateHandler handles agent binary update operations
// DEPRECATED: This handler is being consolidated - will be replaced by unified update handling
type AgentUpdateHandler struct {
agentQueries *queries.AgentQueries
agentUpdateQueries *queries.AgentUpdateQueries

View File

@@ -0,0 +1,881 @@
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),
})
}

View File

@@ -23,6 +23,8 @@ func isValidResult(result string) bool {
return validResults[result]
}
// UpdateHandler handles package update operations
// DEPRECATED: This handler is being consolidated - will be replaced by unified update handling
type UpdateHandler struct {
updateQueries *queries.UpdateQueries
agentQueries *queries.AgentQueries

View File

@@ -13,12 +13,14 @@ import {
CheckCircle,
AlertCircle,
XCircle,
Upload,
} from 'lucide-react';
import { formatRelativeTime } from '@/lib/utils';
import { agentApi, securityApi } from '@/lib/api';
import toast from 'react-hot-toast';
import { cn } from '@/lib/utils';
import { AgentSubsystem } from '@/types';
import { AgentUpdate } from './AgentUpdate';
interface AgentScannersProps {
agentId: string;
@@ -53,6 +55,7 @@ const subsystemConfig: Record<string, { icon: React.ReactNode; name: string; des
};
export function AgentScanners({ agentId }: AgentScannersProps) {
const [showUpdateModal, setShowUpdateModal] = useState(false);
const queryClient = useQueryClient();
// Fetch subsystems from API
@@ -65,6 +68,13 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
refetchInterval: 30000, // Refresh every 30 seconds
});
// Fetch agent data for Update Agent button
const { data: agent } = useQuery({
queryKey: ['agent', agentId],
queryFn: () => agentApi.getAgent(agentId),
refetchInterval: 30000,
});
// Fetch security health status
const { data: securityOverview, isLoading: securityLoading } = useQuery({
queryKey: ['security-overview'],
@@ -79,12 +89,16 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
const getSecurityStatusDisplay = (status: string) => {
switch (status) {
case 'healthy':
case 'enforced':
case 'operational':
return {
color: 'text-green-600 bg-green-100 border-green-200',
icon: <CheckCircle className="h-4 w-4 text-green-600" />
};
case 'enforced':
return {
color: 'text-blue-600 bg-blue-100 border-blue-200',
icon: <Shield className="h-4 w-4 text-blue-600" />
};
case 'degraded':
return {
color: 'text-amber-600 bg-amber-100 border-amber-200',
@@ -227,11 +241,25 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
const autoRunCount = subsystems.filter(s => s.auto_run && s.enabled).length;
return (
<div className="space-y-6">
<div className="space-y-4">
{/* Subsystem Configuration Table */}
<div className="card">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-4">
<h3 className="text-sm font-medium text-gray-900">Subsystem Configuration</h3>
<div className="flex items-center space-x-3 text-xs text-gray-600">
<span>{enabledCount} enabled</span>
<span>{autoRunCount} auto-running</span>
<span className="text-gray-500"> {subsystems.length} total</span>
</div>
</div>
<button
onClick={() => setShowUpdateModal(true)}
className="flex items-center space-x-1 px-3 py-1 text-xs text-blue-600 hover:text-blue-800 border border-blue-300 hover:bg-blue-50 rounded-md transition-colors"
>
<Upload className="h-3 w-3" />
<span>Update Agent</span>
</button>
</div>
{isLoading ? (
@@ -376,37 +404,13 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
)}
</div>
{/* Compact note */}
<div className="text-xs text-gray-500">
Subsystems report specific metrics to the server on scheduled intervals. Enable auto-run to schedule automatic scans, or trigger manual scans as needed.
</div>
{/* 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">Enabled:</span>
<span className="ml-2 font-medium text-green-600">{enabledCount}/{subsystems.length}</span>
</div>
<div>
<span className="text-gray-600">Auto-Run:</span>
<span className="ml-2 font-medium text-blue-600">{autoRunCount}</span>
</div>
</div>
<button
onClick={() => refetch()}
disabled={isLoading}
className="flex items-center space-x-1 px-3 py-1 text-xs text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded transition-colors"
>
<RefreshCw className={cn('h-3 w-3', isLoading && 'animate-spin')} />
<span>Refresh</span>
</button>
</div>
{/* Note */}
<div className="text-xs text-gray-500 text-center py-2">
Subsystems report specific metrics on scheduled intervals. Enable auto-run to schedule automatic scans, or use Actions to trigger manual scans.
</div>
{/* Security Health */}
<div className="bg-white/90 backdrop-blur-md rounded-lg border border-gray-200/50 shadow-sm">
<div className="card">
<div className="flex items-center justify-between p-4 border-b border-gray-200/50">
<div className="flex items-center space-x-2">
<Shield className="h-5 w-5 text-blue-600" />
@@ -516,6 +520,25 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
}
};
const getDetailedSecurityInfo = (subsystemType: string, subsystem: any) => {
if (!securityOverview?.subsystems[subsystemType]) return '';
const subsystemData = securityOverview.subsystems[subsystemType];
const checks = subsystemData.checks || {};
const metrics = subsystemData.metrics || {};
switch (subsystemType) {
case 'nonce_validation':
return `Nonces: ${metrics.total_pending_commands || 0} pending. Max age: ${checks.max_age_minutes || 5}min. Failures: ${checks.validation_failures || 0}. Format: ${checks.nonce_format || 'UUID:Timestamp'}`;
case 'machine_binding':
return `Machine ID: ${checks.machine_id_type || 'Hardware fingerprint'}. Bound agents: ${checks.bound_agents || 'Unknown'}. Violations: ${checks.recent_violations || 0}. Min version: ${checks.min_agent_version || 'v0.1.22'}`;
case 'ed25519_signing':
return `Key: ${checks.public_key_fingerprint?.substring(0, 16) || 'Not available'}... Algorithm: ${checks.algorithm || 'Ed25519'}. Valid since: ${new Date(securityOverview.timestamp).toLocaleDateString()}`;
default:
return `Status: ${subsystem.status}. Last check: ${new Date(securityOverview.timestamp).toLocaleString()}`;
}
};
return (
<div
key={key}
@@ -528,7 +551,7 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
{getSecurityIcon(key)}
</div>
</div>
<div>
<div className="flex-1">
<p className="text-sm font-medium text-gray-900 flex items-center gap-2">
{getSecurityDisplayName(key)}
<CheckCircle className="w-3 h-3 text-gray-400" />
@@ -536,15 +559,24 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
<p className="text-xs text-gray-600 mt-0.5">
{getEnhancedSubtitle(key, subsystem.status)}
</p>
{(key === 'nonce_validation' || key === 'machine_binding' || key === 'ed25519_signing') && (
<div className="mt-2 p-2 bg-gray-50/70 rounded border border-gray-200/50">
<p className="text-xs text-gray-700 font-mono break-all">
{getDetailedSecurityInfo(key, subsystem)}
</p>
</div>
)}
</div>
</div>
<div className={cn(
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium border',
subsystem.status === 'healthy' ? 'bg-green-100 text-green-700 border-green-200' :
subsystem.status === 'enforced' ? 'bg-blue-100 text-blue-700 border-blue-200' :
subsystem.status === 'degraded' ? 'bg-amber-100 text-amber-700 border-amber-200' :
'bg-red-100 text-red-700 border-red-200'
)}>
{subsystem.status === 'healthy' && <CheckCircle className="w-3 h-3" />}
{subsystem.status === 'enforced' && <Shield className="w-3 h-3" />}
{subsystem.status === 'degraded' && <AlertCircle className="w-3 h-3" />}
{subsystem.status === 'unhealthy' && <XCircle className="w-3 h-3" />}
{subsystem.status.toUpperCase()}
@@ -603,6 +635,35 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
</div>
)}
</div>
{/* Update Agent Modal */}
{showUpdateModal && agent && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowUpdateModal(false)} />
<div className="relative z-10 w-full max-w-lg mx-4">
<div className="bg-white rounded-lg shadow-xl border border-gray-200">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">Update Agent: {agent.hostname}</h3>
<button
onClick={() => setShowUpdateModal(false)}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<XCircle className="h-5 w-5" />
</button>
</div>
<div className="p-6">
<AgentUpdate
agent={agent}
onUpdateComplete={() => {
setShowUpdateModal(false);
queryClient.invalidateQueries({ queryKey: ['agent', agentId] });
}}
/>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -190,6 +190,36 @@ export const agentApi = {
return response.data;
},
// Get agent metrics
getAgentMetrics: async (agentId: string): Promise<any> => {
const response = await api.get(`/agents/${agentId}/metrics`);
return response.data;
},
// Get agent storage metrics
getAgentStorageMetrics: async (agentId: string): Promise<any> => {
const response = await api.get(`/agents/${agentId}/metrics/storage`);
return response.data;
},
// Get agent system metrics
getAgentSystemMetrics: async (agentId: string): Promise<any> => {
const response = await api.get(`/agents/${agentId}/metrics/system`);
return response.data;
},
// Get agent Docker images
getAgentDockerImages: async (agentId: string, params?: any): Promise<any> => {
const response = await api.get(`/agents/${agentId}/docker-images`, { params });
return response.data;
},
// Get agent Docker info
getAgentDockerInfo: async (agentId: string): Promise<any> => {
const response = await api.get(`/agents/${agentId}/docker-info`);
return response.data;
},
// Update multiple agents (bulk)
updateMultipleAgents: async (updateData: {
agent_ids: string[];