Add screenshots and update gitignore for alpha release

- Fixed gitignore to allow Screenshots/*.png files
- Added all screenshots for README documentation
- Fixed gitignore to be less restrictive with image files
- Includes dashboard, agent, updates, and docker screenshots
This commit is contained in:
Fimeg
2025-10-16 09:16:05 -04:00
parent a7fad61de2
commit 61294ba514
36 changed files with 3088 additions and 443 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
server:
url: "http://localhost:8080"
agent:
hostname: "test-agent"
check_in_interval: 60
batch_size: 50
auth:
token: "test-token"

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(&currentState, 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
}

View File

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

View File

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

View File

@@ -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 && <NotificationCenter />}
{/* App routes */}
<Routes>
{/* Login route */}
@@ -96,6 +93,7 @@ const App: React.FC = () => {
<Route path="/agents/:id" element={<Agents />} />
<Route path="/updates" element={<Updates />} />
<Route path="/updates/:id" element={<Updates />} />
<Route path="/docker" element={<Docker />} />
<Route path="/logs" element={<Logs />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -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<AgentUpdateResponse>({
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 (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-4 bg-gray-200 rounded"></div>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="text-red-600 text-sm">Error loading updates: {(error as Error).message}</div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
{/* Header */}
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">System Updates</h2>
<div className="text-sm text-gray-500">
{totalCount} update{totalCount !== 1 ? 's' : ''} available
</div>
</div>
</div>
{/* Filters */}
<div className="p-4 border-b border-gray-200 bg-gray-50">
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<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"
placeholder="Search packages..."
value={searchTerm}
onChange={(e) => {
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"
/>
</div>
</div>
{/* Page Size */}
<div className="sm:w-32">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
</div>
</div>
{/* Updates List */}
<div className="divide-y divide-gray-200">
{updates.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<Package className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>No updates found</p>
<p className="text-sm mt-2">This agent is up to date!</p>
</div>
) : (
updates.map((update) => (
<div key={update.id} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">{getPackageTypeIcon(update.package_type)}</span>
<h3 className="text-sm font-medium text-gray-900 truncate">
{update.package_name}
</h3>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${getSeverityColor(update.severity)}`}>
{update.severity}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 mb-2">
<span>Type: {update.package_type}</span>
{update.repository_source && (
<span>Source: {update.repository_source}</span>
)}
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatRelativeTime(update.created_at)}
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-600">From:</span>
<span className="font-mono bg-gray-100 px-1 py-0.5 rounded">
{update.current_version || 'N/A'}
</span>
<span className="text-gray-600"></span>
<span className="font-mono bg-green-50 text-green-700 px-1 py-0.5 rounded">
{update.available_version}
</span>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
className="text-green-600 hover:text-green-800 text-sm font-medium"
onClick={() => {
// TODO: Implement install single update functionality
console.log('Install update:', update.id);
}}
>
Install
</button>
<button
className="text-blue-600 hover:text-blue-800 text-sm"
onClick={() => {
// TODO: Implement view logs functionality
console.log('View logs for update:', update.id);
}}
>
Logs
</button>
</div>
</div>
</div>
))
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="p-4 border-t border-gray-200 bg-gray-50">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-700">
Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalCount)} of {totalCount} results
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="px-3 py-1 text-sm text-gray-700">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<LayoutProps> = ({ 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<LayoutProps> = ({ 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<LayoutProps> = ({ 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 (
<div className="min-h-screen bg-gray-50 flex">
{/* Sidebar */}
@@ -148,7 +183,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
{/* Top header */}
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="flex items-center justify-between h-16 px-4 sm:px-6 lg:px-8">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-4 flex-1">
<button
onClick={() => setSidebarOpen(true)}
className="lg:hidden text-gray-500 hover:text-gray-700"
@@ -157,7 +192,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
</button>
{/* Search */}
<form onSubmit={handleSearch} className="hidden md:block">
<form onSubmit={handleSearch} className="hidden md:block flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
@@ -165,29 +200,113 @@ const Layout: React.FC<LayoutProps> = ({ 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"
/>
</div>
</form>
</div>
<div className="flex items-center space-x-4">
{/* Header actions - right to left order */}
<div className="flex items-center space-x-2 ml-4">
{/* Refresh button */}
<button
onClick={() => window.location.reload()}
className="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100"
title="Refresh"
className="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100 transition-colors"
title="Refresh page"
>
<RefreshCw className="w-4 h-4" />
</button>
{/* Notifications */}
<button className="relative text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100">
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
<div className="relative">
<button
onClick={() => setIsNotificationDropdownOpen(!isNotificationDropdownOpen)}
className="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100 transition-colors relative"
title="Notifications"
>
<Bell className="w-4 h-4" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-600 text-white text-xs rounded-full flex items-center justify-center font-medium">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Notifications dropdown */}
{isNotificationDropdownOpen && (
<div className="absolute top-12 right-0 w-96 bg-white rounded-lg shadow-lg border border-gray-200 max-h-96 overflow-hidden z-50">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h3 className="font-semibold text-gray-900">Notifications</h3>
<div className="flex items-center space-x-2">
{notifications.length > 0 && (
<button
onClick={clearNotifications}
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
Clear All
</button>
)}
<button
onClick={() => setIsNotificationDropdownOpen(false)}
className="text-gray-500 hover:text-gray-700 transition-colors"
>
</button>
</div>
</div>
{/* Notifications list */}
<div className="overflow-y-auto max-h-80">
{notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<Bell className="w-8 h-8 mx-auto mb-2 text-gray-300" />
<p>No notifications</p>
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={cn(
'p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors',
!notification.read && 'bg-blue-50 border-l-4 border-l-blue-500',
getNotificationColor(notification.type)
)}
onClick={() => {
markNotificationRead(notification.id);
setIsNotificationDropdownOpen(false);
}}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 mt-0.5 text-lg">
{getNotificationIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900">
{notification.title}
</p>
{!notification.read && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
New
</span>
)}
</div>
<p className="text-sm text-gray-600 mt-1">
{notification.message}
</p>
<p className="text-xs text-gray-400 mt-2">
{formatRelativeTime(notification.timestamp)}
</p>
</div>
</div>
</div>
))
)}
</div>
</div>
)}
</button>
</div>
</div>
</div>
</header>

View File

@@ -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 (
<div className="fixed top-4 right-4 z-50">
<div className="fixed top-4 right-4 z-40">
{/* Notification bell */}
<button
onClick={() => setIsOpen(!isOpen)}

View File

@@ -1,99 +1,39 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useMutation } from '@tanstack/react-query';
import { agentApi } from '@/lib/api';
import { Agent, ListQueryParams } from '@/types';
import { useAgentStore, useRealtimeStore } from '@/lib/store';
import { handleApiError } from '@/lib/api';
export const useAgents = (params?: ListQueryParams) => {
const { setAgents, setLoading, setError, updateAgentStatus } = useAgentStore();
import type { Agent, ListQueryParams, AgentListResponse, ScanRequest } from '@/types';
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
export const useAgents = (params?: ListQueryParams): UseQueryResult<AgentListResponse, Error> => {
return useQuery({
queryKey: ['agents', params],
queryFn: () => agentApi.getAgents(params),
onSuccess: (data) => {
setAgents(data.agents);
setLoading(false);
setError(null);
},
onError: (error) => {
setError(handleApiError(error).message);
setLoading(false);
},
onSettled: () => {
setLoading(false);
},
staleTime: 30 * 1000, // Consider data stale after 30 seconds
refetchInterval: 60 * 1000, // Auto-refetch every minute
});
};
export const useAgent = (id: string, enabled: boolean = true) => {
const { setSelectedAgent, setLoading, setError } = useAgentStore();
export const useAgent = (id: string, enabled: boolean = true): UseQueryResult<Agent, Error> => {
return useQuery({
queryKey: ['agent', id],
queryFn: () => agentApi.getAgent(id),
enabled: enabled && !!id,
onSuccess: (data) => {
setSelectedAgent(data);
setLoading(false);
setError(null);
},
onError: (error) => {
setError(handleApiError(error).message);
setLoading(false);
},
});
};
export const useScanAgent = () => {
const queryClient = useQueryClient();
const { addNotification } = useRealtimeStore();
export const useScanAgent = (): UseMutationResult<void, Error, string, unknown> => {
return useMutation({
mutationFn: agentApi.scanAgent,
onSuccess: () => {
// Invalidate agents query to refresh data
queryClient.invalidateQueries({ queryKey: ['agents'] });
// Show success notification
addNotification({
type: 'success',
title: 'Scan Triggered',
message: 'Agent scan has been triggered successfully.',
});
},
onError: (error) => {
addNotification({
type: 'error',
title: 'Scan Failed',
message: handleApiError(error).message,
});
},
});
};
export const useScanMultipleAgents = () => {
const queryClient = useQueryClient();
const { addNotification } = useRealtimeStore();
export const useScanMultipleAgents = (): UseMutationResult<void, Error, ScanRequest, unknown> => {
return useMutation({
mutationFn: agentApi.triggerScan,
onSuccess: () => {
// Invalidate agents query to refresh data
queryClient.invalidateQueries({ queryKey: ['agents'] });
});
};
// Show success notification
addNotification({
type: 'success',
title: 'Bulk Scan Triggered',
message: 'Scan has been triggered for selected agents.',
});
},
onError: (error) => {
addNotification({
type: 'error',
title: 'Bulk Scan Failed',
message: handleApiError(error).message,
});
},
export const useUnregisterAgent = (): UseMutationResult<void, Error, string, unknown> => {
return useMutation({
mutationFn: agentApi.unregisterAgent,
});
};

View File

@@ -0,0 +1,170 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { dockerApi } from '@/lib/api';
import type { DockerContainer, DockerImage } from '@/types';
import toast from 'react-hot-toast';
// Hook for fetching all Docker containers/images across all agents
export const useDockerContainers = (params?: {
page?: number;
page_size?: number;
agent?: string;
status?: string;
search?: string;
}) => {
return useQuery({
queryKey: ['docker-containers', params],
queryFn: async () => {
const response = await dockerApi.getContainers(params || {});
return response;
},
staleTime: 30000, // 30 seconds
});
};
// Hook for fetching Docker containers for a specific agent
export const useAgentDockerContainers = (agentId: string, params?: {
page?: number;
page_size?: number;
status?: string;
search?: string;
}) => {
return useQuery({
queryKey: ['agent-docker-containers', agentId, params],
queryFn: async () => {
const response = await dockerApi.getAgentContainers(agentId, params || {});
return response;
},
staleTime: 30000,
enabled: !!agentId,
});
};
// Hook for Docker statistics
export const useDockerStats = () => {
return useQuery({
queryKey: ['docker-stats'],
queryFn: async () => {
const response = await dockerApi.getStats();
return response;
},
staleTime: 60000, // 1 minute
});
};
// Hook for approving Docker updates
export const useApproveDockerUpdate = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ containerId, imageId }: {
containerId: string;
imageId: string;
}) => {
const response = await dockerApi.approveUpdate(containerId, imageId);
return response;
},
onSuccess: () => {
toast.success('Docker update approved successfully');
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to approve Docker update');
},
});
};
// Hook for rejecting Docker updates
export const useRejectDockerUpdate = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ containerId, imageId }: {
containerId: string;
imageId: string;
}) => {
const response = await dockerApi.rejectUpdate(containerId, imageId);
return response;
},
onSuccess: () => {
toast.success('Docker update rejected');
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to reject Docker update');
},
});
};
// Hook for installing Docker updates
export const useInstallDockerUpdate = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ containerId, imageId }: {
containerId: string;
imageId: string;
}) => {
const response = await dockerApi.installUpdate(containerId, imageId);
return response;
},
onSuccess: () => {
toast.success('Docker update installation started');
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to install Docker update');
},
});
};
// Hook for bulk Docker operations
export const useBulkDockerActions = () => {
const queryClient = useQueryClient();
const approveMultiple = useMutation({
mutationFn: async ({ updates }: {
updates: Array<{ containerId: string; imageId: string }>;
}) => {
const response = await dockerApi.bulkApproveUpdates(updates);
return response;
},
onSuccess: (data) => {
toast.success(`${data.approved} Docker updates approved`);
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to approve Docker updates');
},
});
const rejectMultiple = useMutation({
mutationFn: async ({ updates }: {
updates: Array<{ containerId: string; imageId: string }>;
}) => {
const response = await dockerApi.bulkRejectUpdates(updates);
return response;
},
onSuccess: (data) => {
toast.success(`${data.rejected} Docker updates rejected`);
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to reject Docker updates');
},
});
return {
approveMultiple,
rejectMultiple,
};
};

View File

@@ -0,0 +1,46 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../lib/api'
export interface TimezoneOption {
value: string
label: string
}
export interface TimezoneSettings {
timezone: string
label: string
}
export function useTimezones() {
return useQuery({
queryKey: ['timezones'],
queryFn: async (): Promise<TimezoneOption[]> => {
const { data } = await api.get('/settings/timezones')
return data.timezones
},
})
}
export function useTimezone() {
return useQuery({
queryKey: ['timezone'],
queryFn: async (): Promise<TimezoneSettings> => {
const { data } = await api.get('/settings/timezone')
return data
},
})
}
export function useUpdateTimezone() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (timezone: string): Promise<TimezoneSettings> => {
const { data } = await api.put('/settings/timezone', { timezone })
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timezone'] })
},
})
}

View File

@@ -1,16 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { statsApi } from '@/lib/api';
import { DashboardStats } from '@/types';
import { handleApiError } from '@/lib/api';
import type { DashboardStats } from '@/types';
import type { UseQueryResult } from '@tanstack/react-query';
export const useDashboardStats = () => {
export const useDashboardStats = (): UseQueryResult<DashboardStats, Error> => {
return useQuery({
queryKey: ['dashboard-stats'],
queryFn: statsApi.getDashboardStats,
refetchInterval: 30000, // Refresh every 30 seconds
staleTime: 15000, // Consider data stale after 15 seconds
onError: (error) => {
console.error('Failed to fetch dashboard stats:', handleApiError(error));
},
});
};

View File

@@ -1,173 +1,44 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useMutation } from '@tanstack/react-query';
import { updateApi } from '@/lib/api';
import { UpdatePackage, ListQueryParams, UpdateApprovalRequest } from '@/types';
import { useUpdateStore, useRealtimeStore } from '@/lib/store';
import { handleApiError } from '@/lib/api';
export const useUpdates = (params?: ListQueryParams) => {
const { setUpdates, setLoading, setError } = useUpdateStore();
import type { UpdatePackage, ListQueryParams, UpdateApprovalRequest, UpdateListResponse } from '@/types';
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
export const useUpdates = (params?: ListQueryParams): UseQueryResult<UpdateListResponse, Error> => {
return useQuery({
queryKey: ['updates', params],
queryFn: () => updateApi.getUpdates(params),
onSuccess: (data) => {
setUpdates(data.updates);
setLoading(false);
setError(null);
},
onError: (error) => {
setError(handleApiError(error).message);
setLoading(false);
},
onSettled: () => {
setLoading(false);
},
});
};
export const useUpdate = (id: string, enabled: boolean = true) => {
const { setSelectedUpdate, setLoading, setError } = useUpdateStore();
export const useUpdate = (id: string, enabled: boolean = true): UseQueryResult<UpdatePackage, Error> => {
return useQuery({
queryKey: ['update', id],
queryFn: () => updateApi.getUpdate(id),
enabled: enabled && !!id,
onSuccess: (data) => {
setSelectedUpdate(data);
setLoading(false);
setError(null);
},
onError: (error) => {
setError(handleApiError(error).message);
setLoading(false);
},
});
};
export const useApproveUpdate = () => {
const queryClient = useQueryClient();
const { updateUpdateStatus } = useUpdateStore();
const { addNotification } = useRealtimeStore();
export const useApproveUpdate = (): UseMutationResult<void, Error, { id: string; scheduledAt?: string; }, unknown> => {
return useMutation({
mutationFn: ({ id, scheduledAt }: { id: string; scheduledAt?: string }) =>
updateApi.approveUpdate(id, scheduledAt),
onSuccess: (_, { id }) => {
// Update local state
updateUpdateStatus(id, 'approved');
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ['updates'] });
queryClient.invalidateQueries({ queryKey: ['update', id] });
// Show success notification
addNotification({
type: 'success',
title: 'Update Approved',
message: 'The update has been approved successfully.',
});
},
onError: (error) => {
addNotification({
type: 'error',
title: 'Approval Failed',
message: handleApiError(error).message,
});
},
});
};
export const useApproveMultipleUpdates = () => {
const queryClient = useQueryClient();
const { bulkUpdateStatus } = useUpdateStore();
const { addNotification } = useRealtimeStore();
export const useApproveMultipleUpdates = (): UseMutationResult<void, Error, UpdateApprovalRequest, unknown> => {
return useMutation({
mutationFn: (request: UpdateApprovalRequest) => updateApi.approveUpdates(request),
onSuccess: (_, request) => {
// Update local state
bulkUpdateStatus(request.update_ids, 'approved');
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ['updates'] });
// Show success notification
addNotification({
type: 'success',
title: 'Updates Approved',
message: `${request.update_ids.length} update(s) have been approved successfully.`,
});
},
onError: (error) => {
addNotification({
type: 'error',
title: 'Bulk Approval Failed',
message: handleApiError(error).message,
});
},
});
};
export const useRejectUpdate = () => {
const queryClient = useQueryClient();
const { updateUpdateStatus } = useUpdateStore();
const { addNotification } = useRealtimeStore();
export const useRejectUpdate = (): UseMutationResult<void, Error, string, unknown> => {
return useMutation({
mutationFn: updateApi.rejectUpdate,
onSuccess: (_, id) => {
// Update local state
updateUpdateStatus(id, 'pending');
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ['updates'] });
queryClient.invalidateQueries({ queryKey: ['update', id] });
// Show success notification
addNotification({
type: 'success',
title: 'Update Rejected',
message: 'The update has been rejected and moved back to pending status.',
});
},
onError: (error) => {
addNotification({
type: 'error',
title: 'Rejection Failed',
message: handleApiError(error).message,
});
},
});
};
export const useInstallUpdate = () => {
const queryClient = useQueryClient();
const { updateUpdateStatus } = useUpdateStore();
const { addNotification } = useRealtimeStore();
export const useInstallUpdate = (): UseMutationResult<void, Error, string, unknown> => {
return useMutation({
mutationFn: updateApi.installUpdate,
onSuccess: (_, id) => {
// Update local state
updateUpdateStatus(id, 'installing');
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ['updates'] });
queryClient.invalidateQueries({ queryKey: ['update', id] });
// Show success notification
addNotification({
type: 'info',
title: 'Installation Started',
message: 'The update installation has been started. This may take a few minutes.',
});
},
onError: (error) => {
addNotification({
type: 'error',
title: 'Installation Failed',
message: handleApiError(error).message,
});
},
});
};

View File

@@ -1,12 +1,12 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@layer base {
* {
@apply border-border;
@apply border-gray-200;
}
body {

View File

@@ -8,13 +8,21 @@ import {
UpdateApprovalRequest,
ScanRequest,
ListQueryParams,
ApiResponse,
ApiError
ApiError,
DockerContainer,
DockerImage,
DockerContainerListResponse,
DockerStats,
DockerUpdateRequest,
BulkDockerUpdateRequest
} from '@/types';
// Base URL for API
export const API_BASE_URL = (import.meta.env?.VITE_API_URL as string) || '/api/v1';
// Create axios instance
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api/v1',
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
@@ -66,6 +74,11 @@ export const agentApi = {
scanAgent: async (id: string): Promise<void> => {
await api.post(`/agents/${id}/scan`);
},
// Unregister/remove agent
unregisterAgent: async (id: string): Promise<void> => {
await api.delete(`/agents/${id}`);
},
};
export const updateApi = {
@@ -178,7 +191,7 @@ export const handleApiError = (error: any): ApiError => {
};
}
if (status >= 500) {
if (status && status >= 500) {
return {
message: 'Server error. Please try again later.',
code: 'SERVER_ERROR',
@@ -198,4 +211,75 @@ export const handleApiError = (error: any): ApiError => {
};
};
// Docker-specific API endpoints
export const dockerApi = {
// Get all Docker containers and images across all agents
getContainers: async (params?: {
page?: number;
page_size?: number;
agent?: string;
status?: string;
search?: string;
}): Promise<DockerContainerListResponse> => {
const response = await api.get('/docker/containers', { params });
return response.data;
},
// Get Docker containers for a specific agent
getAgentContainers: async (agentId: string, params?: {
page?: number;
page_size?: number;
status?: string;
search?: string;
}): Promise<DockerContainerListResponse> => {
const response = await api.get(`/agents/${agentId}/docker`, { params });
return response.data;
},
// Get Docker statistics
getStats: async (): Promise<DockerStats> => {
const response = await api.get('/docker/stats');
return response.data;
},
// Approve Docker image update
approveUpdate: async (containerId: string, imageId: string, scheduledAt?: string): Promise<void> => {
await api.post(`/docker/containers/${containerId}/images/${imageId}/approve`, {
scheduled_at: scheduledAt,
});
},
// Reject Docker image update
rejectUpdate: async (containerId: string, imageId: string): Promise<void> => {
await api.post(`/docker/containers/${containerId}/images/${imageId}/reject`);
},
// Install Docker image update
installUpdate: async (containerId: string, imageId: string): Promise<void> => {
await api.post(`/docker/containers/${containerId}/images/${imageId}/install`);
},
// Bulk approve Docker updates
bulkApproveUpdates: async (updates: Array<{ containerId: string; imageId: string }>, scheduledAt?: string): Promise<{ approved: number }> => {
const response = await api.post('/docker/updates/bulk-approve', {
updates,
scheduled_at: scheduledAt,
});
return response.data;
},
// Bulk reject Docker updates
bulkRejectUpdates: async (updates: Array<{ containerId: string; imageId: string }>): Promise<{ rejected: number }> => {
const response = await api.post('/docker/updates/bulk-reject', {
updates,
});
return response.data;
},
// Trigger Docker scan on agents
triggerScan: async (agentIds?: string[]): Promise<void> => {
await api.post('/docker/scan', { agent_ids: agentIds });
},
};
export default api;

View File

@@ -173,7 +173,7 @@ interface RealtimeState {
}>;
setConnected: (connected: boolean) => void;
setLastUpdate: (timestamp: string) => void;
addNotification: (notification: Omit<typeof RealtimeState.prototype.notifications[0], 'id' | 'timestamp' | 'read'>) => void;
addNotification: (notification: Omit<RealtimeState['notifications'][0], 'id' | 'timestamp' | 'read'>) => void;
markNotificationRead: (id: string) => void;
clearNotifications: () => void;
}

View File

@@ -19,7 +19,32 @@ export const formatDate = (dateString: string): string => {
};
export const formatRelativeTime = (dateString: string): string => {
const date = new Date(dateString);
if (!dateString) return 'Never';
let date: Date;
try {
// Handle various timestamp formats
if (dateString.includes('T') && dateString.includes('Z')) {
// ISO 8601 format
date = new Date(dateString);
} else if (dateString.includes(' ')) {
// Database format like "2025-01-15 10:30:00"
date = new Date(dateString.replace(' ', 'T') + 'Z');
} else {
// Try direct parsing
date = new Date(dateString);
}
// Check if date is invalid
if (isNaN(date.getTime())) {
console.warn('Invalid date string:', dateString);
return 'Invalid Date';
}
} catch (error) {
console.warn('Error parsing date:', dateString, error);
return 'Invalid Date';
}
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
@@ -35,7 +60,7 @@ export const formatRelativeTime = (dateString: string): string => {
} else if (diffDays < 7) {
return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
} else {
return formatDate(dateString);
return formatDate(date.toISOString());
}
};
@@ -44,7 +69,7 @@ export const isOnline = (lastCheckin: string): boolean => {
const now = new Date();
const diffMs = now.getTime() - lastCheck.getTime();
const diffMins = Math.floor(diffMs / 60000);
return diffMins < 10; // Consider online if checked in within 10 minutes
return diffMins < 15; // Consider online if checked in within 15 minutes (allows for 5min check-in + buffer)
};
// Size formatting utilities
@@ -103,11 +128,14 @@ export const getSeverityColor = (severity: string): string => {
switch (severity) {
case 'critical':
return 'text-danger-600 bg-danger-100';
case 'important':
case 'high':
return 'text-warning-600 bg-warning-100';
case 'moderate':
case 'medium':
return 'text-blue-600 bg-blue-100';
case 'low':
case 'none':
return 'text-gray-600 bg-gray-100';
default:
return 'text-gray-600 bg-gray-100';
@@ -194,7 +222,7 @@ export const debounce = <T extends (...args: any[]) => any>(
func: T,
wait: number
): ((...args: Parameters<T>) => void) => {
let timeout: NodeJS.Timeout;
let timeout: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeout);

View File

@@ -10,7 +10,7 @@ const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 5 * 60 * 1000, // 5 minutes
staleTime: 10 * 1000, // 10 seconds
},
},
})

View File

@@ -5,21 +5,22 @@ import {
RefreshCw,
Search,
Filter,
ChevronDown,
ChevronRight as ChevronRightIcon,
Activity,
HardDrive,
Cpu,
Globe,
MapPin,
Calendar,
Package,
Cpu,
HardDrive,
MemoryStick,
GitBranch,
Clock,
Trash2,
} from 'lucide-react';
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents } from '@/hooks/useAgents';
import { Agent } from '@/types';
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
import { AgentSystemUpdates } from '@/components/AgentUpdates';
const Agents: React.FC = () => {
const { id } = useParams<{ id?: string }>();
@@ -30,8 +31,81 @@ const Agents: React.FC = () => {
const [showFilters, setShowFilters] = useState(false);
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
// Helper function to get system metadata from agent
const getSystemMetadata = (agent: any) => {
const metadata = agent.metadata || {};
return {
cpuModel: metadata.cpu_model || 'Unknown',
cpuCores: metadata.cpu_cores || 'Unknown',
memoryTotal: metadata.memory_total ? parseInt(metadata.memory_total) : 0,
diskMount: metadata.disk_mount || 'Unknown',
diskTotal: metadata.disk_total ? parseInt(metadata.disk_total) : 0,
diskUsed: metadata.disk_used ? parseInt(metadata.disk_used) : 0,
processes: metadata.processes || 'Unknown',
uptime: metadata.uptime || 'Unknown',
installationTime: metadata.installation_time || 'Unknown',
};
};
// Helper function to parse OS information
const parseOSInfo = (agent: any) => {
const osType = agent.os_type || '';
const osVersion = agent.os_version || '';
// Extract platform and distribution
let platform = osType;
let distribution = '';
let version = osVersion;
// Handle Linux distributions
if (osType.toLowerCase().includes('linux')) {
platform = 'Linux';
// Try to extract distribution from version string
if (osVersion.toLowerCase().includes('ubuntu')) {
distribution = 'Ubuntu';
version = osVersion.replace(/ubuntu/i, '').trim();
} else if (osVersion.toLowerCase().includes('fedora')) {
distribution = 'Fedora';
version = osVersion.replace(/fedora/i, '').trim();
} else if (osVersion.toLowerCase().includes('debian')) {
distribution = 'Debian';
version = osVersion.replace(/debian/i, '').trim();
} else if (osVersion.toLowerCase().includes('centos')) {
distribution = 'CentOS';
version = osVersion.replace(/centos/i, '').trim();
} else if (osVersion.toLowerCase().includes('proxmox')) {
distribution = 'Proxmox';
version = osVersion.replace(/proxmox/i, '').trim();
} else if (osVersion.toLowerCase().includes('arch')) {
distribution = 'Arch Linux';
version = osVersion.replace(/arch/i, '').trim();
} else {
// Try to get first word as distribution
const words = osVersion.split(' ');
distribution = words[0] || 'Unknown Distribution';
version = words.slice(1).join(' ');
}
} else if (osType.toLowerCase().includes('windows')) {
platform = 'Windows';
distribution = osVersion; // Windows version info is all in one field
version = '';
} else if (osType.toLowerCase().includes('darwin') || osType.toLowerCase().includes('macos')) {
platform = 'macOS';
distribution = 'macOS';
version = osVersion;
}
// Truncate long version strings
if (version.length > 30) {
version = version.substring(0, 30) + '...';
}
return { platform, distribution, version: version.trim() };
};
// Fetch agents list
const { data: agentsData, isLoading, error } = useAgents({
const { data: agentsData, isPending, error } = useAgents({
search: searchQuery || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
});
@@ -41,6 +115,7 @@ const Agents: React.FC = () => {
const scanAgentMutation = useScanAgent();
const scanMultipleMutation = useScanMultipleAgents();
const unregisterAgentMutation = useUnregisterAgent();
const agents = agentsData?.agents || [];
const selectedAgent = selectedAgentData || agents.find(a => a.id === id);
@@ -93,6 +168,27 @@ const Agents: React.FC = () => {
}
};
// Handle agent removal
const handleRemoveAgent = async (agentId: string, hostname: string) => {
if (!window.confirm(
`Are you sure you want to remove agent "${hostname}"? This action cannot be undone and will remove the agent from the system.`
)) {
return;
}
try {
await unregisterAgentMutation.mutateAsync(agentId);
toast.success(`Agent "${hostname}" removed successfully`);
// Navigate back to agents list if we're on the agent detail page
if (id && id === agentId) {
navigate('/agents');
}
} catch (error) {
// Error handling is done in the hook
}
};
// Get unique OS types for filter
const osTypes = [...new Set(agents.map(agent => agent.os_type))];
@@ -109,19 +205,19 @@ const Agents: React.FC = () => {
</button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">
<h1 className="text-3xl font-bold text-gray-900">
{selectedAgent.hostname}
</h1>
<p className="mt-1 text-sm text-gray-600">
Agent details and system information
<p className="mt-2 text-sm text-gray-600">
System details and update management for this agent
</p>
</div>
<button
onClick={() => handleScanAgent(selectedAgent.id)}
disabled={scanAgentMutation.isLoading}
disabled={scanAgentMutation.isPending}
className="btn btn-primary"
>
{scanAgentMutation.isLoading ? (
{scanAgentMutation.isPending ? (
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
@@ -134,36 +230,84 @@ const Agents: React.FC = () => {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Agent info */}
<div className="lg:col-span-2 space-y-6">
{/* Status card */}
{/* Agent Status Card */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-gray-900">Status</h2>
<span className={cn('badge', getStatusColor(selectedAgent.status))}>
{selectedAgent.status}
<h2 className="text-lg font-medium text-gray-900">Agent Status</h2>
<span className={cn('badge', getStatusColor(isOnline(selectedAgent.last_seen) ? 'online' : 'offline'))}>
{isOnline(selectedAgent.last_seen) ? 'Online' : 'Offline'}
</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center space-x-2 text-sm text-gray-600">
<Activity className="h-4 w-4" />
<span>Last Check-in:</span>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Agent Information */}
<div className="space-y-4">
<div className="p-3 bg-gray-50 rounded-lg border border-gray-200">
<h3 className="text-sm font-medium text-gray-900 mb-3">Agent Information</h3>
<div className="space-y-3">
<div>
<p className="text-xs text-gray-500">Agent ID</p>
<p className="text-xs font-mono text-gray-700 break-all">
{selectedAgent.id}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs text-gray-500">Version</p>
<p className="text-xs font-medium text-gray-900">
{selectedAgent.agent_version || selectedAgent.version || 'Unknown'}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Registered</p>
<p className="text-xs font-medium text-gray-900">
{formatRelativeTime(selectedAgent.created_at)}
</p>
</div>
</div>
{(() => {
const meta = getSystemMetadata(selectedAgent);
if (meta.installationTime !== 'Unknown') {
return (
<div>
<p className="text-xs text-gray-500">Installation Time</p>
<p className="text-xs font-medium text-gray-900">
{formatRelativeTime(meta.installationTime)}
</p>
</div>
);
}
return null;
})()}
</div>
</div>
<p className="text-sm font-medium text-gray-900">
{formatRelativeTime(selectedAgent.last_checkin)}
</p>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2 text-sm text-gray-600">
<Calendar className="h-4 w-4" />
<span>Last Scan:</span>
{/* Connection Status */}
<div className="space-y-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center space-x-2 text-sm text-gray-600">
<Activity className="h-4 w-4" />
<span>Last Check-in</span>
</div>
<p className="text-sm font-medium text-gray-900">
{formatRelativeTime(selectedAgent.last_seen)}
</p>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2 text-sm text-gray-600">
<Calendar className="h-4 w-4" />
<span>Last Scan</span>
</div>
<p className="text-sm font-medium text-gray-900">
{selectedAgent.last_scan
? formatRelativeTime(selectedAgent.last_scan)
: 'Never'}
</p>
</div>
</div>
<p className="text-sm font-medium text-gray-900">
{selectedAgent.last_scan
? formatRelativeTime(selectedAgent.last_scan)
: 'Never'}
</p>
</div>
</div>
</div>
@@ -172,47 +316,134 @@ const Agents: React.FC = () => {
<div className="card">
<h2 className="text-lg font-medium text-gray-900 mb-4">System Information</h2>
<div className="grid grid-cols-2 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic System Info */}
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600">Operating System</p>
<p className="text-sm text-gray-600">Platform</p>
<p className="text-sm font-medium text-gray-900">
{selectedAgent.os_type} {selectedAgent.os_version}
{(() => {
const osInfo = parseOSInfo(selectedAgent);
return osInfo.platform;
})()}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Distribution</p>
<p className="text-sm font-medium text-gray-900">
{(() => {
const osInfo = parseOSInfo(selectedAgent);
return osInfo.distribution;
})()}
</p>
{(() => {
const osInfo = parseOSInfo(selectedAgent);
if (osInfo.version) {
return (
<p className="text-xs text-gray-500 mt-1">
Version: {osInfo.version}
</p>
);
}
return null;
})()}
</div>
<div>
<p className="text-sm text-gray-600">Architecture</p>
<p className="text-sm font-medium text-gray-900">
{selectedAgent.architecture}
</p>
</div>
<div>
<p className="text-sm text-gray-600">IP Address</p>
<p className="text-sm font-medium text-gray-900">
{selectedAgent.ip_address}
{selectedAgent.os_architecture || selectedAgent.architecture}
</p>
</div>
</div>
{/* Hardware Specs */}
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600">Agent Version</p>
<p className="text-sm font-medium text-gray-900">
{selectedAgent.version}
</p>
</div>
{(() => {
const meta = getSystemMetadata(selectedAgent);
return (
<>
<div>
<p className="text-sm text-gray-600 flex items-center">
<Cpu className="h-4 w-4 mr-1" />
CPU
</p>
<p className="text-sm font-medium text-gray-900">
{meta.cpuModel}
</p>
<p className="text-xs text-gray-500">
{meta.cpuCores} cores
</p>
</div>
<div>
<p className="text-sm text-gray-600">Registered</p>
<p className="text-sm font-medium text-gray-900">
{formatRelativeTime(selectedAgent.created_at)}
</p>
</div>
{meta.memoryTotal > 0 && (
<div>
<p className="text-sm text-gray-600 flex items-center">
<MemoryStick className="h-4 w-4 mr-1" />
Memory
</p>
<p className="text-sm font-medium text-gray-900">
{formatBytes(meta.memoryTotal)}
</p>
</div>
)}
{meta.diskTotal > 0 && (
<div>
<p className="text-sm text-gray-600 flex items-center">
<HardDrive className="h-4 w-4 mr-1" />
Disk ({meta.diskMount})
</p>
<p className="text-sm font-medium text-gray-900">
{formatBytes(meta.diskUsed)} / {formatBytes(meta.diskTotal)}
</p>
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${Math.round((meta.diskUsed / meta.diskTotal) * 100)}%` }}
></div>
</div>
<p className="text-xs text-gray-500">
{Math.round((meta.diskUsed / meta.diskTotal) * 100)}% used
</p>
</div>
)}
{meta.processes !== 'Unknown' && (
<div>
<p className="text-sm text-gray-600 flex items-center">
<GitBranch className="h-4 w-4 mr-1" />
Running Processes
</p>
<p className="text-sm font-medium text-gray-900">
{meta.processes}
</p>
</div>
)}
{meta.uptime !== 'Unknown' && (
<div>
<p className="text-sm text-gray-600 flex items-center">
<Clock className="h-4 w-4 mr-1" />
Uptime
</p>
<p className="text-sm font-medium text-gray-900">
{meta.uptime}
</p>
</div>
)}
</>
);
})()}
</div>
</div>
</div>
</div>
{/* System Updates */}
<AgentSystemUpdates agentId={selectedAgent.id} />
</div>
{/* Quick actions */}
@@ -221,25 +452,25 @@ const Agents: React.FC = () => {
<h2 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h2>
<div className="space-y-3">
<button
onClick={() => handleScanAgent(selectedAgent.id)}
disabled={scanAgentMutation.isLoading}
className="w-full btn btn-primary"
>
{scanAgentMutation.isLoading ? (
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Trigger Scan
</button>
<button
onClick={() => navigate(`/updates?agent=${selectedAgent.id}`)}
className="w-full btn btn-secondary"
>
<Package className="h-4 w-4 mr-2" />
View Updates
View All Updates
</button>
<button
onClick={() => handleRemoveAgent(selectedAgent.id, selectedAgent.hostname)}
disabled={unregisterAgentMutation.isPending}
className="w-full btn btn-danger"
>
{unregisterAgentMutation.isPending ? (
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
Remove Agent
</button>
</div>
</div>
@@ -298,10 +529,10 @@ const Agents: React.FC = () => {
{selectedAgents.length > 0 && (
<button
onClick={handleScanSelected}
disabled={scanMultipleMutation.isLoading}
disabled={scanMultipleMutation.isPending}
className="btn btn-primary"
>
{scanMultipleMutation.isLoading ? (
{scanMultipleMutation.isPending ? (
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
@@ -351,7 +582,7 @@ const Agents: React.FC = () => {
</div>
{/* Agents table */}
{isLoading ? (
{isPending ? (
<div className="animate-pulse">
<div className="bg-white rounded-lg border border-gray-200">
{[...Array(5)].map((_, i) => (
@@ -401,7 +632,7 @@ const Agents: React.FC = () => {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredAgents.map((agent) => (
<tr key={agent.id} className="hover:bg-gray-50">
<tr key={agent.id} className="hover:bg-gray-50 group">
<td className="table-cell">
<input
type="checkbox"
@@ -425,30 +656,46 @@ const Agents: React.FC = () => {
</button>
</div>
<div className="text-xs text-gray-500">
{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';
})()}
</div>
</div>
</div>
</td>
<td className="table-cell">
<span className={cn('badge', getStatusColor(agent.status))}>
{agent.status}
<span className={cn('badge', getStatusColor(isOnline(agent.last_seen) ? 'online' : 'offline'))}>
{isOnline(agent.last_seen) ? 'Online' : 'Offline'}
</span>
</td>
<td className="table-cell">
<div className="text-sm text-gray-900">
{agent.os_type}
{(() => {
const osInfo = parseOSInfo(agent);
return osInfo.distribution || agent.os_type;
})()}
</div>
<div className="text-xs text-gray-500">
{agent.architecture}
{(() => {
const osInfo = parseOSInfo(agent);
if (osInfo.version) {
return `${osInfo.version}${agent.os_architecture || agent.architecture}`;
}
return `${agent.os_architecture || agent.architecture}`;
})()}
</div>
</td>
<td className="table-cell">
<div className="text-sm text-gray-900">
{formatRelativeTime(agent.last_checkin)}
{formatRelativeTime(agent.last_seen)}
</div>
<div className="text-xs text-gray-500">
{isOnline(agent.last_checkin) ? 'Online' : 'Offline'}
{isOnline(agent.last_seen) ? 'Online' : 'Offline'}
</div>
</td>
<td className="table-cell">
@@ -462,12 +709,20 @@ const Agents: React.FC = () => {
<div className="flex items-center space-x-2">
<button
onClick={() => handleScanAgent(agent.id)}
disabled={scanAgentMutation.isLoading}
disabled={scanAgentMutation.isPending}
className="text-gray-400 hover:text-primary-600"
title="Trigger scan"
>
<RefreshCw className="h-4 w-4" />
</button>
<button
onClick={() => handleRemoveAgent(agent.id, agent.hostname)}
disabled={unregisterAgentMutation.isPending}
className="text-gray-400 hover:text-red-600"
title="Remove agent"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={() => navigate(`/agents/${agent.id}`)}
className="text-gray-400 hover:text-primary-600"

View File

@@ -7,17 +7,14 @@ import {
AlertTriangle,
XCircle,
RefreshCw,
Activity,
TrendingUp,
Clock,
} from 'lucide-react';
import { useDashboardStats } from '@/hooks/useStats';
import { formatRelativeTime } from '@/lib/utils';
const Dashboard: React.FC = () => {
const { data: stats, isLoading, error } = useDashboardStats();
const { data: stats, isPending, error } = useDashboardStats();
if (isLoading) {
if (isPending) {
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="animate-pulse">

View File

@@ -1,32 +1,131 @@
import React from 'react';
import { Clock } from 'lucide-react';
import { useSettingsStore } from '@/lib/store';
import { useTimezones, useTimezone, useUpdateTimezone } from '../hooks/useSettings';
const Settings: React.FC = () => {
const { autoRefresh, refreshInterval, setAutoRefresh, setRefreshInterval } = useSettingsStore();
const { data: timezones, isLoading: isLoadingTimezones } = useTimezones();
const { data: currentTimezone, isLoading: isLoadingCurrentTimezone } = useTimezone();
const updateTimezone = useUpdateTimezone();
const [selectedTimezone, setSelectedTimezone] = React.useState('');
React.useEffect(() => {
if (currentTimezone?.timezone) {
setSelectedTimezone(currentTimezone.timezone);
}
}, [currentTimezone]);
const handleTimezoneChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newTimezone = e.target.value;
setSelectedTimezone(newTimezone);
try {
await updateTimezone.mutateAsync(newTimezone);
} catch (error) {
console.error('Failed to update timezone:', error);
// Revert on error
if (currentTimezone?.timezone) {
setSelectedTimezone(currentTimezone.timezone);
}
}
};
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="mb-6">
<div className="px-4 sm:px-6 lg:px-8 space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<p className="mt-1 text-sm text-gray-600">
Configure your dashboard preferences
</p>
<p className="mt-1 text-sm text-gray-600">Configure your RedFlag dashboard preferences</p>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
<div className="p-6 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Dashboard Settings</h2>
<p className="mt-1 text-sm text-gray-600">
Configure how the dashboard behaves and displays information
</p>
{/* Timezone Settings */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-gray-100 rounded-lg">
<Clock className="w-5 h-5 text-gray-600" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900">Timezone Settings</h2>
<p className="text-gray-600">Configure the timezone used for displaying timestamps</p>
</div>
</div>
<div className="p-6 space-y-6">
<div className="space-y-4">
<div>
<label htmlFor="timezone" className="block text-sm font-medium text-gray-700 mb-2">
Display Timezone
</label>
<div className="relative">
<select
id="timezone"
value={selectedTimezone}
onChange={handleTimezoneChange}
disabled={isLoadingTimezones || isLoadingCurrentTimezone || updateTimezone.isPending}
className="w-full px-4 py-2 bg-white border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoadingTimezones ? (
<option>Loading timezones...</option>
) : (
timezones?.map((tz) => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))
)}
</select>
{/* Custom dropdown arrow */}
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{updateTimezone.isPending && (
<p className="mt-2 text-sm text-yellow-600">Updating timezone...</p>
)}
{updateTimezone.isSuccess && (
<p className="mt-2 text-sm text-green-600">Timezone updated successfully!</p>
)}
{updateTimezone.isError && (
<p className="mt-2 text-sm text-red-600">
Failed to update timezone. Please try again.
</p>
)}
</div>
<div className="pt-4 border-t border-gray-200">
<p className="text-sm text-gray-600">
This setting affects how timestamps are displayed throughout the dashboard, including agent
last check-in times, scan times, and update timestamps.
</p>
</div>
</div>
</div>
{/* Dashboard Settings */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-gray-100 rounded-lg">
<Clock className="w-5 h-5 text-gray-600" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900">Dashboard Settings</h2>
<p className="text-gray-600">Configure how the dashboard behaves and displays information</p>
</div>
</div>
<div className="space-y-6">
{/* Auto Refresh */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">Auto Refresh</h3>
<p className="text-sm text-gray-500">
<p className="text-sm text-gray-600">
Automatically refresh dashboard data at regular intervals
</p>
</div>
@@ -51,7 +150,7 @@ const Settings: React.FC = () => {
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
disabled={!autoRefresh}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full px-4 py-2 bg-white border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value={10000}>10 seconds</option>
<option value={30000}>30 seconds</option>
@@ -65,6 +164,26 @@ const Settings: React.FC = () => {
</div>
</div>
</div>
{/* Future Settings Sections */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 opacity-60">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-gray-100 rounded-lg">
<Clock className="w-5 h-5 text-gray-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-400">Additional Settings</h2>
<p className="text-gray-500">More configuration options coming soon</p>
</div>
</div>
<div className="space-y-3 text-sm text-gray-500">
<div> Notification preferences</div>
<div> Agent monitoring settings</div>
<div> Data retention policies</div>
<div> API access tokens</div>
</div>
</div>
</div>
);
};

View File

@@ -4,23 +4,23 @@ import {
Package,
CheckCircle,
XCircle,
Clock,
AlertTriangle,
Search,
Filter,
ChevronDown as ChevronDownIcon,
RefreshCw,
Calendar,
Computer,
ExternalLink,
ChevronLeft,
ChevronRight,
AlertTriangle,
Clock,
Calendar,
} from 'lucide-react';
import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates } from '@/hooks/useUpdates';
import { UpdatePackage } from '@/types';
import type { UpdatePackage } from '@/types';
import { getSeverityColor, getStatusColor, getPackageTypeIcon, formatBytes, formatRelativeTime } from '@/lib/utils';
import { useUpdateStore } from '@/lib/store';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
const Updates: React.FC = () => {
const { id } = useParams<{ id?: string }>();
const navigate = useNavigate();
@@ -34,6 +34,8 @@ const Updates: React.FC = () => {
const [agentFilter, setAgentFilter] = useState(searchParams.get('agent') || '');
const [showFilters, setShowFilters] = useState(false);
const [selectedUpdates, setSelectedUpdates] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(parseInt(searchParams.get('page') || '1'));
const [pageSize, setPageSize] = useState(100);
// Store filters in URL
useEffect(() => {
@@ -43,20 +45,24 @@ const Updates: React.FC = () => {
if (severityFilter) params.set('severity', severityFilter);
if (typeFilter) params.set('type', typeFilter);
if (agentFilter) params.set('agent', agentFilter);
if (currentPage > 1) params.set('page', currentPage.toString());
if (pageSize !== 100) params.set('page_size', pageSize.toString());
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
if (newUrl !== window.location.href) {
window.history.replaceState({}, '', newUrl);
}
}, [searchQuery, statusFilter, severityFilter, typeFilter, agentFilter]);
}, [searchQuery, statusFilter, severityFilter, typeFilter, agentFilter, currentPage, pageSize]);
// Fetch updates list
const { data: updatesData, isLoading, error } = useUpdates({
const { data: updatesData, isPending, error } = useUpdates({
search: searchQuery || undefined,
status: statusFilter || undefined,
severity: severityFilter || undefined,
type: typeFilter || undefined,
agent_id: agentFilter || undefined,
agent: agentFilter || undefined,
page: currentPage,
page_size: pageSize,
});
// Fetch single update if ID is provided
@@ -68,7 +74,13 @@ const Updates: React.FC = () => {
const bulkApproveMutation = useApproveMultipleUpdates();
const updates = updatesData?.updates || [];
const selectedUpdate = selectedUpdateData || updates.find(u => u.id === id);
const totalCount = updatesData?.total || 0;
const selectedUpdate = selectedUpdateData || updates.find((u: UpdatePackage) => u.id === id);
// Pagination calculations
const totalPages = Math.ceil(totalCount / pageSize);
const hasNextPage = currentPage < totalPages;
const hasPrevPage = currentPage > 1;
// Handle update selection
const handleSelectUpdate = (updateId: string, checked: boolean) => {
@@ -81,7 +93,7 @@ const Updates: React.FC = () => {
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedUpdates(updates.map(update => update.id));
setSelectedUpdates(updates.map((update: UpdatePackage) => update.id));
} else {
setSelectedUpdates([]);
}
@@ -127,10 +139,74 @@ const Updates: React.FC = () => {
};
// Get unique values for filters
const statuses = [...new Set(updates.map(u => u.status))];
const severities = [...new Set(updates.map(u => u.severity))];
const types = [...new Set(updates.map(u => u.package_type))];
const agents = [...new Set(updates.map(u => u.agent_id))];
const statuses = [...new Set(updates.map((u: UpdatePackage) => u.status))];
const severities = [...new Set(updates.map((u: UpdatePackage) => u.severity))];
const types = [...new Set(updates.map((u: UpdatePackage) => u.package_type))];
const agents = [...new Set(updates.map((u: UpdatePackage) => u.agent_id))];
// Quick filter functions
const handleQuickFilter = (filter: string) => {
switch (filter) {
case 'critical':
setSeverityFilter('critical');
setStatusFilter('pending');
break;
case 'pending':
setStatusFilter('pending');
setSeverityFilter('');
break;
case 'approved':
setStatusFilter('approved');
setSeverityFilter('');
break;
default:
// Clear all filters
setStatusFilter('');
setSeverityFilter('');
setTypeFilter('');
setAgentFilter('');
break;
}
setCurrentPage(1);
};
// Group updates
const groupUpdates = (updates: UpdatePackage[], groupBy: string) => {
const groups: Record<string, UpdatePackage[]> = {};
updates.forEach(update => {
let key: string;
switch (groupBy) {
case 'severity':
key = update.severity;
break;
case 'type':
key = update.package_type;
break;
case 'status':
key = update.status;
break;
default:
key = 'all';
}
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(update);
});
return groups;
};
// Get total statistics from API (not just current page)
const totalStats = {
total: totalCount,
pending: updatesData?.stats?.pending_updates || 0,
approved: updatesData?.stats?.approved_updates || 0,
critical: updatesData?.stats?.critical_updates || 0,
high: updatesData?.stats?.high_updates || 0,
};
// Update detail view
if (id && selectedUpdate) {
@@ -246,7 +322,7 @@ const Updates: React.FC = () => {
<>
<button
onClick={() => handleApproveUpdate(selectedUpdate.id)}
disabled={approveMutation.isLoading}
disabled={approveMutation.isPending}
className="w-full btn btn-success"
>
<CheckCircle className="h-4 w-4 mr-2" />
@@ -255,7 +331,7 @@ const Updates: React.FC = () => {
<button
onClick={() => handleRejectUpdate(selectedUpdate.id)}
disabled={rejectMutation.isLoading}
disabled={rejectMutation.isPending}
className="w-full btn btn-secondary"
>
<XCircle className="h-4 w-4 mr-2" />
@@ -267,7 +343,7 @@ const Updates: React.FC = () => {
{selectedUpdate.status === 'approved' && (
<button
onClick={() => handleInstallUpdate(selectedUpdate.id)}
disabled={installMutation.isLoading}
disabled={installMutation.isPending}
className="w-full btn btn-primary"
>
<Package className="h-4 w-4 mr-2" />
@@ -290,15 +366,150 @@ const Updates: React.FC = () => {
);
}
// Pagination handlers
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(1); // Reset to first page when changing page size
};
// Updates list view
return (
<div className="px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Updates</h1>
<p className="mt-1 text-sm text-gray-600">
Review and approve available updates for your agents
</p>
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Updates</h1>
<p className="mt-1 text-sm text-gray-600">
Review and approve available updates for your agents
</p>
</div>
<div className="text-right">
<div className="text-sm text-gray-600">
Showing {updates.length} of {totalCount} updates
</div>
{totalCount > 100 && (
<select
value={pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
className="mt-1 text-sm border border-gray-300 rounded px-3 py-1"
>
<option value={50}>50 per page</option>
<option value={100}>100 per page</option>
<option value={200}>200 per page</option>
<option value={500}>500 per page</option>
</select>
)}
</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">
<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 Updates</p>
<p className="text-2xl font-bold text-gray-900">{totalStats.total}</p>
</div>
<Package className="h-8 w-8 text-gray-400" />
</div>
</div>
<div className="bg-white p-4 rounded-lg border border-orange-200 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Pending</p>
<p className="text-2xl font-bold text-orange-600">{totalStats.pending}</p>
</div>
<Clock className="h-8 w-8 text-orange-400" />
</div>
</div>
<div className="bg-white p-4 rounded-lg border border-green-200 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Approved</p>
<p className="text-2xl font-bold text-green-600">{totalStats.approved}</p>
</div>
<CheckCircle className="h-8 w-8 text-green-400" />
</div>
</div>
<div className="bg-white p-4 rounded-lg border border-red-200 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Critical</p>
<p className="text-2xl font-bold text-red-600">{totalStats.critical}</p>
</div>
<AlertTriangle className="h-8 w-8 text-red-400" />
</div>
</div>
<div className="bg-white p-4 rounded-lg border border-yellow-200 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">High Priority</p>
<p className="text-2xl font-bold text-yellow-600">{totalStats.high}</p>
</div>
<AlertTriangle className="h-8 w-8 text-yellow-400" />
</div>
</div>
</div>
{/* Quick Filters */}
<div className="flex flex-wrap gap-2 mb-4">
<button
onClick={() => handleQuickFilter('all')}
className={cn(
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
!statusFilter && !severityFilter && !typeFilter && !agentFilter
? "bg-primary-100 border-primary-300 text-primary-700"
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
)}
>
All Updates
</button>
<button
onClick={() => handleQuickFilter('critical')}
className={cn(
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
statusFilter === 'pending' && severityFilter === 'critical'
? "bg-red-100 border-red-300 text-red-700"
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
)}
>
<AlertTriangle className="h-4 w-4 mr-1 inline" />
Critical
</button>
<button
onClick={() => handleQuickFilter('pending')}
className={cn(
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
statusFilter === 'pending' && !severityFilter
? "bg-orange-100 border-orange-300 text-orange-700"
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
)}
>
<Clock className="h-4 w-4 mr-1 inline" />
Pending Approval
</button>
<button
onClick={() => handleQuickFilter('approved')}
className={cn(
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
statusFilter === 'approved' && !severityFilter
? "bg-green-100 border-green-300 text-green-700"
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
)}
>
<CheckCircle className="h-4 w-4 mr-1 inline" />
Approved
</button>
</div>
</div>
{/* Search and filters */}
@@ -336,7 +547,7 @@ const Updates: React.FC = () => {
{selectedUpdates.length > 0 && (
<button
onClick={handleBulkApprove}
disabled={bulkApproveMutation.isLoading}
disabled={bulkApproveMutation.isPending}
className="btn btn-success"
>
<CheckCircle className="h-4 w-4 mr-2" />
@@ -359,7 +570,7 @@ const Updates: React.FC = () => {
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="">All Status</option>
{statuses.map(status => (
{statuses.map((status: string) => (
<option key={status} value={status}>{status}</option>
))}
</select>
@@ -375,7 +586,7 @@ const Updates: React.FC = () => {
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="">All Severities</option>
{severities.map(severity => (
{severities.map((severity: string) => (
<option key={severity} value={severity}>{severity}</option>
))}
</select>
@@ -391,7 +602,7 @@ const Updates: React.FC = () => {
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="">All Types</option>
{types.map(type => (
{types.map((type: string) => (
<option key={type} value={type}>{type.toUpperCase()}</option>
))}
</select>
@@ -407,7 +618,7 @@ const Updates: React.FC = () => {
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="">All Agents</option>
{agents.map(agentId => (
{agents.map((agentId: string) => (
<option key={agentId} value={agentId}>{agentId}</option>
))}
</select>
@@ -418,7 +629,7 @@ const Updates: React.FC = () => {
</div>
{/* Updates table */}
{isLoading ? (
{isPending ? (
<div className="animate-pulse">
<div className="bg-white rounded-lg border border-gray-200">
{[...Array(5)].map((_, i) => (
@@ -469,7 +680,7 @@ const Updates: React.FC = () => {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{updates.map((update) => (
{updates.map((update: UpdatePackage) => (
<tr key={update.id} className="hover:bg-gray-50">
<td className="table-cell">
<input
@@ -540,7 +751,7 @@ const Updates: React.FC = () => {
<>
<button
onClick={() => handleApproveUpdate(update.id)}
disabled={approveMutation.isLoading}
disabled={approveMutation.isPending}
className="text-success-600 hover:text-success-800"
title="Approve"
>
@@ -548,7 +759,7 @@ const Updates: React.FC = () => {
</button>
<button
onClick={() => handleRejectUpdate(update.id)}
disabled={rejectMutation.isLoading}
disabled={rejectMutation.isPending}
className="text-gray-600 hover:text-gray-800"
title="Reject"
>
@@ -560,7 +771,7 @@ const Updates: React.FC = () => {
{update.status === 'approved' && (
<button
onClick={() => handleInstallUpdate(update.id)}
disabled={installMutation.isLoading}
disabled={installMutation.isPending}
className="text-primary-600 hover:text-primary-800"
title="Install"
>
@@ -582,6 +793,88 @@ const Updates: React.FC = () => {
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={!hasPrevPage}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={!hasNextPage}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> to{' '}
<span className="font-medium">{Math.min(currentPage * pageSize, totalCount)}</span> of{' '}
<span className="font-medium">{totalCount}</span> results
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={!hasPrevPage}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">Previous</span>
<ChevronLeft className="h-5 w-5" />
</button>
{/* Page numbers */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => handlePageChange(pageNum)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === pageNum
? 'z-10 bg-primary-50 border-primary-500 text-primary-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{pageNum}
</button>
);
})}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={!hasNextPage}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">Next</span>
<ChevronRight className="h-5 w-5" />
</button>
</nav>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>

View File

@@ -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<string, any>;
// 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<string, string>;
metadata: Record<string, any>;
}
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<string, any>;
}
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';
}

10
aggregator-web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
// more env variables...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}