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:
13
README.md
13
README.md
@@ -57,10 +57,15 @@ A self-hosted, cross-platform update management platform built with:
|
|||||||
|  |  |  |
|
|  |  |  |
|
||||||
| System overview with metrics | Update approval with dependency workflow | Cross-platform agent management |
|
| System overview with metrics | Update approval with dependency workflow | Cross-platform agent management |
|
||||||
|
|
||||||
| Linux Agent Details | Windows Agent Details | History & Audit |
|
| Linux Agent Details | Windows Agent Details |
|
||||||
|-------------------|---------------------|----------------|
|
|-------------------|---------------------|
|
||||||
|  |  |  |
|
|  |  |
|
||||||
| Linux system specs and updates | Windows Updates and Winget support | Complete audit trail of activities |
|
| Linux system specs and updates | Windows Updates and Winget support |
|
||||||
|
|
||||||
|
| History & Audit | Windows Agent History |
|
||||||
|
|----------------|----------------------|
|
||||||
|
|  |  |
|
||||||
|
| Complete audit trail of activities | Windows agent activity timeline |
|
||||||
|
|
||||||
| Live Operations | Docker Management |
|
| Live Operations | Docker Management |
|
||||||
|-----------------|------------------|
|
|-----------------|------------------|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 237 KiB |
BIN
Screenshots/RedFlag Windows Agent History .png
Normal file
BIN
Screenshots/RedFlag Windows Agent History .png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
@@ -21,7 +21,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AgentVersion = "0.1.5" // Command status synchronization, timeout fixes, DNF improvements
|
AgentVersion = "0.1.7" // Windows Update data enrichment: CVEs, MSRC severity, dates, version parsing
|
||||||
)
|
)
|
||||||
|
|
||||||
// getConfigPath returns the platform-specific config path
|
// getConfigPath returns the platform-specific config path
|
||||||
|
|||||||
@@ -54,9 +54,17 @@ func (i *WindowsUpdateInstaller) installUpdates(packageNames []string, isDryRun
|
|||||||
}
|
}
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Determine action type
|
||||||
|
action := "install"
|
||||||
|
if packageNames == nil {
|
||||||
|
action = "upgrade" // Upgrade all updates
|
||||||
|
}
|
||||||
|
|
||||||
result := &InstallResult{
|
result := &InstallResult{
|
||||||
Success: false,
|
Success: false,
|
||||||
IsDryRun: isDryRun,
|
IsDryRun: isDryRun,
|
||||||
|
Action: action,
|
||||||
DurationSeconds: 0,
|
DurationSeconds: 0,
|
||||||
PackagesInstalled: []string{},
|
PackagesInstalled: []string{},
|
||||||
Dependencies: []string{},
|
Dependencies: []string{},
|
||||||
|
|||||||
@@ -95,8 +95,14 @@ func (s *WindowsUpdateScannerWUA) convertWUAUpdate(update *windowsupdate.IUpdate
|
|||||||
kbArticles := s.getKBArticles(update)
|
kbArticles := s.getKBArticles(update)
|
||||||
updateIdentity := update.Identity
|
updateIdentity := update.Identity
|
||||||
|
|
||||||
// Determine severity from categories
|
// Use MSRC severity if available (more accurate than category-based detection)
|
||||||
severity := s.determineSeverityFromCategories(update)
|
severity := s.mapMsrcSeverity(update.MsrcSeverity)
|
||||||
|
if severity == "" {
|
||||||
|
severity = s.determineSeverityFromCategories(update)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get version information with improved parsing
|
||||||
|
currentVersion, availableVersion := s.parseVersionFromTitle(title)
|
||||||
|
|
||||||
// Get version information
|
// Get version information
|
||||||
maxDownloadSize := update.MaxDownloadSize
|
maxDownloadSize := update.MaxDownloadSize
|
||||||
@@ -116,6 +122,63 @@ func (s *WindowsUpdateScannerWUA) convertWUAUpdate(update *windowsupdate.IUpdate
|
|||||||
"scan_timestamp": time.Now().Format(time.RFC3339),
|
"scan_timestamp": time.Now().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add MSRC severity if available
|
||||||
|
if update.MsrcSeverity != "" {
|
||||||
|
metadata["msrc_severity"] = update.MsrcSeverity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add security bulletin IDs (includes CVEs)
|
||||||
|
if len(update.SecurityBulletinIDs) > 0 {
|
||||||
|
metadata["security_bulletins"] = update.SecurityBulletinIDs
|
||||||
|
// Extract CVEs from security bulletins
|
||||||
|
cveList := make([]string, 0)
|
||||||
|
for _, bulletin := range update.SecurityBulletinIDs {
|
||||||
|
if strings.HasPrefix(bulletin, "CVE-") {
|
||||||
|
cveList = append(cveList, bulletin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cveList) > 0 {
|
||||||
|
metadata["cve_list"] = cveList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add deployment information
|
||||||
|
if update.LastDeploymentChangeTime != nil {
|
||||||
|
metadata["last_deployment_change"] = update.LastDeploymentChangeTime.Format(time.RFC3339)
|
||||||
|
metadata["discovered_at"] = update.LastDeploymentChangeTime.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add deadline if present
|
||||||
|
if update.Deadline != nil {
|
||||||
|
metadata["deadline"] = update.Deadline.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add flags
|
||||||
|
if update.IsMandatory {
|
||||||
|
metadata["is_mandatory"] = true
|
||||||
|
}
|
||||||
|
if update.IsBeta {
|
||||||
|
metadata["is_beta"] = true
|
||||||
|
}
|
||||||
|
if update.IsDownloaded {
|
||||||
|
metadata["is_downloaded"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add more info URLs
|
||||||
|
if len(update.MoreInfoUrls) > 0 {
|
||||||
|
metadata["more_info_urls"] = update.MoreInfoUrls
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add release notes
|
||||||
|
if update.ReleaseNotes != "" {
|
||||||
|
metadata["release_notes"] = update.ReleaseNotes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add support URL
|
||||||
|
if update.SupportUrl != "" {
|
||||||
|
metadata["support_url"] = update.SupportUrl
|
||||||
|
}
|
||||||
|
|
||||||
// Add categories if available
|
// Add categories if available
|
||||||
categories := s.getCategories(update)
|
categories := s.getCategories(update)
|
||||||
if len(categories) > 0 {
|
if len(categories) > 0 {
|
||||||
@@ -126,13 +189,18 @@ func (s *WindowsUpdateScannerWUA) convertWUAUpdate(update *windowsupdate.IUpdate
|
|||||||
PackageType: "windows_update",
|
PackageType: "windows_update",
|
||||||
PackageName: title,
|
PackageName: title,
|
||||||
PackageDescription: description,
|
PackageDescription: description,
|
||||||
CurrentVersion: "Not Installed",
|
CurrentVersion: currentVersion,
|
||||||
AvailableVersion: s.getVersionInfo(update),
|
AvailableVersion: availableVersion,
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
RepositorySource: "Microsoft Update",
|
RepositorySource: "Microsoft Update",
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add KB articles to CVE list field if present
|
||||||
|
if len(kbArticles) > 0 {
|
||||||
|
updateItem.KBID = strings.Join(kbArticles, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
// Add size information to description if available
|
// Add size information to description if available
|
||||||
if maxDownloadSize > 0 {
|
if maxDownloadSize > 0 {
|
||||||
sizeStr := s.formatFileSize(uint64(maxDownloadSize))
|
sizeStr := s.formatFileSize(uint64(maxDownloadSize))
|
||||||
@@ -271,54 +339,6 @@ func (s *WindowsUpdateScannerWUA) categorizeUpdate(title string, categories []st
|
|||||||
return "system"
|
return "system"
|
||||||
}
|
}
|
||||||
|
|
||||||
// getVersionInfo extracts version information from update
|
|
||||||
func (s *WindowsUpdateScannerWUA) getVersionInfo(update *windowsupdate.IUpdate) string {
|
|
||||||
// Try to get version from title or description
|
|
||||||
title := update.Title
|
|
||||||
description := update.Description
|
|
||||||
|
|
||||||
// Look for version patterns
|
|
||||||
title = s.extractVersionFromText(title)
|
|
||||||
if title != "" {
|
|
||||||
return title
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.extractVersionFromText(description)
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractVersionFromText extracts version information from text
|
|
||||||
func (s *WindowsUpdateScannerWUA) extractVersionFromText(text string) string {
|
|
||||||
// Common version patterns to look for
|
|
||||||
patterns := []string{
|
|
||||||
`\b\d+\.\d+\.\d+\b`, // x.y.z
|
|
||||||
`\b\d+\.\d+\b`, // x.y
|
|
||||||
`\bKB\d+\b`, // KB numbers
|
|
||||||
`\b\d{8}\b`, // 8-digit Windows build numbers
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, pattern := range patterns {
|
|
||||||
// This is a simplified version - in production you'd use regex
|
|
||||||
if strings.Contains(text, pattern) {
|
|
||||||
// For now, return a simplified extraction
|
|
||||||
if strings.Contains(text, "KB") {
|
|
||||||
return s.extractKBNumber(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractKBNumber extracts KB numbers from text
|
|
||||||
func (s *WindowsUpdateScannerWUA) extractKBNumber(text string) string {
|
|
||||||
words := strings.Fields(text)
|
|
||||||
for _, word := range words {
|
|
||||||
if strings.HasPrefix(word, "KB") && len(word) > 2 {
|
|
||||||
return word
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// getEstimatedSize gets the estimated size of the update
|
// getEstimatedSize gets the estimated size of the update
|
||||||
func (s *WindowsUpdateScannerWUA) getEstimatedSize(update *windowsupdate.IUpdate) uint64 {
|
func (s *WindowsUpdateScannerWUA) getEstimatedSize(update *windowsupdate.IUpdate) uint64 {
|
||||||
@@ -439,3 +459,95 @@ func (s *WindowsUpdateScannerWUA) determineSeverityFromHistoryEntry(entry *windo
|
|||||||
|
|
||||||
return "moderate"
|
return "moderate"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mapMsrcSeverity maps Microsoft's MSRC severity ratings to our severity levels
|
||||||
|
func (s *WindowsUpdateScannerWUA) mapMsrcSeverity(msrcSeverity string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(msrcSeverity)) {
|
||||||
|
case "critical":
|
||||||
|
return "critical"
|
||||||
|
case "important":
|
||||||
|
return "critical"
|
||||||
|
case "moderate":
|
||||||
|
return "moderate"
|
||||||
|
case "low":
|
||||||
|
return "low"
|
||||||
|
case "unspecified", "":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVersionFromTitle attempts to extract current and available version from update title
|
||||||
|
// Examples:
|
||||||
|
// "Intel Corporation - Display - 26.20.100.7584" -> ("Unknown", "26.20.100.7584")
|
||||||
|
// "2024-01 Cumulative Update for Windows 11 Version 22H2 (KB5034123)" -> ("Unknown", "KB5034123")
|
||||||
|
func (s *WindowsUpdateScannerWUA) parseVersionFromTitle(title string) (currentVersion, availableVersion string) {
|
||||||
|
currentVersion = "Unknown"
|
||||||
|
availableVersion = "Unknown"
|
||||||
|
|
||||||
|
// Pattern 1: Version at the end after last dash (common for drivers)
|
||||||
|
// Example: "Intel Corporation - Display - 26.20.100.7584"
|
||||||
|
if strings.Contains(title, " - ") {
|
||||||
|
parts := strings.Split(title, " - ")
|
||||||
|
lastPart := strings.TrimSpace(parts[len(parts)-1])
|
||||||
|
|
||||||
|
// Check if last part looks like a version (contains dots and digits)
|
||||||
|
if strings.Contains(lastPart, ".") && s.containsDigits(lastPart) {
|
||||||
|
availableVersion = lastPart
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 2: KB article in parentheses
|
||||||
|
// Example: "2024-01 Cumulative Update (KB5034123)"
|
||||||
|
if strings.Contains(title, "(KB") && strings.Contains(title, ")") {
|
||||||
|
start := strings.Index(title, "(KB")
|
||||||
|
end := strings.Index(title[start:], ")")
|
||||||
|
if end > 0 {
|
||||||
|
kbNumber := title[start+1 : start+end]
|
||||||
|
availableVersion = kbNumber
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 3: Date-based versioning
|
||||||
|
// Example: "2024-01 Security Update"
|
||||||
|
if strings.Contains(title, "202") { // Year pattern
|
||||||
|
words := strings.Fields(title)
|
||||||
|
for _, word := range words {
|
||||||
|
// Look for YYYY-MM pattern
|
||||||
|
if len(word) == 7 && word[4] == '-' && s.containsDigits(word[:4]) && s.containsDigits(word[5:]) {
|
||||||
|
availableVersion = word
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 4: Version keyword followed by number
|
||||||
|
// Example: "Feature Update to Windows 11, version 23H2"
|
||||||
|
lowerTitle := strings.ToLower(title)
|
||||||
|
if strings.Contains(lowerTitle, "version ") {
|
||||||
|
idx := strings.Index(lowerTitle, "version ")
|
||||||
|
afterVersion := title[idx+8:]
|
||||||
|
words := strings.Fields(afterVersion)
|
||||||
|
if len(words) > 0 {
|
||||||
|
// Take the first word after "version"
|
||||||
|
versionStr := strings.TrimRight(words[0], ",.")
|
||||||
|
availableVersion = versionStr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsDigits checks if a string contains any digits
|
||||||
|
func (s *WindowsUpdateScannerWUA) containsDigits(str string) bool {
|
||||||
|
for _, char := range str {
|
||||||
|
if char >= '0' && char <= '9' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ func main() {
|
|||||||
agents.POST("/:id/updates", updateHandler.ReportUpdates)
|
agents.POST("/:id/updates", updateHandler.ReportUpdates)
|
||||||
agents.POST("/:id/logs", updateHandler.ReportLog)
|
agents.POST("/:id/logs", updateHandler.ReportLog)
|
||||||
agents.POST("/:id/dependencies", updateHandler.ReportDependencies)
|
agents.POST("/:id/dependencies", updateHandler.ReportDependencies)
|
||||||
|
agents.POST("/:id/system-info", agentHandler.ReportSystemInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dashboard/Web routes (protected by web auth)
|
// Dashboard/Web routes (protected by web auth)
|
||||||
|
|||||||
@@ -454,3 +454,90 @@ func (h *AgentHandler) UnregisterAgent(c *gin.Context) {
|
|||||||
"hostname": agent.Hostname,
|
"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
|
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
|
// Automatically create installation command since no dependencies need approval
|
||||||
command := &models.AgentCommand{
|
command := &models.AgentCommand{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
@@ -489,8 +483,8 @@ func (h *UpdateHandler) ReportDependencies(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update status to installing since no approval needed
|
// Record that dependencies were checked (empty array) and transition directly to installing
|
||||||
if err := h.updateQueries.InstallUpdate(update.ID); err != nil {
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status to installing"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -561,6 +555,7 @@ func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetAllLogs retrieves logs across all agents with filtering for universal log view
|
// 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) {
|
func (h *UpdateHandler) GetAllLogs(c *gin.Context) {
|
||||||
filters := &models.LogFilters{
|
filters := &models.LogFilters{
|
||||||
Action: c.Query("action"),
|
Action: c.Query("action"),
|
||||||
@@ -589,14 +584,15 @@ func (h *UpdateHandler) GetAllLogs(c *gin.Context) {
|
|||||||
filters.Page = page
|
filters.Page = page
|
||||||
filters.PageSize = pageSize
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"logs": logs,
|
"logs": items, // Changed from "logs" to unified items for backwards compatibility
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": pageSize,
|
"page_size": pageSize,
|
||||||
|
|||||||
@@ -252,6 +252,29 @@ func (q *UpdateQueries) SetPendingDependencies(agentID uuid.UUID, packageType, p
|
|||||||
return err
|
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
|
// CreateUpdateLog inserts an update log entry
|
||||||
func (q *UpdateQueries) CreateUpdateLog(log *models.UpdateLog) error {
|
func (q *UpdateQueries) CreateUpdateLog(log *models.UpdateLog) error {
|
||||||
query := `
|
query := `
|
||||||
@@ -448,10 +471,16 @@ func (q *UpdateQueries) ListUpdatesFromState(filters *models.UpdateFilters) ([]m
|
|||||||
}
|
}
|
||||||
|
|
||||||
if filters.Status != "" {
|
if filters.Status != "" {
|
||||||
|
// Explicit status filter provided - use it
|
||||||
baseQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
baseQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||||
countQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
countQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||||
args = append(args, filters.Status)
|
args = append(args, filters.Status)
|
||||||
argIdx++
|
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
|
// Get total count
|
||||||
@@ -725,6 +754,142 @@ func (q *UpdateQueries) GetAllLogs(filters *models.LogFilters) ([]models.UpdateL
|
|||||||
return logs, total, nil
|
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
|
// GetActiveOperations returns currently running operations
|
||||||
func (q *UpdateQueries) GetActiveOperations() ([]models.ActiveOperation, error) {
|
func (q *UpdateQueries) GetActiveOperations() ([]models.ActiveOperation, error) {
|
||||||
var operations []models.ActiveOperation
|
var operations []models.ActiveOperation
|
||||||
|
|||||||
4189
aggregator-web/package-lock.json
generated
Normal file
4189
aggregator-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@
|
|||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
|
"prism-react-renderer": "^2.4.1",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
|||||||
1018
aggregator-web/src/components/ChatTimeline.tsx
Normal file
1018
aggregator-web/src/components/ChatTimeline.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@ import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { AgentSystemUpdates } from '@/components/AgentUpdates';
|
import { AgentSystemUpdates } from '@/components/AgentUpdates';
|
||||||
import HistoryTimeline from '@/components/HistoryTimeline';
|
import ChatTimeline from '@/components/ChatTimeline';
|
||||||
|
|
||||||
const Agents: React.FC = () => {
|
const Agents: React.FC = () => {
|
||||||
const { id } = useParams<{ id?: string }>();
|
const { id } = useParams<{ id?: string }>();
|
||||||
@@ -631,7 +631,7 @@ const Agents: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<HistoryTimeline agentId={selectedAgent.id} />
|
<ChatTimeline agentId={selectedAgent.id} isScopedView={true} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,72 +1,91 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
History,
|
History,
|
||||||
Calendar,
|
Search,
|
||||||
Clock,
|
RefreshCw,
|
||||||
CheckCircle,
|
|
||||||
AlertTriangle,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import HistoryTimeline from '@/components/HistoryTimeline';
|
import ChatTimeline from '@/components/ChatTimeline';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { logApi } from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const HistoryPage: React.FC = () => {
|
const HistoryPage: React.FC = () => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Debounce search query
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedSearchQuery(searchQuery);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const { data: historyData, isLoading, refetch, isFetching } = useQuery({
|
||||||
|
queryKey: ['history', { search: debouncedSearchQuery }],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (debouncedSearchQuery) {
|
||||||
|
params.search = debouncedSearchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await logApi.getAllLogs(params);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch history:', error);
|
||||||
|
toast.error('Failed to fetch history');
|
||||||
|
return { logs: [], total: 0, page: 1, page_size: 50 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 sm:px-6 lg:px-8">
|
<div className="px-4 sm:px-6 lg:px-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<History className="h-8 w-8 text-indigo-600" />
|
<div className="flex items-center space-x-3">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">History & Audit Log</h1>
|
<History className="h-8 w-8 text-indigo-600" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">History & Audit Log</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search events..."
|
||||||
|
className="pl-10 pr-4 py-2 w-64 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Complete chronological timeline of all system activities across all agents
|
Complete chronological timeline of all system activities across all agents
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
|
||||||
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">Total Activities</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">--</p>
|
|
||||||
</div>
|
|
||||||
<History className="h-8 w-8 text-indigo-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-4 rounded-lg border border-green-200 shadow-sm">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">Successful</p>
|
|
||||||
<p className="text-2xl font-bold text-green-600">--</p>
|
|
||||||
</div>
|
|
||||||
<CheckCircle className="h-8 w-8 text-green-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-4 rounded-lg border border-red-200 shadow-sm">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">Failed</p>
|
|
||||||
<p className="text-2xl font-bold text-red-600">--</p>
|
|
||||||
</div>
|
|
||||||
<AlertTriangle className="h-8 w-8 text-red-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-4 rounded-lg border border-blue-200 shadow-sm">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">Today</p>
|
|
||||||
<p className="text-2xl font-bold text-blue-600">--</p>
|
|
||||||
</div>
|
|
||||||
<Calendar className="h-8 w-8 text-blue-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
<HistoryTimeline />
|
<ChatTimeline isScopedView={false} externalSearch={debouncedSearchQuery} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
Clock,
|
||||||
Calendar,
|
|
||||||
X,
|
X,
|
||||||
Loader2,
|
Loader2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates, useRetryCommand, useCancelCommand } from '@/hooks/useUpdates';
|
import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates, useRetryCommand, useCancelCommand } from '@/hooks/useUpdates';
|
||||||
import { useRecentCommands } from '@/hooks/useCommands';
|
import { useRecentCommands } from '@/hooks/useCommands';
|
||||||
@@ -38,6 +40,8 @@ const Updates: React.FC = () => {
|
|||||||
const [severityFilter, setSeverityFilter] = useState(searchParams.get('severity') || '');
|
const [severityFilter, setSeverityFilter] = useState(searchParams.get('severity') || '');
|
||||||
const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || '');
|
const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || '');
|
||||||
const [agentFilter, setAgentFilter] = useState(searchParams.get('agent') || '');
|
const [agentFilter, setAgentFilter] = useState(searchParams.get('agent') || '');
|
||||||
|
const [sortBy, setSortBy] = useState(searchParams.get('sort_by') || '');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(searchParams.get('sort_order') as 'asc' | 'desc' || 'desc');
|
||||||
|
|
||||||
// Debounce search query to avoid API calls on every keystroke
|
// Debounce search query to avoid API calls on every keystroke
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -70,6 +74,8 @@ const Updates: React.FC = () => {
|
|||||||
if (severityFilter) params.set('severity', severityFilter);
|
if (severityFilter) params.set('severity', severityFilter);
|
||||||
if (typeFilter) params.set('type', typeFilter);
|
if (typeFilter) params.set('type', typeFilter);
|
||||||
if (agentFilter) params.set('agent', agentFilter);
|
if (agentFilter) params.set('agent', agentFilter);
|
||||||
|
if (sortBy) params.set('sort_by', sortBy);
|
||||||
|
if (sortOrder) params.set('sort_order', sortOrder);
|
||||||
if (currentPage > 1) params.set('page', currentPage.toString());
|
if (currentPage > 1) params.set('page', currentPage.toString());
|
||||||
if (pageSize !== 100) params.set('page_size', pageSize.toString());
|
if (pageSize !== 100) params.set('page_size', pageSize.toString());
|
||||||
|
|
||||||
@@ -77,7 +83,7 @@ const Updates: React.FC = () => {
|
|||||||
if (newUrl !== window.location.href) {
|
if (newUrl !== window.location.href) {
|
||||||
window.history.replaceState({}, '', newUrl);
|
window.history.replaceState({}, '', newUrl);
|
||||||
}
|
}
|
||||||
}, [debouncedSearchQuery, statusFilter, severityFilter, typeFilter, agentFilter, currentPage, pageSize]);
|
}, [debouncedSearchQuery, statusFilter, severityFilter, typeFilter, agentFilter, sortBy, sortOrder, currentPage, pageSize]);
|
||||||
|
|
||||||
// Fetch updates list
|
// Fetch updates list
|
||||||
const { data: updatesData, isPending, error } = useUpdates({
|
const { data: updatesData, isPending, error } = useUpdates({
|
||||||
@@ -86,6 +92,8 @@ const Updates: React.FC = () => {
|
|||||||
severity: severityFilter || undefined,
|
severity: severityFilter || undefined,
|
||||||
type: typeFilter || undefined,
|
type: typeFilter || undefined,
|
||||||
agent: agentFilter || undefined,
|
agent: agentFilter || undefined,
|
||||||
|
sort_by: sortBy || undefined,
|
||||||
|
sort_order: sortOrder || undefined,
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
});
|
});
|
||||||
@@ -262,6 +270,22 @@ const Updates: React.FC = () => {
|
|||||||
setStatusFilter('approved');
|
setStatusFilter('approved');
|
||||||
setSeverityFilter('');
|
setSeverityFilter('');
|
||||||
break;
|
break;
|
||||||
|
case 'installing':
|
||||||
|
setStatusFilter('installing');
|
||||||
|
setSeverityFilter('');
|
||||||
|
break;
|
||||||
|
case 'installed':
|
||||||
|
setStatusFilter('installed');
|
||||||
|
setSeverityFilter('');
|
||||||
|
break;
|
||||||
|
case 'failed':
|
||||||
|
setStatusFilter('failed');
|
||||||
|
setSeverityFilter('');
|
||||||
|
break;
|
||||||
|
case 'dependencies':
|
||||||
|
setStatusFilter('pending_dependencies');
|
||||||
|
setSeverityFilter('');
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// Clear all filters
|
// Clear all filters
|
||||||
setStatusFilter('');
|
setStatusFilter('');
|
||||||
@@ -273,35 +297,32 @@ const Updates: React.FC = () => {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group updates
|
// Handle column sorting
|
||||||
const groupUpdates = (updates: UpdatePackage[], groupBy: string) => {
|
const handleSort = (column: string) => {
|
||||||
const groups: Record<string, UpdatePackage[]> = {};
|
if (sortBy === column) {
|
||||||
|
// Toggle sort order if clicking the same column
|
||||||
updates.forEach(update => {
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||||
let key: string;
|
} else {
|
||||||
switch (groupBy) {
|
// Set new column with default desc order
|
||||||
case 'severity':
|
setSortBy(column);
|
||||||
key = update.severity;
|
setSortOrder('desc');
|
||||||
break;
|
}
|
||||||
case 'type':
|
setCurrentPage(1);
|
||||||
key = update.package_type;
|
|
||||||
break;
|
|
||||||
case 'status':
|
|
||||||
key = update.status;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
key = 'all';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!groups[key]) {
|
|
||||||
groups[key] = [];
|
|
||||||
}
|
|
||||||
groups[key].push(update);
|
|
||||||
});
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Render sort icon for column headers
|
||||||
|
const renderSortIcon = (column: string) => {
|
||||||
|
if (sortBy !== column) {
|
||||||
|
return <ArrowUpDown className="h-4 w-4 ml-1 text-gray-400" />;
|
||||||
|
}
|
||||||
|
return sortOrder === 'asc' ? (
|
||||||
|
<ArrowUp className="h-4 w-4 ml-1 text-primary-600" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="h-4 w-4 ml-1 text-primary-600" />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Get total statistics from API (not just current page)
|
// Get total statistics from API (not just current page)
|
||||||
const totalStats = {
|
const totalStats = {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
@@ -890,8 +911,9 @@ const Updates: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Statistics Cards - Show total counts across all updates */}
|
{/* Statistics Cards - Compact design with combined visual boxes */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
{/* Total Updates - Standalone */}
|
||||||
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -902,43 +924,51 @@ const Updates: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-4 rounded-lg border border-orange-200 shadow-sm">
|
{/* Approved / Pending - Combined with divider */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||||
<div>
|
<div className="flex items-center justify-between divide-x divide-gray-200">
|
||||||
<p className="text-sm font-medium text-gray-600">Pending</p>
|
<div className="flex-1 pr-4">
|
||||||
<p className="text-2xl font-bold text-orange-600">{totalStats.pending}</p>
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-600">Approved</p>
|
||||||
|
<p className="text-xl font-bold text-green-600">{totalStats.approved}</p>
|
||||||
|
</div>
|
||||||
|
<CheckCircle className="h-6 w-6 text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 pl-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-600">Pending</p>
|
||||||
|
<p className="text-xl font-bold text-orange-600">{totalStats.pending}</p>
|
||||||
|
</div>
|
||||||
|
<Clock className="h-6 w-6 text-orange-400" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Clock className="h-8 w-8 text-orange-400" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-4 rounded-lg border border-green-200 shadow-sm">
|
{/* Critical / High Priority - Combined with divider */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||||
<div>
|
<div className="flex items-center justify-between divide-x divide-gray-200">
|
||||||
<p className="text-sm font-medium text-gray-600">Approved</p>
|
<div className="flex-1 pr-4">
|
||||||
<p className="text-2xl font-bold text-green-600">{totalStats.approved}</p>
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-600">Critical</p>
|
||||||
|
<p className="text-xl font-bold text-red-600">{totalStats.critical}</p>
|
||||||
|
</div>
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-400" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CheckCircle className="h-8 w-8 text-green-400" />
|
<div className="flex-1 pl-4">
|
||||||
</div>
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-600">High Priority</p>
|
||||||
<div className="bg-white p-4 rounded-lg border border-red-200 shadow-sm">
|
<p className="text-xl font-bold text-yellow-600">{totalStats.high}</p>
|
||||||
<div className="flex items-center justify-between">
|
</div>
|
||||||
<div>
|
<AlertTriangle className="h-6 w-6 text-yellow-400" />
|
||||||
<p className="text-sm font-medium text-gray-600">Critical</p>
|
</div>
|
||||||
<p className="text-2xl font-bold text-red-600">{totalStats.critical}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<AlertTriangle className="h-8 w-8 text-red-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-4 rounded-lg border border-yellow-200 shadow-sm">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">High Priority</p>
|
|
||||||
<p className="text-2xl font-bold text-yellow-600">{totalStats.high}</p>
|
|
||||||
</div>
|
|
||||||
<AlertTriangle className="h-8 w-8 text-yellow-400" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -992,6 +1022,54 @@ const Updates: React.FC = () => {
|
|||||||
<CheckCircle className="h-4 w-4 mr-1 inline" />
|
<CheckCircle className="h-4 w-4 mr-1 inline" />
|
||||||
Approved
|
Approved
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleQuickFilter('installing')}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||||
|
statusFilter === 'installing'
|
||||||
|
? "bg-blue-100 border-blue-300 text-blue-700"
|
||||||
|
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 inline" />
|
||||||
|
Installing
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleQuickFilter('installed')}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||||
|
statusFilter === 'installed'
|
||||||
|
? "bg-emerald-100 border-emerald-300 text-emerald-700"
|
||||||
|
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4 mr-1 inline" />
|
||||||
|
Installed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleQuickFilter('failed')}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||||
|
statusFilter === 'failed'
|
||||||
|
? "bg-red-100 border-red-300 text-red-700"
|
||||||
|
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4 mr-1 inline" />
|
||||||
|
Failed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleQuickFilter('dependencies')}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||||
|
statusFilter === 'pending_dependencies'
|
||||||
|
? "bg-amber-100 border-amber-300 text-amber-700"
|
||||||
|
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-4 w-4 mr-1 inline" />
|
||||||
|
Dependencies
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1161,13 +1239,69 @@ const Updates: React.FC = () => {
|
|||||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th className="table-header">Package</th>
|
<th className="table-header">
|
||||||
<th className="table-header">Type</th>
|
<button
|
||||||
<th className="table-header">Versions</th>
|
onClick={() => handleSort('package_name')}
|
||||||
<th className="table-header">Severity</th>
|
className="flex items-center hover:text-primary-600 font-medium"
|
||||||
<th className="table-header">Status</th>
|
>
|
||||||
<th className="table-header">Agent</th>
|
Package
|
||||||
<th className="table-header">Discovered</th>
|
{renderSortIcon('package_name')}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="table-header">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('package_type')}
|
||||||
|
className="flex items-center hover:text-primary-600 font-medium"
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
{renderSortIcon('package_type')}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="table-header">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('available_version')}
|
||||||
|
className="flex items-center hover:text-primary-600 font-medium"
|
||||||
|
>
|
||||||
|
Versions
|
||||||
|
{renderSortIcon('available_version')}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="table-header">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('severity')}
|
||||||
|
className="flex items-center hover:text-primary-600 font-medium"
|
||||||
|
>
|
||||||
|
Severity
|
||||||
|
{renderSortIcon('severity')}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="table-header">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('status')}
|
||||||
|
className="flex items-center hover:text-primary-600 font-medium"
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
{renderSortIcon('status')}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="table-header">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('agent_id')}
|
||||||
|
className="flex items-center hover:text-primary-600 font-medium"
|
||||||
|
>
|
||||||
|
Agent
|
||||||
|
{renderSortIcon('agent_id')}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="table-header">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('created_at')}
|
||||||
|
className="flex items-center hover:text-primary-600 font-medium"
|
||||||
|
>
|
||||||
|
Discovered
|
||||||
|
{renderSortIcon('created_at')}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
<th className="table-header">Actions</th>
|
<th className="table-header">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1185,11 +1319,12 @@ const Updates: React.FC = () => {
|
|||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<span className="text-xl">{getPackageTypeIcon(update.package_type)}</span>
|
<span className="text-xl">{getPackageTypeIcon(update.package_type)}</span>
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-medium text-gray-900">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/updates/${update.id}`)}
|
onClick={() => navigate(`/updates/${update.id}`)}
|
||||||
className="hover:text-primary-600"
|
className="hover:text-primary-600 truncate block max-w-xs"
|
||||||
|
title={update.package_name}
|
||||||
>
|
>
|
||||||
{update.package_name}
|
{update.package_name}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export interface UpdatePackage {
|
|||||||
current_version: string;
|
current_version: string;
|
||||||
available_version: string;
|
available_version: string;
|
||||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||||
status: 'pending' | 'approved' | 'scheduled' | 'installing' | 'installed' | 'failed';
|
status: 'pending' | 'approved' | 'scheduled' | 'installing' | 'installed' | 'failed' | 'checking_dependencies' | 'pending_dependencies';
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
approved_at: string | null;
|
approved_at: string | null;
|
||||||
@@ -248,6 +248,7 @@ export interface ScanRequest {
|
|||||||
// Query parameters
|
// Query parameters
|
||||||
export interface ListQueryParams {
|
export interface ListQueryParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
severity?: string;
|
severity?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user