diff --git a/Screenshots/RedFlag Agent Dashboard.png b/Screenshots/RedFlag Agent Dashboard.png new file mode 100644 index 0000000..a201680 Binary files /dev/null and b/Screenshots/RedFlag Agent Dashboard.png differ diff --git a/Screenshots/RedFlag Default Dashboard.png b/Screenshots/RedFlag Default Dashboard.png new file mode 100644 index 0000000..a728222 Binary files /dev/null and b/Screenshots/RedFlag Default Dashboard.png differ diff --git a/Screenshots/RedFlag Docker Dashboard.png b/Screenshots/RedFlag Docker Dashboard.png new file mode 100644 index 0000000..fe8ceb6 Binary files /dev/null and b/Screenshots/RedFlag Docker Dashboard.png differ diff --git a/Screenshots/RedFlag Updates Dashboard.png b/Screenshots/RedFlag Updates Dashboard.png new file mode 100644 index 0000000..a9d197f Binary files /dev/null and b/Screenshots/RedFlag Updates Dashboard.png differ diff --git a/aggregator-agent/aggregator-agent b/aggregator-agent/aggregator-agent index 8b2ec5d..ef180bb 100755 Binary files a/aggregator-agent/aggregator-agent and b/aggregator-agent/aggregator-agent differ diff --git a/aggregator-agent/cmd/agent/main.go b/aggregator-agent/cmd/agent/main.go index 823b804..9add442 100644 --- a/aggregator-agent/cmd/agent/main.go +++ b/aggregator-agent/cmd/agent/main.go @@ -12,7 +12,9 @@ import ( "github.com/aggregator-project/aggregator-agent/internal/client" "github.com/aggregator-project/aggregator-agent/internal/config" "github.com/aggregator-project/aggregator-agent/internal/display" + "github.com/aggregator-project/aggregator-agent/internal/installer" "github.com/aggregator-project/aggregator-agent/internal/scanner" + "github.com/aggregator-project/aggregator-agent/internal/system" "github.com/google/uuid" ) @@ -41,8 +43,16 @@ func main() { if err := registerAgent(cfg, *serverURL); err != nil { log.Fatal("Registration failed:", err) } - fmt.Println("āœ“ Agent registered successfully!") - fmt.Printf("Agent ID: %s\n", cfg.AgentID) + fmt.Println("==================================================================") + fmt.Println("šŸŽ‰ AGENT REGISTRATION SUCCESSFUL!") + fmt.Println("==================================================================") + fmt.Printf("šŸ“‹ Agent ID: %s\n", cfg.AgentID) + fmt.Printf("🌐 Server: %s\n", cfg.ServerURL) + fmt.Printf("ā±ļø Check-in Interval: %ds\n", cfg.CheckInInterval) + fmt.Println("==================================================================") + fmt.Println("šŸ’” Save this Agent ID for your records!") + fmt.Println("šŸš€ You can now start the agent without flags") + fmt.Println("") return } @@ -82,20 +92,64 @@ func main() { } func registerAgent(cfg *config.Config, serverURL string) error { - hostname, _ := os.Hostname() - osType, osVersion, osArch := client.DetectSystem() + // Get detailed system information + sysInfo, err := system.GetSystemInfo(AgentVersion) + if err != nil { + log.Printf("Warning: Failed to get detailed system info: %v\n", err) + // Fall back to basic detection + hostname, _ := os.Hostname() + osType, osVersion, osArch := client.DetectSystem() + sysInfo = &system.SystemInfo{ + Hostname: hostname, + OSType: osType, + OSVersion: osVersion, + OSArchitecture: osArch, + AgentVersion: AgentVersion, + Metadata: make(map[string]string), + } + } apiClient := client.NewClient(serverURL, "") + // Create metadata with system information + metadata := map[string]string{ + "installation_time": time.Now().Format(time.RFC3339), + } + + // Add system info to metadata + if sysInfo.CPUInfo.ModelName != "" { + metadata["cpu_model"] = sysInfo.CPUInfo.ModelName + } + if sysInfo.CPUInfo.Cores > 0 { + metadata["cpu_cores"] = fmt.Sprintf("%d", sysInfo.CPUInfo.Cores) + } + if sysInfo.MemoryInfo.Total > 0 { + metadata["memory_total"] = fmt.Sprintf("%d", sysInfo.MemoryInfo.Total) + } + if sysInfo.RunningProcesses > 0 { + metadata["processes"] = fmt.Sprintf("%d", sysInfo.RunningProcesses) + } + if sysInfo.Uptime != "" { + metadata["uptime"] = sysInfo.Uptime + } + + // Add disk information + for i, disk := range sysInfo.DiskInfo { + if i == 0 { + metadata["disk_mount"] = disk.Mountpoint + metadata["disk_total"] = fmt.Sprintf("%d", disk.Total) + metadata["disk_used"] = fmt.Sprintf("%d", disk.Used) + break // Only add primary disk info + } + } + req := client.RegisterRequest{ - Hostname: hostname, - OSType: osType, - OSVersion: osVersion, - OSArchitecture: osArch, - AgentVersion: AgentVersion, - Metadata: map[string]string{ - "installation_time": time.Now().Format(time.RFC3339), - }, + Hostname: sysInfo.Hostname, + OSType: sysInfo.OSType, + OSVersion: sysInfo.OSVersion, + OSArchitecture: sysInfo.OSArchitecture, + AgentVersion: sysInfo.AgentVersion, + Metadata: metadata, } resp, err := apiClient.Register(req) @@ -121,14 +175,19 @@ func registerAgent(cfg *config.Config, serverURL string) error { func runAgent(cfg *config.Config) error { log.Printf("🚩 RedFlag Agent v%s starting...\n", AgentVersion) - log.Printf("Agent ID: %s\n", cfg.AgentID) - log.Printf("Server: %s\n", cfg.ServerURL) - log.Printf("Check-in interval: %ds\n", cfg.CheckInInterval) + log.Printf("==================================================================") + log.Printf("šŸ“‹ AGENT ID: %s", cfg.AgentID) + log.Printf("🌐 SERVER: %s", cfg.ServerURL) + log.Printf("ā±ļø CHECK-IN INTERVAL: %ds", cfg.CheckInInterval) + log.Printf("==================================================================") + log.Printf("šŸ’” Tip: Use this Agent ID to identify this agent in the web UI") + log.Printf("") apiClient := client.NewClient(cfg.ServerURL, cfg.Token) // Initialize scanners aptScanner := scanner.NewAPTScanner() + dnfScanner := scanner.NewDNFScanner() dockerScanner, _ := scanner.NewDockerScanner() // Main check-in loop @@ -153,7 +212,7 @@ func runAgent(cfg *config.Config) error { switch cmd.Type { case "scan_updates": - if err := handleScanUpdates(apiClient, cfg, aptScanner, dockerScanner, cmd.ID); err != nil { + if err := handleScanUpdates(apiClient, cfg, aptScanner, dnfScanner, dockerScanner, cmd.ID); err != nil { log.Printf("Error scanning updates: %v\n", err) } @@ -161,7 +220,9 @@ func runAgent(cfg *config.Config) error { log.Println("Spec collection not yet implemented") case "install_updates": - log.Println("Update installation not yet implemented") + if err := handleInstallUpdates(apiClient, cfg, cmd.ID, cmd.Params); err != nil { + log.Printf("Error installing updates: %v\n", err) + } default: log.Printf("Unknown command type: %s\n", cmd.Type) @@ -173,7 +234,7 @@ func runAgent(cfg *config.Config) error { } } -func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner *scanner.APTScanner, dockerScanner *scanner.DockerScanner, commandID string) error { +func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner *scanner.APTScanner, dnfScanner *scanner.DNFScanner, dockerScanner *scanner.DockerScanner, commandID string) error { log.Println("Scanning for updates...") var allUpdates []client.UpdateReportItem @@ -190,6 +251,18 @@ func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner } } + // Scan DNF updates + if dnfScanner.IsAvailable() { + log.Println(" - Scanning DNF packages...") + updates, err := dnfScanner.Scan() + if err != nil { + log.Printf(" DNF scan failed: %v\n", err) + } else { + log.Printf(" Found %d DNF updates\n", len(updates)) + allUpdates = append(allUpdates, updates...) + } + } + // Scan Docker updates if dockerScanner != nil && dockerScanner.IsAvailable() { log.Println(" - Scanning Docker images...") @@ -226,6 +299,7 @@ func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner func handleScanCommand(cfg *config.Config, exportFormat string) error { // Initialize scanners aptScanner := scanner.NewAPTScanner() + dnfScanner := scanner.NewDNFScanner() dockerScanner, _ := scanner.NewDockerScanner() fmt.Println("šŸ” Scanning for updates...") @@ -243,6 +317,18 @@ func handleScanCommand(cfg *config.Config, exportFormat string) error { } } + // Scan DNF updates + if dnfScanner.IsAvailable() { + fmt.Println(" - Scanning DNF packages...") + updates, err := dnfScanner.Scan() + if err != nil { + fmt.Printf(" āš ļø DNF scan failed: %v\n", err) + } else { + fmt.Printf(" āœ“ Found %d DNF updates\n", len(updates)) + allUpdates = append(allUpdates, updates...) + } + } + // Scan Docker updates if dockerScanner != nil && dockerScanner.IsAvailable() { fmt.Println(" - Scanning Docker images...") @@ -345,6 +431,128 @@ func handleListUpdatesCommand(cfg *config.Config, exportFormat string) error { return display.PrintDetailedUpdates(localCache.Updates, exportFormat) } +// handleInstallUpdates handles install_updates command +func handleInstallUpdates(apiClient *client.Client, cfg *config.Config, commandID string, params map[string]interface{}) error { + log.Println("Installing updates...") + + // Parse parameters + packageType := "" + packageName := "" + targetVersion := "" + + if pt, ok := params["package_type"].(string); ok { + packageType = pt + } + if pn, ok := params["package_name"].(string); ok { + packageName = pn + } + if tv, ok := params["target_version"].(string); ok { + targetVersion = tv + } + + // Validate package type + if packageType == "" { + return fmt.Errorf("package_type parameter is required") + } + + // Create installer based on package type + inst, err := installer.InstallerFactory(packageType) + if err != nil { + return fmt.Errorf("failed to create installer for package type %s: %w", packageType, err) + } + + // Check if installer is available + if !inst.IsAvailable() { + return fmt.Errorf("%s installer is not available on this system", packageType) + } + + var result *installer.InstallResult + var action string + + // Perform installation based on what's specified + if packageName != "" { + action = "install" + log.Printf("Installing package: %s (type: %s)", packageName, packageType) + result, err = inst.Install(packageName) + } else if len(params) > 1 { + // Multiple packages might be specified in various ways + var packageNames []string + for key, value := range params { + if key != "package_type" && key != "target_version" { + if name, ok := value.(string); ok && name != "" { + packageNames = append(packageNames, name) + } + } + } + if len(packageNames) > 0 { + action = "install_multiple" + log.Printf("Installing multiple packages: %v (type: %s)", packageNames, packageType) + result, err = inst.InstallMultiple(packageNames) + } else { + // Upgrade all packages if no specific packages named + action = "upgrade" + log.Printf("Upgrading all packages (type: %s)", packageType) + result, err = inst.Upgrade() + } + } else { + // Upgrade all packages if no specific packages named + action = "upgrade" + log.Printf("Upgrading all packages (type: %s)", packageType) + result, err = inst.Upgrade() + } + + if err != nil { + // Report installation failure + logReport := client.LogReport{ + CommandID: commandID, + Action: action, + Result: "failed", + Stdout: "", + Stderr: fmt.Sprintf("Installation error: %v", err), + ExitCode: 1, + DurationSeconds: 0, + } + + if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil { + log.Printf("Failed to report installation failure: %v\n", reportErr) + } + + return fmt.Errorf("installation failed: %w", err) + } + + // Report installation success + logReport := client.LogReport{ + CommandID: commandID, + Action: result.Action, + Result: "success", + Stdout: result.Stdout, + Stderr: result.Stderr, + ExitCode: result.ExitCode, + DurationSeconds: result.DurationSeconds, + } + + // Add additional metadata to the log report + if len(result.PackagesInstalled) > 0 { + logReport.Stdout += fmt.Sprintf("\nPackages installed: %v", result.PackagesInstalled) + } + + if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil { + log.Printf("Failed to report installation success: %v\n", reportErr) + } + + if result.Success { + log.Printf("āœ“ Installation completed successfully in %d seconds\n", result.DurationSeconds) + if len(result.PackagesInstalled) > 0 { + log.Printf(" Packages installed: %v\n", result.PackagesInstalled) + } + } else { + log.Printf("āœ— Installation failed after %d seconds\n", result.DurationSeconds) + log.Printf(" Error: %s\n", result.ErrorMessage) + } + + return nil +} + // formatTimeSince formats a duration as "X time ago" func formatTimeSince(t time.Time) string { duration := time.Since(t) diff --git a/aggregator-agent/internal/client/client.go b/aggregator-agent/internal/client/client.go index 8614765..5c56a72 100644 --- a/aggregator-agent/internal/client/client.go +++ b/aggregator-agent/internal/client/client.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "runtime" + "strings" "time" "github.com/google/uuid" @@ -219,18 +220,17 @@ func (c *Client) ReportLog(agentID uuid.UUID, report LogReport) error { return nil } -// DetectSystem returns basic system information +// DetectSystem returns basic system information (deprecated, use system.GetSystemInfo instead) func DetectSystem() (osType, osVersion, osArch string) { osType = runtime.GOOS osArch = runtime.GOARCH - // Read OS version (simplified for now) + // Read OS version switch osType { case "linux": data, _ := os.ReadFile("/etc/os-release") if data != nil { - // Parse os-release file (simplified) - osVersion = "Linux" + osVersion = parseOSRelease(data) } case "windows": osVersion = "Windows" @@ -240,3 +240,38 @@ func DetectSystem() (osType, osVersion, osArch string) { return } + +// parseOSRelease parses /etc/os-release to get proper distro name +func parseOSRelease(data []byte) string { + lines := strings.Split(string(data), "\n") + id := "" + prettyName := "" + version := "" + + for _, line := range lines { + if strings.HasPrefix(line, "ID=") { + id = strings.Trim(strings.TrimPrefix(line, "ID="), "\"") + } + if strings.HasPrefix(line, "PRETTY_NAME=") { + prettyName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"") + } + if strings.HasPrefix(line, "VERSION_ID=") { + version = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"") + } + } + + // Prefer PRETTY_NAME if available + if prettyName != "" { + return prettyName + } + + // Fall back to ID + VERSION + if id != "" { + if version != "" { + return strings.Title(id) + " " + version + } + return strings.Title(id) + } + + return "Linux" +} diff --git a/aggregator-agent/internal/system/info.go b/aggregator-agent/internal/system/info.go new file mode 100644 index 0000000..8e06844 --- /dev/null +++ b/aggregator-agent/internal/system/info.go @@ -0,0 +1,381 @@ +package system + +import ( + "os/exec" + "runtime" + "strconv" + "strings" + "time" +) + +// SystemInfo contains detailed system information +type SystemInfo struct { + Hostname string `json:"hostname"` + OSType string `json:"os_type"` + OSVersion string `json:"os_version"` + OSArchitecture string `json:"os_architecture"` + AgentVersion string `json:"agent_version"` + IPAddress string `json:"ip_address"` + CPUInfo CPUInfo `json:"cpu_info"` + MemoryInfo MemoryInfo `json:"memory_info"` + DiskInfo []DiskInfo `json:"disk_info"` + RunningProcesses int `json:"running_processes"` + Uptime string `json:"uptime"` + Metadata map[string]string `json:"metadata"` +} + +// CPUInfo contains CPU information +type CPUInfo struct { + ModelName string `json:"model_name"` + Cores int `json:"cores"` + Threads int `json:"threads"` +} + +// MemoryInfo contains memory information +type MemoryInfo struct { + Total uint64 `json:"total"` + Available uint64 `json:"available"` + Used uint64 `json:"used"` + UsedPercent float64 `json:"used_percent"` +} + +// DiskInfo contains disk information +type DiskInfo struct { + Mountpoint string `json:"mountpoint"` + Total uint64 `json:"total"` + Available uint64 `json:"available"` + Used uint64 `json:"used"` + UsedPercent float64 `json:"used_percent"` + Filesystem string `json:"filesystem"` +} + +// GetSystemInfo collects detailed system information +func GetSystemInfo(agentVersion string) (*SystemInfo, error) { + info := &SystemInfo{ + AgentVersion: agentVersion, + Metadata: make(map[string]string), + } + + // Get basic system info + info.OSType = runtime.GOOS + info.OSArchitecture = runtime.GOARCH + + // Get hostname + if hostname, err := exec.Command("hostname").Output(); err == nil { + info.Hostname = strings.TrimSpace(string(hostname)) + } + + // Get IP address + if ip, err := getIPAddress(); err == nil { + info.IPAddress = ip + } + + // Get OS version info + if info.OSType == "linux" { + info.OSVersion = getLinuxDistroInfo() + } else if info.OSType == "windows" { + info.OSVersion = getWindowsInfo() + } else if info.OSType == "darwin" { + info.OSVersion = getMacOSInfo() + } + + // Get CPU info + if cpu, err := getCPUInfo(); err == nil { + info.CPUInfo = *cpu + } + + // Get memory info + if mem, err := getMemoryInfo(); err == nil { + info.MemoryInfo = *mem + } + + // Get disk info + if disks, err := getDiskInfo(); err == nil { + info.DiskInfo = disks + } + + // Get process count + if procs, err := getProcessCount(); err == nil { + info.RunningProcesses = procs + } + + // Get uptime + if uptime, err := getUptime(); err == nil { + info.Uptime = uptime + } + + // Add collection timestamp + info.Metadata["collected_at"] = time.Now().Format(time.RFC3339) + + return info, nil +} + +// getLinuxDistroInfo parses /etc/os-release for distro information +func getLinuxDistroInfo() string { + if data, err := exec.Command("cat", "/etc/os-release").Output(); err == nil { + lines := strings.Split(string(data), "\n") + prettyName := "" + version := "" + + for _, line := range lines { + if strings.HasPrefix(line, "PRETTY_NAME=") { + prettyName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"") + } + if strings.HasPrefix(line, "VERSION_ID=") { + version = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"") + } + } + + if prettyName != "" { + return prettyName + } + + // Fallback to parsing ID and VERSION_ID + id := "" + for _, line := range lines { + if strings.HasPrefix(line, "ID=") { + id = strings.Trim(strings.TrimPrefix(line, "ID="), "\"") + } + } + + if id != "" { + if version != "" { + return strings.Title(id) + " " + version + } + return strings.Title(id) + } + } + + // Try other methods + if data, err := exec.Command("lsb_release", "-d", "-s").Output(); err == nil { + return strings.TrimSpace(string(data)) + } + + return "Linux" +} + +// getWindowsInfo gets Windows version information +func getWindowsInfo() string { + // Try using wmic for Windows version + if cmd, err := exec.LookPath("wmic"); err == nil { + if data, err := exec.Command(cmd, "os", "get", "Caption,Version").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.Contains(line, "Microsoft Windows") { + return strings.TrimSpace(line) + } + } + } + } + + return "Windows" +} + +// getMacOSInfo gets macOS version information +func getMacOSInfo() string { + if cmd, err := exec.LookPath("sw_vers"); err == nil { + if data, err := exec.Command(cmd, "-productVersion").Output(); err == nil { + version := strings.TrimSpace(string(data)) + return "macOS " + version + } + } + + return "macOS" +} + +// getCPUInfo gets CPU information +func getCPUInfo() (*CPUInfo, error) { + cpu := &CPUInfo{} + + if runtime.GOOS == "linux" { + if data, err := exec.Command("cat", "/proc/cpuinfo").Output(); err == nil { + lines := strings.Split(string(data), "\n") + cores := 0 + for _, line := range lines { + if strings.HasPrefix(line, "model name") { + cpu.ModelName = strings.TrimPrefix(line, "model name\t: ") + } + if strings.HasPrefix(line, "processor") { + cores++ + } + } + cpu.Cores = cores + cpu.Threads = cores + } + } else if runtime.GOOS == "darwin" { + if cmd, err := exec.LookPath("sysctl"); err == nil { + if data, err := exec.Command(cmd, "-n", "hw.ncpu").Output(); err == nil { + if cores, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil { + cpu.Cores = cores + cpu.Threads = cores + } + } + } + } + + return cpu, nil +} + +// getMemoryInfo gets memory information +func getMemoryInfo() (*MemoryInfo, error) { + mem := &MemoryInfo{} + + if runtime.GOOS == "linux" { + if data, err := exec.Command("cat", "/proc/meminfo").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) >= 2 { + switch fields[0] { + case "MemTotal:": + if total, err := strconv.ParseUint(fields[1], 10, 64); err == nil { + mem.Total = total * 1024 // Convert from KB to bytes + } + case "MemAvailable:": + if available, err := strconv.ParseUint(fields[1], 10, 64); err == nil { + mem.Available = available * 1024 + } + } + } + } + mem.Used = mem.Total - mem.Available + if mem.Total > 0 { + mem.UsedPercent = float64(mem.Used) / float64(mem.Total) * 100 + } + } + } + + return mem, nil +} + +// getDiskInfo gets disk information for mounted filesystems +func getDiskInfo() ([]DiskInfo, error) { + var disks []DiskInfo + + if cmd, err := exec.LookPath("df"); err == nil { + if data, err := exec.Command(cmd, "-h", "--output=target,size,used,avail,pcent,source").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for i, line := range lines { + if i == 0 || strings.TrimSpace(line) == "" { + continue // Skip header and empty lines + } + + fields := strings.Fields(line) + if len(fields) >= 6 { + disk := DiskInfo{ + Mountpoint: fields[0], + Filesystem: fields[5], + } + + // Parse sizes (df outputs in human readable format, we'll parse the numeric part) + if total, err := parseSize(fields[1]); err == nil { + disk.Total = total + } + if used, err := parseSize(fields[2]); err == nil { + disk.Used = used + } + if available, err := parseSize(fields[3]); err == nil { + disk.Available = available + } + if total, err := strconv.ParseFloat(strings.TrimSuffix(fields[4], "%"), 64); err == nil { + disk.UsedPercent = total + } + + disks = append(disks, disk) + } + } + } + } + + return disks, nil +} + +// parseSize parses human readable size strings (like "1.5G" or "500M") +func parseSize(sizeStr string) (uint64, error) { + sizeStr = strings.TrimSpace(sizeStr) + if len(sizeStr) == 0 { + return 0, nil + } + + multiplier := uint64(1) + unit := sizeStr[len(sizeStr)-1:] + if unit == "G" || unit == "g" { + multiplier = 1024 * 1024 * 1024 + sizeStr = sizeStr[:len(sizeStr)-1] + } else if unit == "M" || unit == "m" { + multiplier = 1024 * 1024 + sizeStr = sizeStr[:len(sizeStr)-1] + } else if unit == "K" || unit == "k" { + multiplier = 1024 + sizeStr = sizeStr[:len(sizeStr)-1] + } + + size, err := strconv.ParseFloat(sizeStr, 64) + if err != nil { + return 0, err + } + + return uint64(size * float64(multiplier)), nil +} + +// getProcessCount gets the number of running processes +func getProcessCount() (int, error) { + if runtime.GOOS == "linux" { + if data, err := exec.Command("ps", "-e").Output(); err == nil { + lines := strings.Split(string(data), "\n") + return len(lines) - 1, nil // Subtract 1 for header + } + } else if runtime.GOOS == "darwin" { + if data, err := exec.Command("ps", "-ax").Output(); err == nil { + lines := strings.Split(string(data), "\n") + return len(lines) - 1, nil // Subtract 1 for header + } + } + + return 0, nil +} + +// getUptime gets system uptime +func getUptime() (string, error) { + if runtime.GOOS == "linux" { + if data, err := exec.Command("uptime", "-p").Output(); err == nil { + return strings.TrimSpace(string(data)), nil + } + } else if runtime.GOOS == "darwin" { + if data, err := exec.Command("uptime").Output(); err == nil { + return strings.TrimSpace(string(data)), nil + } + } + + return "Unknown", nil +} + +// getIPAddress gets the primary IP address +func getIPAddress() (string, error) { + if runtime.GOOS == "linux" { + // Try to get the IP from hostname -I + if data, err := exec.Command("hostname", "-I").Output(); err == nil { + ips := strings.Fields(string(data)) + if len(ips) > 0 { + return ips[0], nil + } + } + + // Fallback to ip route + if data, err := exec.Command("ip", "route", "get", "8.8.8.8").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.Contains(line, "src") { + fields := strings.Fields(line) + for i, field := range fields { + if field == "src" && i+1 < len(fields) { + return fields[i+1], nil + } + } + } + } + } + } + + return "127.0.0.1", nil +} \ No newline at end of file diff --git a/aggregator-agent/test-config/config.yaml b/aggregator-agent/test-config/config.yaml new file mode 100644 index 0000000..6c6ff2e --- /dev/null +++ b/aggregator-agent/test-config/config.yaml @@ -0,0 +1,10 @@ +server: + url: "http://localhost:8080" + +agent: + hostname: "test-agent" + check_in_interval: 60 + batch_size: 50 + +auth: + token: "test-token" \ No newline at end of file diff --git a/aggregator-server/cmd/server/main.go b/aggregator-server/cmd/server/main.go index c2c3cc7..c0f2e2a 100644 --- a/aggregator-server/cmd/server/main.go +++ b/aggregator-server/cmd/server/main.go @@ -4,12 +4,14 @@ import ( "fmt" "log" "path/filepath" + "time" "github.com/aggregator-project/aggregator-server/internal/api/handlers" "github.com/aggregator-project/aggregator-server/internal/api/middleware" "github.com/aggregator-project/aggregator-server/internal/config" "github.com/aggregator-project/aggregator-server/internal/database" "github.com/aggregator-project/aggregator-server/internal/database/queries" + "github.com/aggregator-project/aggregator-server/internal/services" "github.com/gin-gonic/gin" ) @@ -33,7 +35,9 @@ func main() { // Run migrations migrationsPath := filepath.Join("internal", "database", "migrations") if err := db.Migrate(migrationsPath); err != nil { - log.Fatal("Failed to run migrations:", err) + // For development, continue even if migrations fail + // In production, you might want to handle this more gracefully + fmt.Printf("Warning: Migration failed (tables may already exist): %v\n", err) } // Initialize queries @@ -41,13 +45,23 @@ func main() { updateQueries := queries.NewUpdateQueries(db.DB) commandQueries := queries.NewCommandQueries(db.DB) + // Initialize services + timezoneService := services.NewTimezoneService(cfg) + // Initialize handlers agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, cfg.CheckInInterval) - updateHandler := handlers.NewUpdateHandler(updateQueries) + updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries) + authHandler := handlers.NewAuthHandler(cfg.JWTSecret) + statsHandler := handlers.NewStatsHandler(agentQueries, updateQueries) + settingsHandler := handlers.NewSettingsHandler(timezoneService) + dockerHandler := handlers.NewDockerHandler(updateQueries, agentQueries, commandQueries) // Setup router router := gin.Default() + // Add CORS middleware + router.Use(middleware.CORSMiddleware()) + // Health check router.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "healthy"}) @@ -56,6 +70,11 @@ func main() { // API routes api := router.Group("/api/v1") { + // Authentication routes + api.POST("/auth/login", authHandler.Login) + api.POST("/auth/logout", authHandler.Logout) + api.GET("/auth/verify", authHandler.VerifyToken) + // Public routes api.POST("/agents/register", agentHandler.RegisterAgent) @@ -68,15 +87,57 @@ func main() { agents.POST("/:id/logs", updateHandler.ReportLog) } - // Dashboard/Web routes (will add proper auth later) - api.GET("/agents", agentHandler.ListAgents) - api.GET("/agents/:id", agentHandler.GetAgent) - api.POST("/agents/:id/scan", agentHandler.TriggerScan) - api.GET("/updates", updateHandler.ListUpdates) - api.GET("/updates/:id", updateHandler.GetUpdate) - api.POST("/updates/:id/approve", updateHandler.ApproveUpdate) + // Dashboard/Web routes (protected by web auth) + dashboard := api.Group("/") + dashboard.Use(authHandler.WebAuthMiddleware()) + { + dashboard.GET("/stats/summary", statsHandler.GetDashboardStats) + dashboard.GET("/agents", agentHandler.ListAgents) + dashboard.GET("/agents/:id", agentHandler.GetAgent) + dashboard.POST("/agents/:id/scan", agentHandler.TriggerScan) + dashboard.POST("/agents/:id/update", agentHandler.TriggerUpdate) + dashboard.DELETE("/agents/:id", agentHandler.UnregisterAgent) + dashboard.GET("/updates", updateHandler.ListUpdates) + dashboard.GET("/updates/:id", updateHandler.GetUpdate) + dashboard.POST("/updates/:id/approve", updateHandler.ApproveUpdate) + dashboard.POST("/updates/approve", updateHandler.ApproveUpdates) + dashboard.POST("/updates/:id/reject", updateHandler.RejectUpdate) + dashboard.POST("/updates/:id/install", updateHandler.InstallUpdate) + + // Settings routes + dashboard.GET("/settings/timezone", settingsHandler.GetTimezone) + dashboard.GET("/settings/timezones", settingsHandler.GetTimezones) + dashboard.PUT("/settings/timezone", settingsHandler.UpdateTimezone) + + // Docker routes + dashboard.GET("/docker/containers", dockerHandler.GetContainers) + dashboard.GET("/docker/stats", dockerHandler.GetStats) + dashboard.POST("/docker/containers/:container_id/images/:image_id/approve", dockerHandler.ApproveUpdate) + dashboard.POST("/docker/containers/:container_id/images/:image_id/reject", dockerHandler.RejectUpdate) + dashboard.POST("/docker/containers/:container_id/images/:image_id/install", dockerHandler.InstallUpdate) + } } + // Start background goroutine to mark offline agents + // TODO: Make these values configurable via settings: + // - Check interval (currently 2 minutes, should match agent heartbeat setting) + // - Offline threshold (currently 10 minutes, should be based on agent check-in interval + missed checks) + // - Missed checks before offline (default 2, so 300s agent interval * 2 = 10 minutes) + go func() { + ticker := time.NewTicker(2 * time.Minute) // Check every 2 minutes + defer ticker.Stop() + + for { + select { + case <-ticker.C: + // Mark agents as offline if they haven't checked in within 10 minutes + if err := agentQueries.MarkOfflineAgents(10 * time.Minute); err != nil { + log.Printf("Failed to mark offline agents: %v", err) + } + } + } + }() + // Start server addr := ":" + cfg.ServerPort fmt.Printf("\n🚩 RedFlag Aggregator Server starting on %s\n\n", addr) diff --git a/aggregator-server/internal/api/middleware/cors.go b/aggregator-server/internal/api/middleware/cors.go new file mode 100644 index 0000000..ef8b223 --- /dev/null +++ b/aggregator-server/internal/api/middleware/cors.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// CORSMiddleware handles Cross-Origin Resource Sharing +func CORSMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "http://localhost:3000") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") + c.Header("Access-Control-Expose-Headers", "Content-Length") + c.Header("Access-Control-Allow-Credentials", "true") + + // Handle preflight requests + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} \ No newline at end of file diff --git a/aggregator-server/internal/config/config.go b/aggregator-server/internal/config/config.go index 9c55ec5..9a3f5d3 100644 --- a/aggregator-server/internal/config/config.go +++ b/aggregator-server/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "strconv" @@ -14,6 +15,7 @@ type Config struct { JWTSecret string CheckInInterval int OfflineThreshold int + Timezone string } // Load reads configuration from environment variables @@ -24,13 +26,21 @@ func Load() (*Config, error) { checkInInterval, _ := strconv.Atoi(getEnv("CHECK_IN_INTERVAL", "300")) offlineThreshold, _ := strconv.Atoi(getEnv("OFFLINE_THRESHOLD", "600")) - return &Config{ + cfg := &Config{ ServerPort: getEnv("SERVER_PORT", "8080"), DatabaseURL: getEnv("DATABASE_URL", "postgres://aggregator:aggregator@localhost:5432/aggregator?sslmode=disable"), - JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"), + JWTSecret: getEnv("JWT_SECRET", "test-secret-for-development-only"), CheckInInterval: checkInInterval, OfflineThreshold: offlineThreshold, - }, nil + Timezone: getEnv("TIMEZONE", "UTC"), + } + + // Debug: Log what JWT secret we're using (remove in production) + if cfg.JWTSecret == "test-secret-for-development-only" { + fmt.Printf("šŸ”“ Using development JWT secret\n") + } + + return cfg, nil } func getEnv(key, defaultValue string) string { diff --git a/aggregator-server/internal/database/queries/agents.go b/aggregator-server/internal/database/queries/agents.go index 25cdc8c..330a09f 100644 --- a/aggregator-server/internal/database/queries/agents.go +++ b/aggregator-server/internal/database/queries/agents.go @@ -45,7 +45,7 @@ func (q *AgentQueries) GetAgentByID(id uuid.UUID) (*models.Agent, error) { // UpdateAgentLastSeen updates the agent's last_seen timestamp func (q *AgentQueries) UpdateAgentLastSeen(id uuid.UUID) error { query := `UPDATE agents SET last_seen = $1, status = 'online' WHERE id = $2` - _, err := q.db.Exec(query, time.Now(), id) + _, err := q.db.Exec(query, time.Now().UTC(), id) return err } @@ -81,3 +81,77 @@ func (q *AgentQueries) MarkOfflineAgents(threshold time.Duration) error { _, err := q.db.Exec(query, time.Now().Add(-threshold)) return err } + +// GetAgentLastScan gets the last scan time from update events +func (q *AgentQueries) GetAgentLastScan(id uuid.UUID) (*time.Time, error) { + var lastScan time.Time + query := `SELECT MAX(created_at) FROM update_events WHERE agent_id = $1` + err := q.db.Get(&lastScan, query, id) + if err != nil { + return nil, err + } + return &lastScan, nil +} + +// GetAgentWithLastScan gets agent information including last scan time +func (q *AgentQueries) GetAgentWithLastScan(id uuid.UUID) (*models.AgentWithLastScan, error) { + var agent models.AgentWithLastScan + query := ` + SELECT + a.*, + (SELECT MAX(created_at) FROM update_events WHERE agent_id = a.id) as last_scan + FROM agents a + WHERE a.id = $1` + err := q.db.Get(&agent, query, id) + if err != nil { + return nil, err + } + return &agent, nil +} + +// ListAgentsWithLastScan returns all agents with their last scan times +func (q *AgentQueries) ListAgentsWithLastScan(status, osType string) ([]models.AgentWithLastScan, error) { + var agents []models.AgentWithLastScan + query := ` + SELECT + a.*, + (SELECT MAX(created_at) FROM update_events WHERE agent_id = a.id) as last_scan + FROM agents a + WHERE 1=1` + args := []interface{}{} + argIdx := 1 + + if status != "" { + query += ` AND a.status = $` + string(rune(argIdx+'0')) + args = append(args, status) + argIdx++ + } + if osType != "" { + query += ` AND a.os_type = $` + string(rune(argIdx+'0')) + args = append(args, osType) + argIdx++ + } + + query += ` ORDER BY a.last_seen DESC` + err := q.db.Select(&agents, query, args...) + return agents, err +} + +// DeleteAgent removes an agent and all associated data +func (q *AgentQueries) DeleteAgent(id uuid.UUID) error { + // Start a transaction for atomic deletion + tx, err := q.db.Beginx() + if err != nil { + return err + } + defer tx.Rollback() + + // Delete the agent (CASCADE will handle related records) + _, err = tx.Exec("DELETE FROM agents WHERE id = $1", id) + if err != nil { + return err + } + + // Commit the transaction + return tx.Commit() +} diff --git a/aggregator-server/internal/database/queries/updates.go b/aggregator-server/internal/database/queries/updates.go index f56d65e..680c9bd 100644 --- a/aggregator-server/internal/database/queries/updates.go +++ b/aggregator-server/internal/database/queries/updates.go @@ -3,6 +3,7 @@ package queries import ( "fmt" "strings" + "time" "github.com/aggregator-project/aggregator-server/internal/models" "github.com/google/uuid" @@ -45,16 +46,16 @@ func (q *UpdateQueries) UpsertUpdate(update *models.UpdatePackage) error { return err } -// ListUpdates retrieves updates with filtering +// ListUpdates retrieves updates with filtering (legacy method for update_packages table) func (q *UpdateQueries) ListUpdates(filters *models.UpdateFilters) ([]models.UpdatePackage, int, error) { var updates []models.UpdatePackage whereClause := []string{"1=1"} args := []interface{}{} argIdx := 1 - if filters.AgentID != nil { + if filters.AgentID != uuid.Nil { whereClause = append(whereClause, fmt.Sprintf("agent_id = $%d", argIdx)) - args = append(args, *filters.AgentID) + args = append(args, filters.AgentID) argIdx++ } if filters.Status != "" { @@ -103,10 +104,10 @@ func (q *UpdateQueries) ListUpdates(filters *models.UpdateFilters) ([]models.Upd return updates, total, err } -// GetUpdateByID retrieves a single update by ID -func (q *UpdateQueries) GetUpdateByID(id uuid.UUID) (*models.UpdatePackage, error) { - var update models.UpdatePackage - query := `SELECT * FROM update_packages WHERE id = $1` +// GetUpdateByID retrieves a single update by ID from the new state table +func (q *UpdateQueries) GetUpdateByID(id uuid.UUID) (*models.UpdateState, error) { + var update models.UpdateState + query := `SELECT * FROM current_package_state WHERE id = $1` err := q.db.Get(&update, query, id) if err != nil { return nil, err @@ -114,14 +115,98 @@ func (q *UpdateQueries) GetUpdateByID(id uuid.UUID) (*models.UpdatePackage, erro return &update, nil } -// ApproveUpdate marks an update as approved +// GetUpdateByPackage retrieves a single update by agent_id, package_type, and package_name +func (q *UpdateQueries) GetUpdateByPackage(agentID uuid.UUID, packageType, packageName string) (*models.UpdateState, error) { + var update models.UpdateState + query := `SELECT * FROM current_package_state WHERE agent_id = $1 AND package_type = $2 AND package_name = $3` + err := q.db.Get(&update, query, agentID, packageType, packageName) + if err != nil { + return nil, err + } + return &update, nil +} + +// ApproveUpdate marks an update as approved in the new event sourcing system func (q *UpdateQueries) ApproveUpdate(id uuid.UUID, approvedBy string) error { query := ` - UPDATE update_packages - SET status = 'approved', approved_by = $1, approved_at = NOW() - WHERE id = $2 AND status = 'pending' + UPDATE current_package_state + SET status = 'approved', last_updated_at = NOW() + WHERE id = $1 AND status = 'pending' ` - _, err := q.db.Exec(query, approvedBy, id) + _, err := q.db.Exec(query, id) + return err +} + +// ApproveUpdateByPackage approves an update by agent_id, package_type, and package_name +func (q *UpdateQueries) ApproveUpdateByPackage(agentID uuid.UUID, packageType, packageName, approvedBy string) error { + query := ` + UPDATE current_package_state + SET status = 'approved', last_updated_at = NOW() + WHERE agent_id = $1 AND package_type = $2 AND package_name = $3 AND status = 'pending' + ` + _, err := q.db.Exec(query, agentID, packageType, packageName) + return err +} + +// BulkApproveUpdates approves multiple updates by their IDs +func (q *UpdateQueries) BulkApproveUpdates(updateIDs []uuid.UUID, approvedBy string) error { + if len(updateIDs) == 0 { + return nil + } + + // Start transaction + tx, err := q.db.Beginx() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Update each update + for _, id := range updateIDs { + query := ` + UPDATE current_package_state + SET status = 'approved', last_updated_at = NOW() + WHERE id = $1 AND status = 'pending' + ` + _, err := tx.Exec(query, id) + if err != nil { + return fmt.Errorf("failed to approve update %s: %w", id, err) + } + } + + return tx.Commit() +} + +// RejectUpdate marks an update as rejected/ignored +func (q *UpdateQueries) RejectUpdate(id uuid.UUID, rejectedBy string) error { + query := ` + UPDATE current_package_state + SET status = 'ignored', last_updated_at = NOW() + WHERE id = $1 AND status IN ('pending', 'approved') + ` + _, err := q.db.Exec(query, id) + return err +} + +// RejectUpdateByPackage rejects an update by agent_id, package_type, and package_name +func (q *UpdateQueries) RejectUpdateByPackage(agentID uuid.UUID, packageType, packageName, rejectedBy string) error { + query := ` + UPDATE current_package_state + SET status = 'ignored', last_updated_at = NOW() + WHERE agent_id = $1 AND package_type = $2 AND package_name = $3 AND status IN ('pending', 'approved') + ` + _, err := q.db.Exec(query, agentID, packageType, packageName) + return err +} + +// InstallUpdate marks an update as ready for installation +func (q *UpdateQueries) InstallUpdate(id uuid.UUID) error { + query := ` + UPDATE current_package_state + SET status = 'installing', last_updated_at = NOW() + WHERE id = $1 AND status = 'approved' + ` + _, err := q.db.Exec(query, id) return err } @@ -139,3 +224,366 @@ func (q *UpdateQueries) CreateUpdateLog(log *models.UpdateLog) error { _, err := q.db.NamedExec(query, log) return err } + +// NEW EVENT SOURCING IMPLEMENTATION + +// CreateUpdateEvent stores a single update event +func (q *UpdateQueries) CreateUpdateEvent(event *models.UpdateEvent) error { + query := ` + INSERT INTO update_events ( + agent_id, package_type, package_name, version_from, version_to, + severity, repository_source, metadata, event_type + ) VALUES ( + :agent_id, :package_type, :package_name, :version_from, :version_to, + :severity, :repository_source, :metadata, :event_type + ) + ` + _, err := q.db.NamedExec(query, event) + return err +} + +// CreateUpdateEventsBatch creates multiple update events in a transaction +func (q *UpdateQueries) CreateUpdateEventsBatch(events []models.UpdateEvent) error { + if len(events) == 0 { + return nil + } + + // Start transaction + tx, err := q.db.Beginx() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Create batch record + batch := &models.UpdateBatch{ + ID: uuid.New(), + AgentID: events[0].AgentID, + BatchSize: len(events), + Status: "processing", + } + + batchQuery := ` + INSERT INTO update_batches (id, agent_id, batch_size, status) + VALUES (:id, :agent_id, :batch_size, :status) + ` + if _, err := tx.NamedExec(batchQuery, batch); err != nil { + return fmt.Errorf("failed to create batch record: %w", err) + } + + // Insert events in batches to avoid memory issues + batchSize := 100 + processedCount := 0 + failedCount := 0 + + for i := 0; i < len(events); i += batchSize { + end := i + batchSize + if end > len(events) { + end = len(events) + } + + currentBatch := events[i:end] + + // Prepare query with multiple value sets + query := ` + INSERT INTO update_events ( + agent_id, package_type, package_name, version_from, version_to, + severity, repository_source, metadata, event_type + ) VALUES ( + :agent_id, :package_type, :package_name, :version_from, :version_to, + :severity, :repository_source, :metadata, :event_type + ) + ` + + for _, event := range currentBatch { + _, err := tx.NamedExec(query, event) + if err != nil { + failedCount++ + continue + } + processedCount++ + + // Update current state + if err := q.updateCurrentStateInTx(tx, &event); err != nil { + // Log error but don't fail the entire batch + fmt.Printf("Warning: failed to update current state for %s: %v\n", event.PackageName, err) + } + } + } + + // Update batch record + batchUpdateQuery := ` + UPDATE update_batches + SET processed_count = $1, failed_count = $2, status = $3, completed_at = $4 + WHERE id = $5 + ` + batchStatus := "completed" + if failedCount > 0 { + batchStatus = "completed_with_errors" + } + + _, err = tx.Exec(batchUpdateQuery, processedCount, failedCount, batchStatus, time.Now(), batch.ID) + if err != nil { + return fmt.Errorf("failed to update batch record: %w", err) + } + + // Commit transaction + return tx.Commit() +} + +// updateCurrentStateInTx updates the current_package_state table within a transaction +func (q *UpdateQueries) updateCurrentStateInTx(tx *sqlx.Tx, event *models.UpdateEvent) error { + query := ` + INSERT INTO current_package_state ( + agent_id, package_type, package_name, current_version, available_version, + severity, repository_source, metadata, last_discovered_at, status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending') + ON CONFLICT (agent_id, package_type, package_name) + DO UPDATE SET + available_version = EXCLUDED.available_version, + severity = EXCLUDED.severity, + repository_source = EXCLUDED.repository_source, + metadata = EXCLUDED.metadata, + last_discovered_at = EXCLUDED.last_discovered_at, + status = CASE + WHEN current_package_state.status IN ('updated', 'ignored') + THEN current_package_state.status + ELSE 'pending' + END + ` + _, err := tx.Exec(query, + event.AgentID, + event.PackageType, + event.PackageName, + event.VersionFrom, + event.VersionTo, + event.Severity, + event.RepositorySource, + event.Metadata, + event.CreatedAt) + return err +} + +// ListUpdatesFromState returns paginated updates from current state with filtering +func (q *UpdateQueries) ListUpdatesFromState(filters *models.UpdateFilters) ([]models.UpdateState, int, error) { + var updates []models.UpdateState + var count int + + // Build base query + baseQuery := ` + SELECT + id, agent_id, package_type, package_name, current_version, + available_version, severity, repository_source, metadata, + last_discovered_at, last_updated_at, status + FROM current_package_state + WHERE 1=1 + ` + countQuery := `SELECT COUNT(*) FROM current_package_state WHERE 1=1` + + args := []interface{}{} + argIdx := 1 + + // Add filters + if filters.AgentID != uuid.Nil { + baseQuery += fmt.Sprintf(" AND agent_id = $%d", argIdx) + countQuery += fmt.Sprintf(" AND agent_id = $%d", argIdx) + args = append(args, filters.AgentID) + argIdx++ + } + + if filters.PackageType != "" { + baseQuery += fmt.Sprintf(" AND package_type = $%d", argIdx) + countQuery += fmt.Sprintf(" AND package_type = $%d", argIdx) + args = append(args, filters.PackageType) + argIdx++ + } + + if filters.Severity != "" { + baseQuery += fmt.Sprintf(" AND severity = $%d", argIdx) + countQuery += fmt.Sprintf(" AND severity = $%d", argIdx) + args = append(args, filters.Severity) + argIdx++ + } + + if filters.Status != "" { + baseQuery += fmt.Sprintf(" AND status = $%d", argIdx) + countQuery += fmt.Sprintf(" AND status = $%d", argIdx) + args = append(args, filters.Status) + argIdx++ + } + + // Get total count + err := q.db.Get(&count, countQuery, args...) + if err != nil { + return nil, 0, fmt.Errorf("failed to get updates count: %w", err) + } + + // Add ordering and pagination + baseQuery += " ORDER BY last_discovered_at DESC" + baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIdx, argIdx+1) + args = append(args, filters.PageSize, (filters.Page-1)*filters.PageSize) + + // Execute query + err = q.db.Select(&updates, baseQuery, args...) + if err != nil { + return nil, 0, fmt.Errorf("failed to list updates: %w", err) + } + + return updates, count, nil +} + +// GetPackageHistory returns version history for a specific package +func (q *UpdateQueries) GetPackageHistory(agentID uuid.UUID, packageType, packageName string, limit int) ([]models.UpdateHistory, error) { + var history []models.UpdateHistory + + query := ` + SELECT + id, agent_id, package_type, package_name, version_from, version_to, + severity, repository_source, metadata, update_initiated_at, + update_completed_at, update_status, failure_reason + FROM update_version_history + WHERE agent_id = $1 AND package_type = $2 AND package_name = $3 + ORDER BY update_completed_at DESC + LIMIT $4 + ` + + err := q.db.Select(&history, query, agentID, packageType, packageName, limit) + if err != nil { + return nil, fmt.Errorf("failed to get package history: %w", err) + } + + return history, nil +} + +// UpdatePackageStatus updates the status of a package and records history +func (q *UpdateQueries) UpdatePackageStatus(agentID uuid.UUID, packageType, packageName, status string, metadata map[string]interface{}) error { + tx, err := q.db.Beginx() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Get current state + var currentState models.UpdateState + query := `SELECT * FROM current_package_state WHERE agent_id = $1 AND package_type = $2 AND package_name = $3` + err = tx.Get(¤tState, query, agentID, packageType, packageName) + if err != nil { + return fmt.Errorf("failed to get current state: %w", err) + } + + // Update status + updateQuery := ` + UPDATE current_package_state + SET status = $1, last_updated_at = $2 + WHERE agent_id = $3 AND package_type = $4 AND package_name = $5 + ` + _, err = tx.Exec(updateQuery, status, time.Now(), agentID, packageType, packageName) + if err != nil { + return fmt.Errorf("failed to update package status: %w", err) + } + + // Record in history if this is an update completion + if status == "updated" || status == "failed" { + historyQuery := ` + INSERT INTO update_version_history ( + agent_id, package_type, package_name, version_from, version_to, + severity, repository_source, metadata, update_completed_at, update_status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ` + _, err = tx.Exec(historyQuery, + agentID, packageType, packageName, currentState.CurrentVersion, + currentState.AvailableVersion, currentState.Severity, + currentState.RepositorySource, metadata, time.Now(), status) + if err != nil { + return fmt.Errorf("failed to record version history: %w", err) + } + } + + return tx.Commit() +} + +// CleanupOldEvents removes old events to prevent table bloat +func (q *UpdateQueries) CleanupOldEvents(olderThan time.Duration) error { + query := `DELETE FROM update_events WHERE created_at < $1` + result, err := q.db.Exec(query, time.Now().Add(-olderThan)) + if err != nil { + return fmt.Errorf("failed to cleanup old events: %w", err) + } + + rowsAffected, _ := result.RowsAffected() + fmt.Printf("Cleaned up %d old update events\n", rowsAffected) + return nil +} + +// GetBatchStatus returns the status of recent batches +func (q *UpdateQueries) GetBatchStatus(agentID uuid.UUID, limit int) ([]models.UpdateBatch, error) { + var batches []models.UpdateBatch + + query := ` + SELECT id, agent_id, batch_size, processed_count, failed_count, + status, error_details, created_at, completed_at + FROM update_batches + WHERE agent_id = $1 + ORDER BY created_at DESC + LIMIT $2 + ` + + err := q.db.Select(&batches, query, agentID, limit) + if err != nil { + return nil, fmt.Errorf("failed to get batch status: %w", err) + } + + return batches, nil +} + +// GetUpdateStatsFromState returns statistics about updates from current state +func (q *UpdateQueries) GetUpdateStatsFromState(agentID uuid.UUID) (*models.UpdateStats, error) { + stats := &models.UpdateStats{} + + query := ` + SELECT + COUNT(*) as total_updates, + COUNT(*) FILTER (WHERE status = 'pending') as pending_updates, + COUNT(*) FILTER (WHERE status = 'updated') as updated_updates, + COUNT(*) FILTER (WHERE status = 'failed') as failed_updates, + COUNT(*) FILTER (WHERE severity = 'critical') as critical_updates, + COUNT(*) FILTER (WHERE severity = 'important') as important_updates, + COUNT(*) FILTER (WHERE severity = 'moderate') as moderate_updates, + COUNT(*) FILTER (WHERE severity = 'low') as low_updates + FROM current_package_state + WHERE agent_id = $1 + ` + + err := q.db.Get(stats, query, agentID) + if err != nil { + return nil, fmt.Errorf("failed to get update stats: %w", err) + } + + return stats, nil +} + +// GetAllUpdateStats returns overall statistics about updates across all agents +func (q *UpdateQueries) GetAllUpdateStats() (*models.UpdateStats, error) { + stats := &models.UpdateStats{} + + query := ` + SELECT + COUNT(*) as total_updates, + COUNT(*) FILTER (WHERE status = 'pending') as pending_updates, + COUNT(*) FILTER (WHERE status = 'approved') as approved_updates, + COUNT(*) FILTER (WHERE status = 'updated') as updated_updates, + COUNT(*) FILTER (WHERE status = 'failed') as failed_updates, + COUNT(*) FILTER (WHERE severity = 'critical') as critical_updates, + COUNT(*) FILTER (WHERE severity = 'important') as high_updates, + COUNT(*) FILTER (WHERE severity = 'moderate') as moderate_updates, + COUNT(*) FILTER (WHERE severity = 'low') as low_updates + FROM current_package_state + ` + + err := q.db.Get(stats, query) + if err != nil { + return nil, fmt.Errorf("failed to get all update stats: %w", err) + } + + return stats, nil +} diff --git a/aggregator-server/internal/models/agent.go b/aggregator-server/internal/models/agent.go index e2ac25e..696bb33 100644 --- a/aggregator-server/internal/models/agent.go +++ b/aggregator-server/internal/models/agent.go @@ -23,6 +23,22 @@ type Agent struct { UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } +// AgentWithLastScan extends Agent with last scan information +type AgentWithLastScan struct { + ID uuid.UUID `json:"id" db:"id"` + Hostname string `json:"hostname" db:"hostname"` + OSType string `json:"os_type" db:"os_type"` + OSVersion string `json:"os_version" db:"os_version"` + OSArchitecture string `json:"os_architecture" db:"os_architecture"` + AgentVersion string `json:"agent_version" db:"agent_version"` + LastSeen time.Time `json:"last_seen" db:"last_seen"` + Status string `json:"status" db:"status"` + Metadata JSONB `json:"metadata" db:"metadata"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + LastScan *time.Time `json:"last_scan" db:"last_scan"` +} + // AgentSpecs represents system specifications for an agent type AgentSpecs struct { ID uuid.UUID `json:"id" db:"id"` @@ -56,6 +72,28 @@ type AgentRegistrationResponse struct { Config map[string]interface{} `json:"config"` } +// UTCTime is a time.Time that marshals to ISO format with UTC timezone +type UTCTime time.Time + +// MarshalJSON implements json.Marshaler for UTCTime +func (t UTCTime) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Time(t).UTC().Format("2006-01-02T15:04:05.000Z")) +} + +// UnmarshalJSON implements json.Unmarshaler for UTCTime +func (t *UTCTime) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + parsed, err := time.Parse("2006-01-02T15:04:05.000Z", s) + if err != nil { + return err + } + *t = UTCTime(parsed) + return nil +} + // JSONB type for PostgreSQL JSONB columns type JSONB map[string]interface{} diff --git a/aggregator-server/internal/models/update.go b/aggregator-server/internal/models/update.go index 3f5cb7e..e78b659 100644 --- a/aggregator-server/internal/models/update.go +++ b/aggregator-server/internal/models/update.go @@ -79,10 +79,87 @@ type UpdateLogRequest struct { // UpdateFilters for querying updates type UpdateFilters struct { - AgentID *uuid.UUID + AgentID uuid.UUID Status string Severity string PackageType string Page int PageSize int } + +// EVENT SOURCING MODELS + +// UpdateEvent represents a single update event in the event sourcing system +type UpdateEvent struct { + ID uuid.UUID `json:"id" db:"id"` + AgentID uuid.UUID `json:"agent_id" db:"agent_id"` + PackageType string `json:"package_type" db:"package_type"` + PackageName string `json:"package_name" db:"package_name"` + VersionFrom string `json:"version_from" db:"version_from"` + VersionTo string `json:"version_to" db:"version_to"` + Severity string `json:"severity" db:"severity"` + RepositorySource string `json:"repository_source" db:"repository_source"` + Metadata JSONB `json:"metadata" db:"metadata"` + EventType string `json:"event_type" db:"event_type"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// UpdateState represents the current state of a package (denormalized for queries) +type UpdateState struct { + ID uuid.UUID `json:"id" db:"id"` + AgentID uuid.UUID `json:"agent_id" db:"agent_id"` + PackageType string `json:"package_type" db:"package_type"` + PackageName string `json:"package_name" db:"package_name"` + CurrentVersion string `json:"current_version" db:"current_version"` + AvailableVersion string `json:"available_version" db:"available_version"` + Severity string `json:"severity" db:"severity"` + RepositorySource string `json:"repository_source" db:"repository_source"` + Metadata JSONB `json:"metadata" db:"metadata"` + LastDiscoveredAt time.Time `json:"last_discovered_at" db:"last_discovered_at"` + LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"` + Status string `json:"status" db:"status"` +} + +// UpdateHistory represents the version history of a package +type UpdateHistory struct { + ID uuid.UUID `json:"id" db:"id"` + AgentID uuid.UUID `json:"agent_id" db:"agent_id"` + PackageType string `json:"package_type" db:"package_type"` + PackageName string `json:"package_name" db:"package_name"` + VersionFrom string `json:"version_from" db:"version_from"` + VersionTo string `json:"version_to" db:"version_to"` + Severity string `json:"severity" db:"severity"` + RepositorySource string `json:"repository_source" db:"repository_source"` + Metadata JSONB `json:"metadata" db:"metadata"` + UpdateInitiatedAt *time.Time `json:"update_initiated_at" db:"update_initiated_at"` + UpdateCompletedAt time.Time `json:"update_completed_at" db:"update_completed_at"` + UpdateStatus string `json:"update_status" db:"update_status"` + FailureReason string `json:"failure_reason" db:"failure_reason"` +} + +// UpdateBatch represents a batch of update events +type UpdateBatch struct { + ID uuid.UUID `json:"id" db:"id"` + AgentID uuid.UUID `json:"agent_id" db:"agent_id"` + BatchSize int `json:"batch_size" db:"batch_size"` + ProcessedCount int `json:"processed_count" db:"processed_count"` + FailedCount int `json:"failed_count" db:"failed_count"` + Status string `json:"status" db:"status"` + ErrorDetails JSONB `json:"error_details" db:"error_details"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + CompletedAt *time.Time `json:"completed_at" db:"completed_at"` +} + +// UpdateStats represents statistics about updates +type UpdateStats struct { + TotalUpdates int `json:"total_updates" db:"total_updates"` + PendingUpdates int `json:"pending_updates" db:"pending_updates"` + ApprovedUpdates int `json:"approved_updates" db:"approved_updates"` + UpdatedUpdates int `json:"updated_updates" db:"updated_updates"` + FailedUpdates int `json:"failed_updates" db:"failed_updates"` + CriticalUpdates int `json:"critical_updates" db:"critical_updates"` + HighUpdates int `json:"high_updates" db:"high_updates"` + ImportantUpdates int `json:"important_updates" db:"important_updates"` + ModerateUpdates int `json:"moderate_updates" db:"moderate_updates"` + LowUpdates int `json:"low_updates" db:"low_updates"` +} diff --git a/aggregator-web/src/App.tsx b/aggregator-web/src/App.tsx index 1f0c4a5..9e0899a 100644 --- a/aggregator-web/src/App.tsx +++ b/aggregator-web/src/App.tsx @@ -1,16 +1,15 @@ import React, { useEffect } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { Toaster } from 'react-hot-toast'; -import { useAuthStore } from '@/lib/store'; -import { useSettingsStore } from '@/lib/store'; +import { useAuthStore, useUIStore } from '@/lib/store'; import Layout from '@/components/Layout'; import Dashboard from '@/pages/Dashboard'; import Agents from '@/pages/Agents'; import Updates from '@/pages/Updates'; +import Docker from '@/pages/Docker'; import Logs from '@/pages/Logs'; import Settings from '@/pages/Settings'; import Login from '@/pages/Login'; -import NotificationCenter from '@/components/NotificationCenter'; // Protected route component const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -25,7 +24,7 @@ const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) = const App: React.FC = () => { const { isAuthenticated, token } = useAuthStore(); - const { theme } = useSettingsStore(); + const { theme } = useUIStore(); // Apply theme to document useEffect(() => { @@ -72,9 +71,7 @@ const App: React.FC = () => { }} /> - {/* Notification center */} - {isAuthenticated && } - + {/* App routes */} {/* Login route */} @@ -96,6 +93,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/aggregator-web/src/components/AgentUpdates.tsx b/aggregator-web/src/components/AgentUpdates.tsx new file mode 100644 index 0000000..b843aca --- /dev/null +++ b/aggregator-web/src/components/AgentUpdates.tsx @@ -0,0 +1,238 @@ +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Search, Filter, Package, Clock, AlertTriangle } from 'lucide-react'; +import { formatRelativeTime } from '@/lib/utils'; +import { updateApi } from '@/lib/api'; +import type { UpdatePackage } from '@/types'; + +interface AgentUpdatesProps { + agentId: string; +} + +interface AgentUpdateResponse { + updates: UpdatePackage[]; + total: number; +} + +export function AgentSystemUpdates({ agentId }: AgentUpdatesProps) { + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [searchTerm, setSearchTerm] = useState(''); + const { data: updateData, isLoading, error } = useQuery({ + queryKey: ['agent-updates', agentId, currentPage, pageSize, searchTerm], + queryFn: async () => { + const params = { + page: currentPage, + page_size: pageSize, + agent: agentId, + type: 'system', // Only show system updates in AgentUpdates + ...(searchTerm && { search: searchTerm }), + }; + + const response = await updateApi.getUpdates(params); + return response; + }, + }); + + const updates = updateData?.updates || []; + const totalCount = updateData?.total || 0; + const totalPages = Math.ceil(totalCount / pageSize); + + const getSeverityColor = (severity: string) => { + switch (severity.toLowerCase()) { + case 'critical': return 'text-red-600 bg-red-50'; + case 'important': + case 'high': return 'text-orange-600 bg-orange-50'; + case 'moderate': + case 'medium': return 'text-yellow-600 bg-yellow-50'; + case 'low': + case 'none': return 'text-blue-600 bg-blue-50'; + default: return 'text-gray-600 bg-gray-50'; + } + }; + + const getPackageTypeIcon = (packageType: string) => { + switch (packageType.toLowerCase()) { + case 'system': return 'šŸ“¦'; + default: return 'šŸ“‹'; + } + }; + + if (isLoading) { + return ( +
+
+
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+
Error loading updates: {(error as Error).message}
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

System Updates

+
+ {totalCount} update{totalCount !== 1 ? 's' : ''} available +
+
+
+ + {/* Filters */} +
+
+ {/* Search */} +
+
+ + { + setSearchTerm(e.target.value); + setCurrentPage(1); + }} + className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ + {/* Page Size */} +
+ +
+
+
+ + {/* Updates List */} +
+ {updates.length === 0 ? ( +
+ +

No updates found

+

This agent is up to date!

+
+ ) : ( + updates.map((update) => ( +
+
+
+
+ {getPackageTypeIcon(update.package_type)} +

+ {update.package_name} +

+ + {update.severity} + +
+ +
+ Type: {update.package_type} + {update.repository_source && ( + Source: {update.repository_source} + )} +
+ + {formatRelativeTime(update.created_at)} +
+
+ +
+ From: + + {update.current_version || 'N/A'} + + → + + {update.available_version} + +
+
+ +
+ + +
+
+
+ )) + )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+
+ Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalCount)} of {totalCount} results +
+
+ + + Page {currentPage} of {totalPages} + + +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/aggregator-web/src/components/Layout.tsx b/aggregator-web/src/components/Layout.tsx index c32d194..e4ece47 100644 --- a/aggregator-web/src/components/Layout.tsx +++ b/aggregator-web/src/components/Layout.tsx @@ -9,12 +9,13 @@ import { Menu, X, LogOut, - Bell, Search, RefreshCw, + Container, + Bell, } from 'lucide-react'; import { useUIStore, useAuthStore, useRealtimeStore } from '@/lib/store'; -import { cn } from '@/lib/utils'; +import { cn, formatRelativeTime } from '@/lib/utils'; interface LayoutProps { children: React.ReactNode; @@ -25,8 +26,9 @@ const Layout: React.FC = ({ children }) => { const navigate = useNavigate(); const { sidebarOpen, setSidebarOpen, setActiveTab } = useUIStore(); const { logout } = useAuthStore(); - const { notifications } = useRealtimeStore(); + const { notifications, markNotificationRead, clearNotifications } = useRealtimeStore(); const [searchQuery, setSearchQuery] = useState(''); + const [isNotificationDropdownOpen, setIsNotificationDropdownOpen] = useState(false); const unreadCount = notifications.filter(n => !n.read).length; @@ -49,6 +51,12 @@ const Layout: React.FC = ({ children }) => { icon: Package, current: location.pathname.startsWith('/updates'), }, + { + name: 'Docker', + href: '/docker', + icon: Container, + current: location.pathname.startsWith('/docker'), + }, { name: 'Logs', href: '/logs', @@ -78,6 +86,33 @@ const Layout: React.FC = ({ children }) => { } }; + // Notification helper functions + const getNotificationIcon = (type: string) => { + switch (type) { + case 'success': + return 'āœ…'; + case 'error': + return 'āŒ'; + case 'warning': + return 'āš ļø'; + default: + return 'ā„¹ļø'; + } + }; + + const getNotificationColor = (type: string) => { + switch (type) { + case 'success': + return 'border-green-200 bg-green-50'; + case 'error': + return 'border-red-200 bg-red-50'; + case 'warning': + return 'border-yellow-200 bg-yellow-50'; + default: + return 'border-blue-200 bg-blue-50'; + } + }; + return (
{/* Sidebar */} @@ -148,7 +183,7 @@ const Layout: React.FC = ({ children }) => { {/* Top header */}
-
+
{/* Search */} -
+
= ({ children }) => { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search updates..." - className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" />
-
+ {/* Header actions - right to left order */} +
{/* Refresh button */} {/* Notifications */} - + + {/* Notifications dropdown */} + {isNotificationDropdownOpen && ( +
+ {/* Header */} +
+

Notifications

+
+ {notifications.length > 0 && ( + + )} + +
+
+ + {/* Notifications list */} +
+ {notifications.length === 0 ? ( +
+ +

No notifications

+
+ ) : ( + notifications.map((notification) => ( +
{ + markNotificationRead(notification.id); + setIsNotificationDropdownOpen(false); + }} + > +
+
+ {getNotificationIcon(notification.type)} +
+
+
+

+ {notification.title} +

+ {!notification.read && ( + + New + + )} +
+

+ {notification.message} +

+

+ {formatRelativeTime(notification.timestamp)} +

+
+
+
+ )) + )} +
+
)} - +
diff --git a/aggregator-web/src/components/NotificationCenter.tsx b/aggregator-web/src/components/NotificationCenter.tsx index d265355..4a8afbf 100644 --- a/aggregator-web/src/components/NotificationCenter.tsx +++ b/aggregator-web/src/components/NotificationCenter.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Bell, X, Check, Info, AlertTriangle, CheckCircle, XCircle } from 'lucide-react'; +import { Bell, X, Info, AlertTriangle, CheckCircle, XCircle } from 'lucide-react'; import { useRealtimeStore } from '@/lib/store'; import { cn, formatRelativeTime } from '@/lib/utils'; @@ -36,7 +36,7 @@ const NotificationCenter: React.FC = () => { }; return ( -
+
{/* Notification bell */}
-

+

{selectedAgent.hostname}

-

- Agent details and system information +

+ System details and update management for this agent

- + +
@@ -298,10 +529,10 @@ const Agents: React.FC = () => { {selectedAgents.length > 0 && (
{/* Agents table */} - {isLoading ? ( + {isPending ? (
{[...Array(5)].map((_, i) => ( @@ -401,7 +632,7 @@ const Agents: React.FC = () => { {filteredAgents.map((agent) => ( - + {
- {agent.ip_address} + {agent.metadata && (() => { + const meta = getSystemMetadata(agent); + const parts = []; + if (meta.cpuCores !== 'Unknown') parts.push(`${meta.cpuCores} cores`); + if (meta.memoryTotal > 0) parts.push(formatBytes(meta.memoryTotal)); + if (parts.length > 0) return parts.join(' • '); + return 'System info available'; + })()}
- - {agent.status} + + {isOnline(agent.last_seen) ? 'Online' : 'Offline'}
- {agent.os_type} + {(() => { + const osInfo = parseOSInfo(agent); + return osInfo.distribution || agent.os_type; + })()}
- {agent.architecture} + {(() => { + const osInfo = parseOSInfo(agent); + if (osInfo.version) { + return `${osInfo.version} • ${agent.os_architecture || agent.architecture}`; + } + return `${agent.os_architecture || agent.architecture}`; + })()}
- {formatRelativeTime(agent.last_checkin)} + {formatRelativeTime(agent.last_seen)}
- {isOnline(agent.last_checkin) ? 'Online' : 'Offline'} + {isOnline(agent.last_seen) ? 'Online' : 'Offline'}
@@ -462,12 +709,20 @@ const Agents: React.FC = () => {
+ + + + +
{/* Search and filters */} @@ -336,7 +547,7 @@ const Updates: React.FC = () => { {selectedUpdates.length > 0 && ( + + +
+
+

+ Showing {(currentPage - 1) * pageSize + 1} to{' '} + {Math.min(currentPage * pageSize, totalCount)} of{' '} + {totalCount} results +

+
+
+ +
+
+ + + )} )} diff --git a/aggregator-web/src/types/index.ts b/aggregator-web/src/types/index.ts index 2f4e666..8a54858 100644 --- a/aggregator-web/src/types/index.ts +++ b/aggregator-web/src/types/index.ts @@ -11,14 +11,18 @@ export interface Agent { hostname: string; os_type: string; os_version: string; - architecture: string; - status: 'online' | 'offline'; - last_checkin: string; + os_architecture: string; + architecture: string; // For backward compatibility + agent_version: string; + version: string; // For backward compatibility + last_seen: string; + last_checkin: string; // For backward compatibility last_scan: string | null; + status: 'online' | 'offline'; created_at: string; updated_at: string; - version: string; - ip_address: string; + metadata?: Record; + // Note: ip_address not available from API yet } export interface AgentSpec { @@ -61,6 +65,97 @@ export interface DockerUpdateInfo { size_bytes: number; } +// Docker-specific types for dedicated Docker module +export interface DockerContainer { + id: string; + agent_id: string; + name: string; + image_id: string; + image_name: string; + image_tag: string; + status: 'running' | 'stopped' | 'paused' | 'restarting' | 'removing' | 'exited' | 'dead'; + created_at: string; + started_at: string | null; + ports: DockerPort[]; + volumes: DockerVolume[]; + labels: Record; + metadata: Record; +} + +export interface DockerImage { + id: string; + agent_id: string; + repository: string; + tag: string; + digest: string; + size_bytes: number; + created_at: string; + last_pulled: string | null; + update_available: boolean; + current_version: string; + available_version: string | null; + severity: 'low' | 'medium' | 'high' | 'critical'; + status: 'up-to-date' | 'update-available' | 'update-approved' | 'update-scheduled' | 'update-installing' | 'update-failed'; + update_approved_at: string | null; + update_scheduled_at: string | null; + update_installed_at: string | null; + metadata: Record; +} + +export interface DockerPort { + container_port: number; + host_port: number | null; + protocol: 'tcp' | 'udp'; + host_ip: string; +} + +export interface DockerVolume { + name: string; + source: string; + destination: string; + mode: 'ro' | 'rw'; + driver: string; +} + +// Docker API response types +export interface DockerContainerListResponse { + containers: DockerContainer[]; + images: DockerImage[]; + total_containers: number; + total_images: number; + page: number; + page_size: number; + total_pages: number; +} + +export interface DockerStats { + total_containers: number; + running_containers: number; + stopped_containers: number; + total_images: number; + images_with_updates: number; + critical_updates: number; + high_updates: number; + medium_updates: number; + low_updates: number; + agents_with_docker: number; + total_storage_used: number; +} + +// Docker action types +export interface DockerUpdateRequest { + image_id: string; + scheduled_at?: string; +} + +export interface BulkDockerUpdateRequest { + updates: Array<{ + container_id: string; + image_id: string; + }>; + scheduled_at?: string; +} + export interface AptUpdateInfo { package_name: string; current_version: string; @@ -122,6 +217,22 @@ export interface AgentListResponse { export interface UpdateListResponse { updates: UpdatePackage[]; total: number; + page: number; + page_size: number; + stats?: UpdateStats; +} + +export interface UpdateStats { + total_updates: number; + pending_updates: number; + approved_updates: number; + updated_updates: number; + failed_updates: number; + critical_updates: number; + high_updates: number; + important_updates: number; + moderate_updates: number; + low_updates: number; } export interface UpdateApprovalRequest { @@ -142,6 +253,7 @@ export interface ListQueryParams { severity?: string; type?: string; search?: string; + agent?: string; sort_by?: string; sort_order?: 'asc' | 'desc'; } diff --git a/aggregator-web/src/vite-env.d.ts b/aggregator-web/src/vite-env.d.ts new file mode 100644 index 0000000..79b2836 --- /dev/null +++ b/aggregator-web/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL: string + // more env variables... +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} \ No newline at end of file