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 |
|
||||
|
||||
| Linux Agent Details | Windows Agent Details | History & Audit |
|
||||
|-------------------|---------------------|----------------|
|
||||
|  |  |  |
|
||||
| Linux system specs and updates | Windows Updates and Winget support | Complete audit trail of activities |
|
||||
| Linux Agent Details | Windows Agent Details |
|
||||
|-------------------|---------------------|
|
||||
|  |  |
|
||||
| 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 |
|
||||
|-----------------|------------------|
|
||||
|
||||
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 (
|
||||
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
|
||||
|
||||
@@ -54,9 +54,17 @@ func (i *WindowsUpdateInstaller) installUpdates(packageNames []string, isDryRun
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Determine action type
|
||||
action := "install"
|
||||
if packageNames == nil {
|
||||
action = "upgrade" // Upgrade all updates
|
||||
}
|
||||
|
||||
result := &InstallResult{
|
||||
Success: false,
|
||||
IsDryRun: isDryRun,
|
||||
Action: action,
|
||||
DurationSeconds: 0,
|
||||
PackagesInstalled: []string{},
|
||||
Dependencies: []string{},
|
||||
|
||||
@@ -95,8 +95,14 @@ func (s *WindowsUpdateScannerWUA) convertWUAUpdate(update *windowsupdate.IUpdate
|
||||
kbArticles := s.getKBArticles(update)
|
||||
updateIdentity := update.Identity
|
||||
|
||||
// Determine severity from categories
|
||||
severity := s.determineSeverityFromCategories(update)
|
||||
// Use MSRC severity if available (more accurate than category-based detection)
|
||||
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
|
||||
maxDownloadSize := update.MaxDownloadSize
|
||||
@@ -116,6 +122,63 @@ func (s *WindowsUpdateScannerWUA) convertWUAUpdate(update *windowsupdate.IUpdate
|
||||
"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
|
||||
categories := s.getCategories(update)
|
||||
if len(categories) > 0 {
|
||||
@@ -126,13 +189,18 @@ func (s *WindowsUpdateScannerWUA) convertWUAUpdate(update *windowsupdate.IUpdate
|
||||
PackageType: "windows_update",
|
||||
PackageName: title,
|
||||
PackageDescription: description,
|
||||
CurrentVersion: "Not Installed",
|
||||
AvailableVersion: s.getVersionInfo(update),
|
||||
CurrentVersion: currentVersion,
|
||||
AvailableVersion: availableVersion,
|
||||
Severity: severity,
|
||||
RepositorySource: "Microsoft Update",
|
||||
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
|
||||
if maxDownloadSize > 0 {
|
||||
sizeStr := s.formatFileSize(uint64(maxDownloadSize))
|
||||
@@ -271,54 +339,6 @@ func (s *WindowsUpdateScannerWUA) categorizeUpdate(title string, categories []st
|
||||
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
|
||||
func (s *WindowsUpdateScannerWUA) getEstimatedSize(update *windowsupdate.IUpdate) uint64 {
|
||||
@@ -438,4 +458,96 @@ func (s *WindowsUpdateScannerWUA) determineSeverityFromHistoryEntry(entry *windo
|
||||
}
|
||||
|
||||
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/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
|
||||
|
||||
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",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.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 toast from 'react-hot-toast';
|
||||
import { AgentSystemUpdates } from '@/components/AgentUpdates';
|
||||
import HistoryTimeline from '@/components/HistoryTimeline';
|
||||
import ChatTimeline from '@/components/ChatTimeline';
|
||||
|
||||
const Agents: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
@@ -631,7 +631,7 @@ const Agents: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<HistoryTimeline agentId={selectedAgent.id} />
|
||||
<ChatTimeline agentId={selectedAgent.id} isScopedView={true} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,72 +1,91 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
History,
|
||||
Calendar,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Search,
|
||||
RefreshCw,
|
||||
} 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 [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 (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<History className="h-8 w-8 text-indigo-600" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">History & Audit Log</h1>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<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>
|
||||
<p className="text-gray-600">
|
||||
Complete chronological timeline of all system activities across all agents
|
||||
</p>
|
||||
</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 */}
|
||||
<HistoryTimeline />
|
||||
<ChatTimeline isScopedView={false} externalSearch={debouncedSearchQuery} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
ChevronRight,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Calendar,
|
||||
X,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from 'lucide-react';
|
||||
import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates, useRetryCommand, useCancelCommand } from '@/hooks/useUpdates';
|
||||
import { useRecentCommands } from '@/hooks/useCommands';
|
||||
@@ -38,6 +40,8 @@ const Updates: React.FC = () => {
|
||||
const [severityFilter, setSeverityFilter] = useState(searchParams.get('severity') || '');
|
||||
const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || '');
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -70,6 +74,8 @@ const Updates: React.FC = () => {
|
||||
if (severityFilter) params.set('severity', severityFilter);
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
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 (pageSize !== 100) params.set('page_size', pageSize.toString());
|
||||
|
||||
@@ -77,7 +83,7 @@ const Updates: React.FC = () => {
|
||||
if (newUrl !== window.location.href) {
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
}, [debouncedSearchQuery, statusFilter, severityFilter, typeFilter, agentFilter, currentPage, pageSize]);
|
||||
}, [debouncedSearchQuery, statusFilter, severityFilter, typeFilter, agentFilter, sortBy, sortOrder, currentPage, pageSize]);
|
||||
|
||||
// Fetch updates list
|
||||
const { data: updatesData, isPending, error } = useUpdates({
|
||||
@@ -86,6 +92,8 @@ const Updates: React.FC = () => {
|
||||
severity: severityFilter || undefined,
|
||||
type: typeFilter || undefined,
|
||||
agent: agentFilter || undefined,
|
||||
sort_by: sortBy || undefined,
|
||||
sort_order: sortOrder || undefined,
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
});
|
||||
@@ -262,6 +270,22 @@ const Updates: React.FC = () => {
|
||||
setStatusFilter('approved');
|
||||
setSeverityFilter('');
|
||||
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:
|
||||
// Clear all filters
|
||||
setStatusFilter('');
|
||||
@@ -273,35 +297,32 @@ const Updates: React.FC = () => {
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Group updates
|
||||
const groupUpdates = (updates: UpdatePackage[], groupBy: string) => {
|
||||
const groups: Record<string, UpdatePackage[]> = {};
|
||||
|
||||
updates.forEach(update => {
|
||||
let key: string;
|
||||
switch (groupBy) {
|
||||
case 'severity':
|
||||
key = update.severity;
|
||||
break;
|
||||
case 'type':
|
||||
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;
|
||||
// Handle column sorting
|
||||
const handleSort = (column: string) => {
|
||||
if (sortBy === column) {
|
||||
// Toggle sort order if clicking the same column
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
// Set new column with default desc order
|
||||
setSortBy(column);
|
||||
setSortOrder('desc');
|
||||
}
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 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)
|
||||
const totalStats = {
|
||||
total: totalCount,
|
||||
@@ -890,8 +911,9 @@ const Updates: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards - Show total counts across all updates */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
||||
{/* Statistics Cards - Compact design with combined visual boxes */}
|
||||
<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="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -902,43 +924,51 @@ const Updates: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-orange-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Pending</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{totalStats.pending}</p>
|
||||
{/* Approved / Pending - Combined with divider */}
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between divide-x divide-gray-200">
|
||||
<div className="flex-1 pr-4">
|
||||
<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>
|
||||
<Clock className="h-8 w-8 text-orange-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">Approved</p>
|
||||
<p className="text-2xl font-bold text-green-600">{totalStats.approved}</p>
|
||||
{/* Critical / High Priority - Combined with divider */}
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between divide-x divide-gray-200">
|
||||
<div className="flex-1 pr-4">
|
||||
<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>
|
||||
<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">Critical</p>
|
||||
<p className="text-2xl font-bold text-red-600">{totalStats.critical}</p>
|
||||
<div className="flex-1 pl-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-600">High Priority</p>
|
||||
<p className="text-xl font-bold text-yellow-600">{totalStats.high}</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-6 w-6 text-yellow-400" />
|
||||
</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>
|
||||
@@ -992,6 +1022,54 @@ const Updates: React.FC = () => {
|
||||
<CheckCircle className="h-4 w-4 mr-1 inline" />
|
||||
Approved
|
||||
</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>
|
||||
|
||||
@@ -1161,13 +1239,69 @@ const Updates: React.FC = () => {
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header">Package</th>
|
||||
<th className="table-header">Type</th>
|
||||
<th className="table-header">Versions</th>
|
||||
<th className="table-header">Severity</th>
|
||||
<th className="table-header">Status</th>
|
||||
<th className="table-header">Agent</th>
|
||||
<th className="table-header">Discovered</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('package_name')}
|
||||
className="flex items-center hover:text-primary-600 font-medium"
|
||||
>
|
||||
Package
|
||||
{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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1185,11 +1319,12 @@ const Updates: React.FC = () => {
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-3">
|
||||
<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">
|
||||
<button
|
||||
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}
|
||||
</button>
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface UpdatePackage {
|
||||
current_version: string;
|
||||
available_version: string;
|
||||
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;
|
||||
updated_at: string;
|
||||
approved_at: string | null;
|
||||
@@ -248,6 +248,7 @@ export interface ScanRequest {
|
||||
// Query parameters
|
||||
export interface ListQueryParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
severity?: string;
|
||||
|
||||
Reference in New Issue
Block a user