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:
Fimeg
2025-10-17 22:40:40 -04:00
parent 4ef5216c89
commit d1c5cb9597
17 changed files with 5933 additions and 195 deletions

View File

@@ -57,10 +57,15 @@ A self-hosted, cross-platform update management platform built with:
| ![Main Dashboard](Screenshots/RedFlag%20Default%20Dashboard.png) | ![Updates Dashboard](Screenshots/RedFlag%20Updates%20Dashboard.png) | ![Agent List](Screenshots/RedFlag%20Agent%20List.png) |
| System overview with metrics | Update approval with dependency workflow | Cross-platform agent management |
| Linux Agent Details | Windows Agent Details | History & Audit |
|-------------------|---------------------|----------------|
| ![Linux Agent Details](Screenshots/RedFlag%20Linux%20Agent%20Details.png) | ![Windows Agent Details](Screenshots/RedFlag%20Windows%20Agent%20Details.png) | ![History Dashboard](Screenshots/RedFlag%20History%20Dashboard.png) |
| Linux system specs and updates | Windows Updates and Winget support | Complete audit trail of activities |
| Linux Agent Details | Windows Agent Details |
|-------------------|---------------------|
| ![Linux Agent Details](Screenshots/RedFlag%20Linux%20Agent%20Details.png) | ![Windows Agent Details](Screenshots/RedFlag%20Windows%20Agent%20Details.png) |
| Linux system specs and updates | Windows Updates and Winget support |
| History & Audit | Windows Agent History |
|----------------|----------------------|
| ![History Dashboard](Screenshots/RedFlag%20History%20Dashboard.png) | ![Windows Agent History](Screenshots/RedFlag%20Windows%20Agent%20History%20.png) |
| 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

View File

@@ -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

View File

@@ -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{},

View File

@@ -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 {
@@ -439,3 +459,95 @@ 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
}

View File

@@ -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)

View File

@@ -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"})
}

View File

@@ -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,

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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">
<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>
);
};

View File

@@ -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';
// 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');
}
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(update);
});
return groups;
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">
{/* 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-sm font-medium text-gray-600">Pending</p>
<p className="text-2xl font-bold text-orange-600">{totalStats.pending}</p>
<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">
{/* 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-sm font-medium text-gray-600">Approved</p>
<p className="text-2xl font-bold text-green-600">{totalStats.approved}</p>
<p className="text-xs font-medium text-gray-600">Critical</p>
<p className="text-xl font-bold text-red-600">{totalStats.critical}</p>
</div>
<CheckCircle className="h-8 w-8 text-green-400" />
<AlertTriangle className="h-6 w-6 text-red-400" />
</div>
</div>
<div className="bg-white p-4 rounded-lg border border-red-200 shadow-sm">
<div className="flex-1 pl-4">
<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>
<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-8 w-8 text-red-400" />
<AlertTriangle className="h-6 w-6 text-yellow-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>

View File

@@ -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;