feat: Updates page UI improvements and Windows agent enhancements
- Redesigned statistics cards with combined layout - Added quick filters for Installing, Installed, Failed, Dependencies - Implemented column sorting for all table headers - Added package name truncation to prevent layout stretching - Fixed TypeScript types for new update statuses - Updated screenshots and README
This commit is contained in:
@@ -89,6 +89,7 @@ func main() {
|
||||
agents.POST("/:id/updates", updateHandler.ReportUpdates)
|
||||
agents.POST("/:id/logs", updateHandler.ReportLog)
|
||||
agents.POST("/:id/dependencies", updateHandler.ReportDependencies)
|
||||
agents.POST("/:id/system-info", agentHandler.ReportSystemInfo)
|
||||
}
|
||||
|
||||
// Dashboard/Web routes (protected by web auth)
|
||||
|
||||
@@ -454,3 +454,90 @@ func (h *AgentHandler) UnregisterAgent(c *gin.Context) {
|
||||
"hostname": agent.Hostname,
|
||||
})
|
||||
}
|
||||
|
||||
// ReportSystemInfo handles system information updates from agents
|
||||
func (h *AgentHandler) ReportSystemInfo(c *gin.Context) {
|
||||
agentID := c.MustGet("agent_id").(uuid.UUID)
|
||||
|
||||
var req struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
CPUModel string `json:"cpu_model,omitempty"`
|
||||
CPUCores int `json:"cpu_cores,omitempty"`
|
||||
CPUThreads int `json:"cpu_threads,omitempty"`
|
||||
MemoryTotal uint64 `json:"memory_total,omitempty"`
|
||||
DiskTotal uint64 `json:"disk_total,omitempty"`
|
||||
DiskUsed uint64 `json:"disk_used,omitempty"`
|
||||
IPAddress string `json:"ip_address,omitempty"`
|
||||
Processes int `json:"processes,omitempty"`
|
||||
Uptime string `json:"uptime,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get current agent to preserve existing metadata
|
||||
agent, err := h.agentQueries.GetAgentByID(agentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update agent metadata with system information
|
||||
if agent.Metadata == nil {
|
||||
agent.Metadata = models.JSONB{}
|
||||
}
|
||||
|
||||
// Store system specs in metadata
|
||||
if req.CPUModel != "" {
|
||||
agent.Metadata["cpu_model"] = req.CPUModel
|
||||
}
|
||||
if req.CPUCores > 0 {
|
||||
agent.Metadata["cpu_cores"] = req.CPUCores
|
||||
}
|
||||
if req.CPUThreads > 0 {
|
||||
agent.Metadata["cpu_threads"] = req.CPUThreads
|
||||
}
|
||||
if req.MemoryTotal > 0 {
|
||||
agent.Metadata["memory_total"] = req.MemoryTotal
|
||||
}
|
||||
if req.DiskTotal > 0 {
|
||||
agent.Metadata["disk_total"] = req.DiskTotal
|
||||
}
|
||||
if req.DiskUsed > 0 {
|
||||
agent.Metadata["disk_used"] = req.DiskUsed
|
||||
}
|
||||
if req.IPAddress != "" {
|
||||
agent.Metadata["ip_address"] = req.IPAddress
|
||||
}
|
||||
if req.Processes > 0 {
|
||||
agent.Metadata["processes"] = req.Processes
|
||||
}
|
||||
if req.Uptime != "" {
|
||||
agent.Metadata["uptime"] = req.Uptime
|
||||
}
|
||||
|
||||
// Store the timestamp when system info was last updated
|
||||
agent.Metadata["system_info_updated_at"] = time.Now().Format(time.RFC3339)
|
||||
|
||||
// Merge any additional metadata
|
||||
if req.Metadata != nil {
|
||||
for k, v := range req.Metadata {
|
||||
agent.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Update agent with new metadata
|
||||
if err := h.agentQueries.UpdateAgent(agent); err != nil {
|
||||
log.Printf("Warning: Failed to update agent system info: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update system info"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ System info updated for agent %s (%s): CPU=%s, Cores=%d, Memory=%dMB",
|
||||
agent.Hostname, agentID, req.CPUModel, req.CPUCores, req.MemoryTotal/1024/1024)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "system info updated successfully"})
|
||||
}
|
||||
|
||||
@@ -463,12 +463,6 @@ func (h *UpdateHandler) ReportDependencies(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Record that dependencies were checked (empty array) in metadata
|
||||
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 metadata"})
|
||||
return
|
||||
}
|
||||
|
||||
// Automatically create installation command since no dependencies need approval
|
||||
command := &models.AgentCommand{
|
||||
ID: uuid.New(),
|
||||
@@ -489,8 +483,8 @@ func (h *UpdateHandler) ReportDependencies(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update status to installing since no approval needed
|
||||
if err := h.updateQueries.InstallUpdate(update.ID); err != nil {
|
||||
// Record that dependencies were checked (empty array) and transition directly to installing
|
||||
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
|
||||
}
|
||||
@@ -561,6 +555,7 @@ func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
|
||||
}
|
||||
|
||||
// GetAllLogs retrieves logs across all agents with filtering for universal log view
|
||||
// Now returns unified history of both commands and logs
|
||||
func (h *UpdateHandler) GetAllLogs(c *gin.Context) {
|
||||
filters := &models.LogFilters{
|
||||
Action: c.Query("action"),
|
||||
@@ -589,14 +584,15 @@ func (h *UpdateHandler) GetAllLogs(c *gin.Context) {
|
||||
filters.Page = page
|
||||
filters.PageSize = pageSize
|
||||
|
||||
logs, total, err := h.updateQueries.GetAllLogs(filters)
|
||||
// Get unified history (both commands and logs)
|
||||
items, total, err := h.updateQueries.GetAllUnifiedHistory(filters)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve logs"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve history"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": logs,
|
||||
"logs": items, // Changed from "logs" to unified items for backwards compatibility
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
|
||||
@@ -252,6 +252,29 @@ func (q *UpdateQueries) SetPendingDependencies(agentID uuid.UUID, packageType, p
|
||||
return err
|
||||
}
|
||||
|
||||
// SetInstallingWithNoDependencies records zero dependencies and transitions directly to installing
|
||||
// This function is used when a package has NO dependencies and can skip the pending_dependencies state
|
||||
func (q *UpdateQueries) SetInstallingWithNoDependencies(id uuid.UUID, dependencies []string) error {
|
||||
depsJSON, err := json.Marshal(dependencies)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal dependencies: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'installing',
|
||||
metadata = jsonb_set(
|
||||
jsonb_set(metadata, '{dependencies}', $2::jsonb),
|
||||
'{dependencies_reported_at}',
|
||||
to_jsonb(NOW())
|
||||
),
|
||||
last_updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'checking_dependencies'
|
||||
`
|
||||
_, err = q.db.Exec(query, id, depsJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateUpdateLog inserts an update log entry
|
||||
func (q *UpdateQueries) CreateUpdateLog(log *models.UpdateLog) error {
|
||||
query := `
|
||||
@@ -448,10 +471,16 @@ func (q *UpdateQueries) ListUpdatesFromState(filters *models.UpdateFilters) ([]m
|
||||
}
|
||||
|
||||
if filters.Status != "" {
|
||||
// Explicit status filter provided - use it
|
||||
baseQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||
countQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||
args = append(args, filters.Status)
|
||||
argIdx++
|
||||
} else {
|
||||
// No status filter - exclude 'updated' and 'ignored' packages by default
|
||||
// These should only be visible in history or when explicitly filtered
|
||||
baseQuery += " AND status NOT IN ('updated', 'ignored')"
|
||||
countQuery += " AND status NOT IN ('updated', 'ignored')"
|
||||
}
|
||||
|
||||
// Get total count
|
||||
@@ -725,6 +754,142 @@ func (q *UpdateQueries) GetAllLogs(filters *models.LogFilters) ([]models.UpdateL
|
||||
return logs, total, nil
|
||||
}
|
||||
|
||||
// UnifiedHistoryItem represents a single item in unified history (can be a command or log)
|
||||
type UnifiedHistoryItem struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
||||
Type string `json:"type" db:"type"` // "command" or "log"
|
||||
Action string `json:"action" db:"action"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Result string `json:"result" db:"result"`
|
||||
PackageName string `json:"package_name" db:"package_name"`
|
||||
PackageType string `json:"package_type" db:"package_type"`
|
||||
Stdout string `json:"stdout" db:"stdout"`
|
||||
Stderr string `json:"stderr" db:"stderr"`
|
||||
ExitCode int `json:"exit_code" db:"exit_code"`
|
||||
DurationSeconds int `json:"duration_seconds" db:"duration_seconds"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
Hostname string `json:"hostname" db:"hostname"`
|
||||
}
|
||||
|
||||
// GetAllUnifiedHistory retrieves both commands and logs as a unified history view
|
||||
func (q *UpdateQueries) GetAllUnifiedHistory(filters *models.LogFilters) ([]UnifiedHistoryItem, int, error) {
|
||||
whereClause := []string{"1=1"}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
// Add filters
|
||||
if filters.AgentID != uuid.Nil {
|
||||
whereClause = append(whereClause, fmt.Sprintf("agent_id = $%d", argIdx))
|
||||
args = append(args, filters.AgentID)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if filters.Action != "" {
|
||||
whereClause = append(whereClause, fmt.Sprintf("action = $%d", argIdx))
|
||||
args = append(args, filters.Action)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if filters.Result != "" {
|
||||
whereClause = append(whereClause, fmt.Sprintf("result = $%d", argIdx))
|
||||
args = append(args, filters.Result)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if filters.Since != nil {
|
||||
whereClause = append(whereClause, fmt.Sprintf("created_at >= $%d", argIdx))
|
||||
args = append(args, filters.Since)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
// Build the unified query using UNION ALL
|
||||
whereStr := strings.Join(whereClause, " AND ")
|
||||
|
||||
// Commands query
|
||||
commandsQuery := fmt.Sprintf(`
|
||||
SELECT
|
||||
ac.id,
|
||||
ac.agent_id,
|
||||
'command' as type,
|
||||
ac.command_type as action,
|
||||
ac.status,
|
||||
COALESCE(ac.result::text, '') as result,
|
||||
COALESCE(ac.params->>'package_name', 'System Operation') as package_name,
|
||||
COALESCE(ac.params->>'package_type', 'system') as package_type,
|
||||
COALESCE(ac.result->>'stdout', '') as stdout,
|
||||
COALESCE(ac.result->>'stderr', '') as stderr,
|
||||
COALESCE((ac.result->>'exit_code')::int, 0) as exit_code,
|
||||
COALESCE((ac.result->>'duration_seconds')::int, 0) as duration_seconds,
|
||||
ac.created_at,
|
||||
COALESCE(a.hostname, '') as hostname
|
||||
FROM agent_commands ac
|
||||
LEFT JOIN agents a ON ac.agent_id = a.id
|
||||
WHERE %s
|
||||
`, whereStr)
|
||||
|
||||
// Logs query
|
||||
logsQuery := fmt.Sprintf(`
|
||||
SELECT
|
||||
ul.id,
|
||||
ul.agent_id,
|
||||
'log' as type,
|
||||
ul.action,
|
||||
'' as status,
|
||||
ul.result,
|
||||
'' as package_name,
|
||||
'' as package_type,
|
||||
ul.stdout,
|
||||
ul.stderr,
|
||||
ul.exit_code,
|
||||
ul.duration_seconds,
|
||||
ul.executed_at as created_at,
|
||||
COALESCE(a.hostname, '') as hostname
|
||||
FROM update_logs ul
|
||||
LEFT JOIN agents a ON ul.agent_id = a.id
|
||||
WHERE %s
|
||||
`, whereStr)
|
||||
|
||||
// Combined query
|
||||
unifiedQuery := fmt.Sprintf(`
|
||||
%s
|
||||
UNION ALL
|
||||
%s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $%d OFFSET $%d
|
||||
`, commandsQuery, logsQuery, argIdx, argIdx+1)
|
||||
|
||||
// Get total count (combined count of both tables)
|
||||
countCommandsQuery := fmt.Sprintf("SELECT COUNT(*) FROM agent_commands WHERE %s", whereStr)
|
||||
countLogsQuery := fmt.Sprintf("SELECT COUNT(*) FROM update_logs WHERE %s", whereStr)
|
||||
|
||||
var totalCommands, totalLogs int
|
||||
q.db.Get(&totalCommands, countCommandsQuery, args...)
|
||||
q.db.Get(&totalLogs, countLogsQuery, args...)
|
||||
total := totalCommands + totalLogs
|
||||
|
||||
// Add pagination parameters
|
||||
limit := filters.PageSize
|
||||
if limit == 0 {
|
||||
limit = 100 // Default limit
|
||||
}
|
||||
offset := (filters.Page - 1) * limit
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
args = append(args, limit, offset)
|
||||
|
||||
// Execute query
|
||||
var items []UnifiedHistoryItem
|
||||
err := q.db.Select(&items, unifiedQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get unified history: %w", err)
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// GetActiveOperations returns currently running operations
|
||||
func (q *UpdateQueries) GetActiveOperations() ([]models.ActiveOperation, error) {
|
||||
var operations []models.ActiveOperation
|
||||
|
||||
Reference in New Issue
Block a user