Update README with current features and screenshots
- Cross-platform support (Windows/Linux) with Windows Updates and Winget - Added dependency confirmation workflow and refresh token authentication - New screenshots: History, Live Operations, Windows Agent Details - Local CLI features with terminal output and cache system - Updated known limitations - Proxmox integration is broken - Organized docs to docs/ folder and updated .gitignore - Probably introduced a dozen bugs with Windows agents - stay tuned
59
.gitignore
vendored
@@ -11,6 +11,13 @@
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# All documentation goes in docs/ folder (private development)
|
||||
docs/
|
||||
*.md
|
||||
!README.md
|
||||
!LICENSE
|
||||
!.env.example
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
@@ -226,6 +233,13 @@ Thumbs.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Compiled binaries (project-specific)
|
||||
redflag-agent
|
||||
redflag-server
|
||||
aggregator-agent/redflag-agent
|
||||
aggregator-server/redflag-server
|
||||
aggregator-server/server
|
||||
|
||||
# Agent configuration (may contain sensitive data)
|
||||
aggregator-agent/config.json
|
||||
aggregator-agent/.agent-id
|
||||
@@ -256,6 +270,11 @@ test-results/
|
||||
.devenv/
|
||||
dev/
|
||||
|
||||
# Development packages and scripts
|
||||
aggregator-agent/pkg/
|
||||
aggregator-server/scripts/
|
||||
aggregator-server/internal/utils/
|
||||
|
||||
# Build artifacts
|
||||
*.tar.gz
|
||||
*.zip
|
||||
@@ -369,9 +388,9 @@ secrets/
|
||||
|
||||
# Screenshots (needed for README)
|
||||
!Screenshots/
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
!Screenshots/*.png
|
||||
!Screenshots/*.jpg
|
||||
!Screenshots/*.jpeg
|
||||
|
||||
# Core functionality (needed for working system)
|
||||
!aggregator-agent/internal/installer/
|
||||
@@ -383,36 +402,6 @@ secrets/
|
||||
# Only minimal README, no other documentation
|
||||
|
||||
# =============================================================================
|
||||
# Exclude detailed documentation and session files (keep private development)
|
||||
# =============================================================================
|
||||
# Exclude ALL documentation files - this is private development
|
||||
*.md
|
||||
!README.md
|
||||
!LICENSE
|
||||
!.env.example
|
||||
!docker-compose.yml
|
||||
!Makefile
|
||||
*.html
|
||||
*.txt
|
||||
|
||||
# Session and development files
|
||||
SESSION_*
|
||||
claude*
|
||||
TECHNICAL_*
|
||||
COMPETITIVE_*
|
||||
PROXMOX_*
|
||||
HOW_TO_*
|
||||
NEXT_*
|
||||
Starting*
|
||||
README_D*
|
||||
README_backup*
|
||||
|
||||
# Setup and documentation files
|
||||
SETUP_*
|
||||
CONTRIBUTING*
|
||||
.github/
|
||||
docs/
|
||||
|
||||
# AI / LLM Development Files
|
||||
.claude/
|
||||
*claude*
|
||||
# =============================================================================
|
||||
.claude/
|
||||
50
README.md
@@ -17,9 +17,10 @@ A self-hosted, cross-platform update management platform built with:
|
||||
|
||||
- Go server backend + PostgreSQL
|
||||
- React web dashboard with TypeScript
|
||||
- Linux agents with APT + Docker scanning
|
||||
- Cross-platform agents (Linux APT/DNF/Docker, Windows Updates/Winget)
|
||||
- Local CLI tools for agent management
|
||||
- Update installation system (alpha)
|
||||
- Update installation system with dependency management
|
||||
- Refresh token authentication for stable agent identity
|
||||
|
||||
## What This Isn't
|
||||
|
||||
@@ -32,34 +33,49 @@ A self-hosted, cross-platform update management platform built with:
|
||||
|
||||
### Working Features
|
||||
- Server backend with REST API
|
||||
- Agent registration and check-in
|
||||
- Update discovery for APT packages and Docker images
|
||||
- Update approval workflow
|
||||
- Web dashboard with agent management
|
||||
- Local CLI tools (--scan, --status, --list-updates, --export)
|
||||
- Update installation system (alpha quality)
|
||||
- Cross-platform agent registration and check-in
|
||||
- Update discovery for APT, DNF, Docker images, Windows Updates, and Winget packages
|
||||
- Update approval workflow with dependency confirmation
|
||||
- Web dashboard with agent management and real-time status
|
||||
- Local CLI tools (--scan, --status, --list-updates, --export, --export=json/csv)
|
||||
- Update installation system with dry-run dependency checking
|
||||
- Beautiful terminal output with colors and severity indicators
|
||||
- Local cache system for offline viewing of scan results
|
||||
- Refresh token authentication for stable agent identity
|
||||
- Event-sourced database architecture for scalability
|
||||
|
||||
### Known Limitations
|
||||
- Update installation is minimally tested
|
||||
- DNF/RPM scanner incomplete
|
||||
- No rate limiting on API endpoints
|
||||
- No Windows agent support
|
||||
- No rate limiting on API endpoints (security improvement needed)
|
||||
- No real-time WebSocket updates
|
||||
- Proxmox integration is broken (needs complete rewrite)
|
||||
- Authentication system works but needs security hardening
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Default Dashboard
|
||||

|
||||
Main overview showing agent status, system metrics, and update statistics
|
||||
### Main Dashboard
|
||||

|
||||
Overview showing agent status, system metrics, and update statistics
|
||||
|
||||
### Updates Management
|
||||

|
||||
Comprehensive update listing with filtering, approval, and bulk operations
|
||||
Comprehensive update listing with filtering, approval, and dependency confirmation
|
||||
|
||||
### Agent Details
|
||||

|
||||

|
||||
Detailed agent information including system specs, last check-in, and individual update management
|
||||
|
||||
### Windows Agent Support
|
||||

|
||||
Cross-platform support for Windows Updates and Winget package management
|
||||
|
||||
### History & Audit Trail
|
||||

|
||||
Complete audit trail of all update activities and command execution
|
||||
|
||||
### Live Operations
|
||||

|
||||
Real-time view of update operations with success/failure tracking
|
||||
|
||||
### Docker Container Management
|
||||

|
||||
Docker-specific interface for container image updates and management
|
||||
|
||||
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 66 KiB |
BIN
Screenshots/RedFlag Agent Details - old.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
Screenshots/RedFlag Agent Details.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
Screenshots/RedFlag History Dashboard.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
Screenshots/RedFlag Live Operations - Failed Dashboard.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 163 KiB |
BIN
Screenshots/RedFlag Windows Agent Details.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
@@ -6,6 +6,8 @@ import (
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/cache"
|
||||
@@ -19,27 +21,68 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
AgentVersion = "0.1.0"
|
||||
ConfigPath = "/etc/aggregator/config.json"
|
||||
AgentVersion = "0.1.5" // Command status synchronization, timeout fixes, DNF improvements
|
||||
)
|
||||
|
||||
// getConfigPath returns the platform-specific config path
|
||||
func getConfigPath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "C:\\ProgramData\\RedFlag\\config.json"
|
||||
}
|
||||
return "/etc/aggregator/config.json"
|
||||
}
|
||||
|
||||
// getDefaultServerURL returns the default server URL with environment variable support
|
||||
func getDefaultServerURL() string {
|
||||
// Check environment variable first
|
||||
if envURL := os.Getenv("REDFLAG_SERVER_URL"); envURL != "" {
|
||||
return envURL
|
||||
}
|
||||
|
||||
// Platform-specific defaults
|
||||
if runtime.GOOS == "windows" {
|
||||
// For Windows, use a placeholder that prompts users to configure
|
||||
return "http://REPLACE_WITH_SERVER_IP:8080"
|
||||
}
|
||||
return "http://localhost:8080"
|
||||
}
|
||||
|
||||
func main() {
|
||||
registerCmd := flag.Bool("register", false, "Register agent with server")
|
||||
scanCmd := flag.Bool("scan", false, "Scan for updates and display locally")
|
||||
statusCmd := flag.Bool("status", false, "Show agent status")
|
||||
listUpdatesCmd := flag.Bool("list-updates", false, "List detailed update information")
|
||||
serverURL := flag.String("server", "http://localhost:8080", "Server URL")
|
||||
serverURL := flag.String("server", getDefaultServerURL(), "Server URL")
|
||||
exportFormat := flag.String("export", "", "Export format: json, csv")
|
||||
flag.Parse()
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load(ConfigPath)
|
||||
cfg, err := config.Load(getConfigPath())
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load configuration:", err)
|
||||
}
|
||||
|
||||
// Handle registration
|
||||
if *registerCmd {
|
||||
// Validate server URL for Windows users
|
||||
if runtime.GOOS == "windows" && strings.Contains(*serverURL, "REPLACE_WITH_SERVER_IP") {
|
||||
fmt.Println("❌ CONFIGURATION REQUIRED!")
|
||||
fmt.Println("==================================================================")
|
||||
fmt.Println("Please configure the server URL before registering:")
|
||||
fmt.Println("")
|
||||
fmt.Println("Option 1 - Use the -server flag:")
|
||||
fmt.Printf(" redflag-agent.exe -register -server http://10.10.20.159:8080\n")
|
||||
fmt.Println("")
|
||||
fmt.Println("Option 2 - Use environment variable:")
|
||||
fmt.Println(" set REDFLAG_SERVER_URL=http://10.10.20.159:8080")
|
||||
fmt.Println(" redflag-agent.exe -register")
|
||||
fmt.Println("")
|
||||
fmt.Println("Option 3 - Create a .env file:")
|
||||
fmt.Println(" REDFLAG_SERVER_URL=http://10.10.20.159:8080")
|
||||
fmt.Println("==================================================================")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := registerAgent(cfg, *serverURL); err != nil {
|
||||
log.Fatal("Registration failed:", err)
|
||||
}
|
||||
@@ -161,6 +204,7 @@ func registerAgent(cfg *config.Config, serverURL string) error {
|
||||
cfg.ServerURL = serverURL
|
||||
cfg.AgentID = resp.AgentID
|
||||
cfg.Token = resp.Token
|
||||
cfg.RefreshToken = resp.RefreshToken
|
||||
|
||||
// Get check-in interval from server config
|
||||
if interval, ok := resp.Config["check_in_interval"].(float64); ok {
|
||||
@@ -170,7 +214,44 @@ func registerAgent(cfg *config.Config, serverURL string) error {
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
return cfg.Save(ConfigPath)
|
||||
return cfg.Save(getConfigPath())
|
||||
}
|
||||
|
||||
// renewTokenIfNeeded handles 401 errors by renewing the agent token using refresh token
|
||||
func renewTokenIfNeeded(apiClient *client.Client, cfg *config.Config, err error) (*client.Client, error) {
|
||||
if err != nil && strings.Contains(err.Error(), "401 Unauthorized") {
|
||||
log.Printf("🔄 Access token expired - attempting renewal with refresh token...")
|
||||
|
||||
// Check if we have a refresh token
|
||||
if cfg.RefreshToken == "" {
|
||||
log.Printf("❌ No refresh token available - re-registration required")
|
||||
return nil, fmt.Errorf("refresh token missing - please re-register agent")
|
||||
}
|
||||
|
||||
// Create temporary client without token for renewal
|
||||
tempClient := client.NewClient(cfg.ServerURL, "")
|
||||
|
||||
// Attempt to renew access token using refresh token
|
||||
if err := tempClient.RenewToken(cfg.AgentID, cfg.RefreshToken); err != nil {
|
||||
log.Printf("❌ Refresh token renewal failed: %v", err)
|
||||
log.Printf("💡 Refresh token may be expired (>90 days) - re-registration required")
|
||||
return nil, fmt.Errorf("refresh token renewal failed: %w - please re-register agent", err)
|
||||
}
|
||||
|
||||
// Update config with new access token (agent ID and refresh token stay the same!)
|
||||
cfg.Token = tempClient.GetToken()
|
||||
|
||||
// Save updated config
|
||||
if err := cfg.Save(getConfigPath()); err != nil {
|
||||
log.Printf("⚠️ Warning: Failed to save renewed access token: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Access token renewed successfully - agent ID maintained: %s", cfg.AgentID)
|
||||
return tempClient, nil
|
||||
}
|
||||
|
||||
// Return original client if no 401 error
|
||||
return apiClient, nil
|
||||
}
|
||||
|
||||
func runAgent(cfg *config.Config) error {
|
||||
@@ -189,6 +270,8 @@ func runAgent(cfg *config.Config) error {
|
||||
aptScanner := scanner.NewAPTScanner()
|
||||
dnfScanner := scanner.NewDNFScanner()
|
||||
dockerScanner, _ := scanner.NewDockerScanner()
|
||||
windowsUpdateScanner := scanner.NewWindowsUpdateScanner()
|
||||
wingetScanner := scanner.NewWingetScanner()
|
||||
|
||||
// Main check-in loop
|
||||
for {
|
||||
@@ -196,14 +279,57 @@ func runAgent(cfg *config.Config) error {
|
||||
jitter := time.Duration(rand.Intn(30)) * time.Second
|
||||
time.Sleep(jitter)
|
||||
|
||||
log.Println("Checking in with server...")
|
||||
log.Printf("Checking in with server... (Agent v%s)", AgentVersion)
|
||||
|
||||
// Get commands from server
|
||||
commands, err := apiClient.GetCommands(cfg.AgentID)
|
||||
// Collect lightweight system metrics
|
||||
sysMetrics, err := system.GetLightweightMetrics()
|
||||
var metrics *client.SystemMetrics
|
||||
if err == nil {
|
||||
metrics = &client.SystemMetrics{
|
||||
CPUPercent: sysMetrics.CPUPercent,
|
||||
MemoryPercent: sysMetrics.MemoryPercent,
|
||||
MemoryUsedGB: sysMetrics.MemoryUsedGB,
|
||||
MemoryTotalGB: sysMetrics.MemoryTotalGB,
|
||||
DiskUsedGB: sysMetrics.DiskUsedGB,
|
||||
DiskTotalGB: sysMetrics.DiskTotalGB,
|
||||
DiskPercent: sysMetrics.DiskPercent,
|
||||
Uptime: sysMetrics.Uptime,
|
||||
Version: AgentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// Get commands from server (with optional metrics)
|
||||
commands, err := apiClient.GetCommands(cfg.AgentID, metrics)
|
||||
if err != nil {
|
||||
log.Printf("Error getting commands: %v\n", err)
|
||||
time.Sleep(time.Duration(cfg.CheckInInterval) * time.Second)
|
||||
continue
|
||||
// Try to renew token if we got a 401 error
|
||||
newClient, renewErr := renewTokenIfNeeded(apiClient, cfg, err)
|
||||
if renewErr != nil {
|
||||
log.Printf("Check-in unsuccessful and token renewal failed: %v\n", renewErr)
|
||||
time.Sleep(time.Duration(cfg.CheckInInterval) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// If token was renewed, update client and retry
|
||||
if newClient != apiClient {
|
||||
log.Printf("🔄 Retrying check-in with renewed token...")
|
||||
apiClient = newClient
|
||||
commands, err = apiClient.GetCommands(cfg.AgentID, metrics)
|
||||
if err != nil {
|
||||
log.Printf("Check-in unsuccessful even after token renewal: %v\n", err)
|
||||
time.Sleep(time.Duration(cfg.CheckInInterval) * time.Second)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
log.Printf("Check-in unsuccessful: %v\n", err)
|
||||
time.Sleep(time.Duration(cfg.CheckInInterval) * time.Second)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if len(commands) == 0 {
|
||||
log.Printf("Check-in successful - no new commands")
|
||||
} else {
|
||||
log.Printf("Check-in successful - received %d command(s)", len(commands))
|
||||
}
|
||||
|
||||
// Process each command
|
||||
@@ -212,18 +338,28 @@ func runAgent(cfg *config.Config) error {
|
||||
|
||||
switch cmd.Type {
|
||||
case "scan_updates":
|
||||
if err := handleScanUpdates(apiClient, cfg, aptScanner, dnfScanner, dockerScanner, cmd.ID); err != nil {
|
||||
if err := handleScanUpdates(apiClient, cfg, aptScanner, dnfScanner, dockerScanner, windowsUpdateScanner, wingetScanner, cmd.ID); err != nil {
|
||||
log.Printf("Error scanning updates: %v\n", err)
|
||||
}
|
||||
|
||||
case "collect_specs":
|
||||
log.Println("Spec collection not yet implemented")
|
||||
|
||||
case "dry_run_update":
|
||||
if err := handleDryRunUpdate(apiClient, cfg, cmd.ID, cmd.Params); err != nil {
|
||||
log.Printf("Error dry running update: %v\n", err)
|
||||
}
|
||||
|
||||
case "install_updates":
|
||||
if err := handleInstallUpdates(apiClient, cfg, cmd.ID, cmd.Params); err != nil {
|
||||
log.Printf("Error installing updates: %v\n", err)
|
||||
}
|
||||
|
||||
case "confirm_dependencies":
|
||||
if err := handleConfirmDependencies(apiClient, cfg, cmd.ID, cmd.Params); err != nil {
|
||||
log.Printf("Error confirming dependencies: %v\n", err)
|
||||
}
|
||||
|
||||
default:
|
||||
log.Printf("Unknown command type: %s\n", cmd.Type)
|
||||
}
|
||||
@@ -234,7 +370,7 @@ func runAgent(cfg *config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner *scanner.APTScanner, dnfScanner *scanner.DNFScanner, dockerScanner *scanner.DockerScanner, commandID string) error {
|
||||
func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner *scanner.APTScanner, dnfScanner *scanner.DNFScanner, dockerScanner *scanner.DockerScanner, windowsUpdateScanner *scanner.WindowsUpdateScanner, wingetScanner *scanner.WingetScanner, commandID string) error {
|
||||
log.Println("Scanning for updates...")
|
||||
|
||||
var allUpdates []client.UpdateReportItem
|
||||
@@ -275,6 +411,30 @@ func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner
|
||||
}
|
||||
}
|
||||
|
||||
// Scan Windows updates
|
||||
if windowsUpdateScanner.IsAvailable() {
|
||||
log.Println(" - Scanning Windows updates...")
|
||||
updates, err := windowsUpdateScanner.Scan()
|
||||
if err != nil {
|
||||
log.Printf(" Windows Update scan failed: %v\n", err)
|
||||
} else {
|
||||
log.Printf(" Found %d Windows updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan Winget packages
|
||||
if wingetScanner.IsAvailable() {
|
||||
log.Println(" - Scanning Winget packages...")
|
||||
updates, err := wingetScanner.Scan()
|
||||
if err != nil {
|
||||
log.Printf(" Winget scan failed: %v\n", err)
|
||||
} else {
|
||||
log.Printf(" Found %d Winget package updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Report to server
|
||||
if len(allUpdates) > 0 {
|
||||
report := client.UpdateReport{
|
||||
@@ -301,6 +461,8 @@ func handleScanCommand(cfg *config.Config, exportFormat string) error {
|
||||
aptScanner := scanner.NewAPTScanner()
|
||||
dnfScanner := scanner.NewDNFScanner()
|
||||
dockerScanner, _ := scanner.NewDockerScanner()
|
||||
windowsUpdateScanner := scanner.NewWindowsUpdateScanner()
|
||||
wingetScanner := scanner.NewWingetScanner()
|
||||
|
||||
fmt.Println("🔍 Scanning for updates...")
|
||||
var allUpdates []client.UpdateReportItem
|
||||
@@ -341,6 +503,30 @@ func handleScanCommand(cfg *config.Config, exportFormat string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Scan Windows updates
|
||||
if windowsUpdateScanner.IsAvailable() {
|
||||
fmt.Println(" - Scanning Windows updates...")
|
||||
updates, err := windowsUpdateScanner.Scan()
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠️ Windows Update scan failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d Windows updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan Winget packages
|
||||
if wingetScanner.IsAvailable() {
|
||||
fmt.Println(" - Scanning Winget packages...")
|
||||
updates, err := wingetScanner.Scan()
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠️ Winget scan failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d Winget package updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Load and update cache
|
||||
localCache, err := cache.Load()
|
||||
if err != nil {
|
||||
@@ -438,7 +624,6 @@ func handleInstallUpdates(apiClient *client.Client, cfg *config.Config, commandI
|
||||
// Parse parameters
|
||||
packageType := ""
|
||||
packageName := ""
|
||||
targetVersion := ""
|
||||
|
||||
if pt, ok := params["package_type"].(string); ok {
|
||||
packageType = pt
|
||||
@@ -446,9 +631,6 @@ func handleInstallUpdates(apiClient *client.Client, cfg *config.Config, commandI
|
||||
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 == "" {
|
||||
@@ -478,7 +660,7 @@ func handleInstallUpdates(apiClient *client.Client, cfg *config.Config, commandI
|
||||
// Multiple packages might be specified in various ways
|
||||
var packageNames []string
|
||||
for key, value := range params {
|
||||
if key != "package_type" && key != "target_version" {
|
||||
if key != "package_type" {
|
||||
if name, ok := value.(string); ok && name != "" {
|
||||
packageNames = append(packageNames, name)
|
||||
}
|
||||
@@ -553,6 +735,232 @@ func handleInstallUpdates(apiClient *client.Client, cfg *config.Config, commandI
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDryRunUpdate handles dry_run_update command
|
||||
func handleDryRunUpdate(apiClient *client.Client, cfg *config.Config, commandID string, params map[string]interface{}) error {
|
||||
log.Println("Performing dry run update...")
|
||||
|
||||
// Parse parameters
|
||||
packageType := ""
|
||||
packageName := ""
|
||||
|
||||
if pt, ok := params["package_type"].(string); ok {
|
||||
packageType = pt
|
||||
}
|
||||
if pn, ok := params["package_name"].(string); ok {
|
||||
packageName = pn
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
if packageType == "" || packageName == "" {
|
||||
return fmt.Errorf("package_type and package_name parameters are 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)
|
||||
}
|
||||
|
||||
// Perform dry run
|
||||
log.Printf("Dry running package: %s (type: %s)", packageName, packageType)
|
||||
result, err := inst.DryRun(packageName)
|
||||
if err != nil {
|
||||
// Report dry run failure
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "dry_run",
|
||||
Result: "failed",
|
||||
Stdout: "",
|
||||
Stderr: fmt.Sprintf("Dry run error: %v", err),
|
||||
ExitCode: 1,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
|
||||
if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
|
||||
log.Printf("Failed to report dry run failure: %v\n", reportErr)
|
||||
}
|
||||
|
||||
return fmt.Errorf("dry run failed: %w", err)
|
||||
}
|
||||
|
||||
// Convert installer.InstallResult to client.InstallResult for reporting
|
||||
clientResult := &client.InstallResult{
|
||||
Success: result.Success,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
ExitCode: result.ExitCode,
|
||||
DurationSeconds: result.DurationSeconds,
|
||||
Action: result.Action,
|
||||
PackagesInstalled: result.PackagesInstalled,
|
||||
ContainersUpdated: result.ContainersUpdated,
|
||||
Dependencies: result.Dependencies,
|
||||
IsDryRun: true,
|
||||
}
|
||||
|
||||
// Report dependencies back to server
|
||||
depReport := client.DependencyReport{
|
||||
PackageName: packageName,
|
||||
PackageType: packageType,
|
||||
Dependencies: result.Dependencies,
|
||||
UpdateID: params["update_id"].(string),
|
||||
DryRunResult: clientResult,
|
||||
}
|
||||
|
||||
if reportErr := apiClient.ReportDependencies(cfg.AgentID, depReport); reportErr != nil {
|
||||
log.Printf("Failed to report dependencies: %v\n", reportErr)
|
||||
return fmt.Errorf("failed to report dependencies: %w", reportErr)
|
||||
}
|
||||
|
||||
// Report dry run success
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "dry_run",
|
||||
Result: "success",
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
ExitCode: result.ExitCode,
|
||||
DurationSeconds: result.DurationSeconds,
|
||||
}
|
||||
|
||||
if len(result.Dependencies) > 0 {
|
||||
logReport.Stdout += fmt.Sprintf("\nDependencies found: %v", result.Dependencies)
|
||||
}
|
||||
|
||||
if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
|
||||
log.Printf("Failed to report dry run success: %v\n", reportErr)
|
||||
}
|
||||
|
||||
if result.Success {
|
||||
log.Printf("✓ Dry run completed successfully in %d seconds\n", result.DurationSeconds)
|
||||
if len(result.Dependencies) > 0 {
|
||||
log.Printf(" Dependencies found: %v\n", result.Dependencies)
|
||||
} else {
|
||||
log.Printf(" No additional dependencies found\n")
|
||||
}
|
||||
} else {
|
||||
log.Printf("✗ Dry run failed after %d seconds\n", result.DurationSeconds)
|
||||
log.Printf(" Error: %s\n", result.ErrorMessage)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleConfirmDependencies handles confirm_dependencies command
|
||||
func handleConfirmDependencies(apiClient *client.Client, cfg *config.Config, commandID string, params map[string]interface{}) error {
|
||||
log.Println("Installing update with confirmed dependencies...")
|
||||
|
||||
// Parse parameters
|
||||
packageType := ""
|
||||
packageName := ""
|
||||
var dependencies []string
|
||||
|
||||
if pt, ok := params["package_type"].(string); ok {
|
||||
packageType = pt
|
||||
}
|
||||
if pn, ok := params["package_name"].(string); ok {
|
||||
packageName = pn
|
||||
}
|
||||
if deps, ok := params["dependencies"].([]interface{}); ok {
|
||||
for _, dep := range deps {
|
||||
if depStr, ok := dep.(string); ok {
|
||||
dependencies = append(dependencies, depStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
if packageType == "" || packageName == "" {
|
||||
return fmt.Errorf("package_type and package_name parameters are 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 with dependencies
|
||||
if len(dependencies) > 0 {
|
||||
action = "install_with_dependencies"
|
||||
log.Printf("Installing package with dependencies: %s (dependencies: %v)", packageName, dependencies)
|
||||
// Install main package + dependencies
|
||||
allPackages := append([]string{packageName}, dependencies...)
|
||||
result, err = inst.InstallMultiple(allPackages)
|
||||
} else {
|
||||
action = "install"
|
||||
log.Printf("Installing package: %s (no dependencies)", packageName)
|
||||
result, err = inst.Install(packageName)
|
||||
}
|
||||
|
||||
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 len(dependencies) > 0 {
|
||||
logReport.Stdout += fmt.Sprintf("\nDependencies included: %v", dependencies)
|
||||
}
|
||||
|
||||
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 with dependencies 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 with dependencies 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)
|
||||
|
||||
@@ -16,6 +16,7 @@ require (
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
@@ -23,6 +24,7 @@ require (
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
|
||||
@@ -23,6 +23,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -47,6 +49,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f h1:v+bqkkvZj6Oasqi58jzJk03XO0vaXvdb6SS9U1Rbqpw=
|
||||
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f/go.mod h1:Zt2M6t3i/fnWviIZkuw9wGn2E185P/rWZTqJkIrViGY=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
@@ -94,6 +98,7 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
||||
221
aggregator-agent/install.sh
Executable file
@@ -0,0 +1,221 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# RedFlag Agent Installation Script
|
||||
# This script installs the RedFlag agent as a systemd service with proper permissions
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
AGENT_USER="redflag-agent"
|
||||
AGENT_HOME="/var/lib/redflag-agent"
|
||||
AGENT_BINARY="/usr/local/bin/redflag-agent"
|
||||
SUDOERS_FILE="/etc/sudoers.d/redflag-agent"
|
||||
SERVICE_FILE="/etc/systemd/system/redflag-agent.service"
|
||||
|
||||
echo "=== RedFlag Agent Installation ==="
|
||||
echo ""
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "ERROR: This script must be run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to create user if doesn't exist
|
||||
create_user() {
|
||||
if id "$AGENT_USER" &>/dev/null; then
|
||||
echo "✓ User $AGENT_USER already exists"
|
||||
else
|
||||
echo "Creating system user $AGENT_USER..."
|
||||
useradd -r -s /bin/false -d "$AGENT_HOME" -m "$AGENT_USER"
|
||||
echo "✓ User $AGENT_USER created"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to build agent binary
|
||||
build_agent() {
|
||||
echo "Building agent binary..."
|
||||
cd "$SCRIPT_DIR"
|
||||
go build -o redflag-agent ./cmd/agent
|
||||
echo "✓ Agent binary built"
|
||||
}
|
||||
|
||||
# Function to install agent binary
|
||||
install_binary() {
|
||||
echo "Installing agent binary to $AGENT_BINARY..."
|
||||
cp "$SCRIPT_DIR/redflag-agent" "$AGENT_BINARY"
|
||||
chmod 755 "$AGENT_BINARY"
|
||||
chown root:root "$AGENT_BINARY"
|
||||
echo "✓ Agent binary installed"
|
||||
}
|
||||
|
||||
# Function to install sudoers configuration
|
||||
install_sudoers() {
|
||||
echo "Installing sudoers configuration..."
|
||||
cat > "$SUDOERS_FILE" <<'EOF'
|
||||
# RedFlag Agent minimal sudo permissions
|
||||
# This file is generated automatically during RedFlag agent installation
|
||||
|
||||
# APT package management commands
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get update
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get upgrade -y
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install --dry-run --yes *
|
||||
|
||||
# DNF package management commands
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf refresh -y
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf upgrade -y
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install --assumeno --downloadonly *
|
||||
|
||||
# Docker operations (uncomment if needed)
|
||||
# redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker pull *
|
||||
# redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker image inspect *
|
||||
# redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker manifest inspect *
|
||||
EOF
|
||||
|
||||
chmod 440 "$SUDOERS_FILE"
|
||||
|
||||
# Validate sudoers file
|
||||
if visudo -c -f "$SUDOERS_FILE"; then
|
||||
echo "✓ Sudoers configuration installed and validated"
|
||||
else
|
||||
echo "ERROR: Sudoers configuration is invalid"
|
||||
rm -f "$SUDOERS_FILE"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to install systemd service
|
||||
install_service() {
|
||||
echo "Installing systemd service..."
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=RedFlag Update Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$AGENT_USER
|
||||
Group=$AGENT_USER
|
||||
WorkingDirectory=$AGENT_HOME
|
||||
ExecStart=$AGENT_BINARY
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=$AGENT_HOME
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
chmod 644 "$SERVICE_FILE"
|
||||
echo "✓ Systemd service installed"
|
||||
}
|
||||
|
||||
# Function to start and enable service
|
||||
start_service() {
|
||||
echo "Reloading systemd daemon..."
|
||||
systemctl daemon-reload
|
||||
|
||||
# Stop service if running
|
||||
if systemctl is-active --quiet redflag-agent; then
|
||||
echo "Stopping existing service..."
|
||||
systemctl stop redflag-agent
|
||||
fi
|
||||
|
||||
echo "Enabling and starting redflag-agent service..."
|
||||
systemctl enable redflag-agent
|
||||
systemctl start redflag-agent
|
||||
|
||||
# Wait a moment for service to start
|
||||
sleep 2
|
||||
|
||||
echo "✓ Service started"
|
||||
}
|
||||
|
||||
# Function to show status
|
||||
show_status() {
|
||||
echo ""
|
||||
echo "=== Service Status ==="
|
||||
systemctl status redflag-agent --no-pager -l
|
||||
echo ""
|
||||
echo "=== Recent Logs ==="
|
||||
journalctl -u redflag-agent -n 20 --no-pager
|
||||
}
|
||||
|
||||
# Function to register agent
|
||||
register_agent() {
|
||||
local server_url="${1:-http://localhost:8080}"
|
||||
|
||||
echo "Registering agent with server at $server_url..."
|
||||
|
||||
# Create config directory
|
||||
mkdir -p /etc/aggregator
|
||||
|
||||
# Register agent (run as regular binary, not as service)
|
||||
if "$AGENT_BINARY" -register -server "$server_url"; then
|
||||
echo "✓ Agent registered successfully"
|
||||
else
|
||||
echo "ERROR: Agent registration failed"
|
||||
echo "Please ensure the RedFlag server is running at $server_url"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Main installation flow
|
||||
SERVER_URL="${1:-http://localhost:8080}"
|
||||
|
||||
echo "Step 1: Creating system user..."
|
||||
create_user
|
||||
|
||||
echo ""
|
||||
echo "Step 2: Building agent binary..."
|
||||
build_agent
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Installing agent binary..."
|
||||
install_binary
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Registering agent with server..."
|
||||
register_agent "$SERVER_URL"
|
||||
|
||||
echo ""
|
||||
echo "Step 5: Setting config file permissions..."
|
||||
chown redflag-agent:redflag-agent /etc/aggregator/config.json
|
||||
chmod 600 /etc/aggregator/config.json
|
||||
|
||||
echo ""
|
||||
echo "Step 6: Installing sudoers configuration..."
|
||||
install_sudoers
|
||||
|
||||
echo ""
|
||||
echo "Step 7: Installing systemd service..."
|
||||
install_service
|
||||
|
||||
echo ""
|
||||
echo "Step 8: Starting service..."
|
||||
start_service
|
||||
|
||||
echo ""
|
||||
echo "=== Installation Complete ==="
|
||||
echo ""
|
||||
echo "The RedFlag agent is now installed and running as a systemd service."
|
||||
echo "Server URL: $SERVER_URL"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " - Check status: sudo systemctl status redflag-agent"
|
||||
echo " - View logs: sudo journalctl -u redflag-agent -f"
|
||||
echo " - Restart: sudo systemctl restart redflag-agent"
|
||||
echo " - Stop: sudo systemctl stop redflag-agent"
|
||||
echo " - Disable: sudo systemctl disable redflag-agent"
|
||||
echo ""
|
||||
echo "Note: To re-register with a different server, edit /etc/aggregator/config.json"
|
||||
echo ""
|
||||
|
||||
show_status
|
||||
@@ -32,6 +32,16 @@ func NewClient(baseURL, token string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken returns the current JWT token
|
||||
func (c *Client) GetToken() string {
|
||||
return c.token
|
||||
}
|
||||
|
||||
// SetToken updates the JWT token
|
||||
func (c *Client) SetToken(token string) {
|
||||
c.token = token
|
||||
}
|
||||
|
||||
// RegisterRequest is the payload for agent registration
|
||||
type RegisterRequest struct {
|
||||
Hostname string `json:"hostname"`
|
||||
@@ -44,9 +54,10 @@ type RegisterRequest struct {
|
||||
|
||||
// RegisterResponse is returned after successful registration
|
||||
type RegisterResponse struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Token string `json:"token"` // Short-lived access token (24h)
|
||||
RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d)
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
// Register registers the agent with the server
|
||||
@@ -86,6 +97,59 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// TokenRenewalRequest is the payload for token renewal using refresh token
|
||||
type TokenRenewalRequest struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// TokenRenewalResponse is returned after successful token renewal
|
||||
type TokenRenewalResponse struct {
|
||||
Token string `json:"token"` // New short-lived access token (24h)
|
||||
}
|
||||
|
||||
// RenewToken uses refresh token to get a new access token (proper implementation)
|
||||
func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string) error {
|
||||
url := fmt.Sprintf("%s/api/v1/agents/renew", c.baseURL)
|
||||
|
||||
renewalReq := TokenRenewalRequest{
|
||||
AgentID: agentID,
|
||||
RefreshToken: refreshToken,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(renewalReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("token renewal failed: %s - %s", resp.Status, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result TokenRenewalResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update client token
|
||||
c.token = result.Token
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Command represents a command from the server
|
||||
type Command struct {
|
||||
ID string `json:"id"`
|
||||
@@ -98,14 +162,45 @@ type CommandsResponse struct {
|
||||
Commands []Command `json:"commands"`
|
||||
}
|
||||
|
||||
// SystemMetrics represents lightweight system metrics sent with check-ins
|
||||
type SystemMetrics struct {
|
||||
CPUPercent float64 `json:"cpu_percent,omitempty"`
|
||||
MemoryPercent float64 `json:"memory_percent,omitempty"`
|
||||
MemoryUsedGB float64 `json:"memory_used_gb,omitempty"`
|
||||
MemoryTotalGB float64 `json:"memory_total_gb,omitempty"`
|
||||
DiskUsedGB float64 `json:"disk_used_gb,omitempty"`
|
||||
DiskTotalGB float64 `json:"disk_total_gb,omitempty"`
|
||||
DiskPercent float64 `json:"disk_percent,omitempty"`
|
||||
Uptime string `json:"uptime,omitempty"`
|
||||
Version string `json:"version,omitempty"` // Agent version
|
||||
}
|
||||
|
||||
// GetCommands retrieves pending commands from the server
|
||||
func (c *Client) GetCommands(agentID uuid.UUID) ([]Command, error) {
|
||||
// Optionally sends lightweight system metrics in the request
|
||||
func (c *Client) GetCommands(agentID uuid.UUID, metrics *SystemMetrics) ([]Command, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/agents/%s/commands", c.baseURL, agentID)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var req *http.Request
|
||||
var err error
|
||||
|
||||
// If metrics provided, send them in request body
|
||||
if metrics != nil {
|
||||
body, err := json.Marshal(metrics)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err = http.NewRequest("GET", url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req, err = http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
@@ -220,6 +315,60 @@ func (c *Client) ReportLog(agentID uuid.UUID, report LogReport) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DependencyReport represents a dependency report after dry run
|
||||
type DependencyReport struct {
|
||||
PackageName string `json:"package_name"`
|
||||
PackageType string `json:"package_type"`
|
||||
Dependencies []string `json:"dependencies"`
|
||||
UpdateID string `json:"update_id"`
|
||||
DryRunResult *InstallResult `json:"dry_run_result,omitempty"`
|
||||
}
|
||||
|
||||
// InstallResult represents the result of a package installation attempt
|
||||
type InstallResult struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
Action string `json:"action,omitempty"`
|
||||
PackagesInstalled []string `json:"packages_installed,omitempty"`
|
||||
ContainersUpdated []string `json:"containers_updated,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
IsDryRun bool `json:"is_dry_run"`
|
||||
}
|
||||
|
||||
// ReportDependencies sends dependency report to the server
|
||||
func (c *Client) ReportDependencies(agentID uuid.UUID, report DependencyReport) error {
|
||||
url := fmt.Sprintf("%s/api/v1/agents/%s/dependencies", c.baseURL, agentID)
|
||||
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("failed to report dependencies: %s - %s", resp.Status, string(bodyBytes))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DetectSystem returns basic system information (deprecated, use system.GetSystemInfo instead)
|
||||
func DetectSystem() (osType, osVersion, osArch string) {
|
||||
osType = runtime.GOOS
|
||||
|
||||
@@ -13,7 +13,8 @@ import (
|
||||
type Config struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
Token string `json:"token"` // Short-lived access token (24h)
|
||||
RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d)
|
||||
CheckInInterval int `json:"check_in_interval"`
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,21 @@ package installer
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
)
|
||||
|
||||
// APTInstaller handles APT package installations
|
||||
type APTInstaller struct{}
|
||||
type APTInstaller struct {
|
||||
executor *SecureCommandExecutor
|
||||
}
|
||||
|
||||
// NewAPTInstaller creates a new APT installer
|
||||
func NewAPTInstaller() *APTInstaller {
|
||||
return &APTInstaller{}
|
||||
return &APTInstaller{
|
||||
executor: NewSecureCommandExecutor(),
|
||||
}
|
||||
}
|
||||
|
||||
// IsAvailable checks if APT is available on this system
|
||||
@@ -26,37 +30,34 @@ func (i *APTInstaller) IsAvailable() bool {
|
||||
func (i *APTInstaller) Install(packageName string) (*InstallResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Update package cache first
|
||||
updateCmd := exec.Command("sudo", "apt-get", "update")
|
||||
if output, err := updateCmd.CombinedOutput(); err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("Failed to update APT cache: %v\nStdout: %s", err, string(output)),
|
||||
DurationSeconds: int(time.Since(startTime).Seconds()),
|
||||
}, fmt.Errorf("apt-get update failed: %w", err)
|
||||
// Update package cache first using secure executor
|
||||
updateResult, err := i.executor.ExecuteCommand("apt-get", []string{"update"})
|
||||
if err != nil {
|
||||
updateResult.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
updateResult.ErrorMessage = fmt.Sprintf("Failed to update APT cache: %v", err)
|
||||
return updateResult, fmt.Errorf("apt-get update failed: %w", err)
|
||||
}
|
||||
|
||||
// Install package
|
||||
installCmd := exec.Command("sudo", "apt-get", "install", "-y", packageName)
|
||||
output, err := installCmd.CombinedOutput()
|
||||
// Install package using secure executor
|
||||
installResult, err := i.executor.ExecuteCommand("apt-get", []string{"install", "-y", packageName})
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
if err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("APT install failed: %v", err),
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: getExitCode(err),
|
||||
Stdout: installResult.Stdout,
|
||||
Stderr: installResult.Stderr,
|
||||
ExitCode: installResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
}, err
|
||||
}
|
||||
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: 0,
|
||||
Stdout: installResult.Stdout,
|
||||
Stderr: installResult.Stderr,
|
||||
ExitCode: installResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
PackagesInstalled: []string{packageName},
|
||||
}, nil
|
||||
@@ -73,39 +74,36 @@ func (i *APTInstaller) InstallMultiple(packageNames []string) (*InstallResult, e
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Update package cache first
|
||||
updateCmd := exec.Command("sudo", "apt-get", "update")
|
||||
if output, err := updateCmd.CombinedOutput(); err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("Failed to update APT cache: %v\nStdout: %s", err, string(output)),
|
||||
DurationSeconds: int(time.Since(startTime).Seconds()),
|
||||
}, fmt.Errorf("apt-get update failed: %w", err)
|
||||
// Update package cache first using secure executor
|
||||
updateResult, err := i.executor.ExecuteCommand("apt-get", []string{"update"})
|
||||
if err != nil {
|
||||
updateResult.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
updateResult.ErrorMessage = fmt.Sprintf("Failed to update APT cache: %v", err)
|
||||
return updateResult, fmt.Errorf("apt-get update failed: %w", err)
|
||||
}
|
||||
|
||||
// Install all packages in one command
|
||||
// Install all packages in one command using secure executor
|
||||
args := []string{"install", "-y"}
|
||||
args = append(args, packageNames...)
|
||||
installCmd := exec.Command("sudo", "apt-get", args...)
|
||||
output, err := installCmd.CombinedOutput()
|
||||
installResult, err := i.executor.ExecuteCommand("apt-get", args)
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
if err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("APT install failed: %v", err),
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: getExitCode(err),
|
||||
Stdout: installResult.Stdout,
|
||||
Stderr: installResult.Stderr,
|
||||
ExitCode: installResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
}, err
|
||||
}
|
||||
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: 0,
|
||||
Stdout: installResult.Stdout,
|
||||
Stderr: installResult.Stderr,
|
||||
ExitCode: installResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
PackagesInstalled: packageNames,
|
||||
}, nil
|
||||
@@ -115,42 +113,148 @@ func (i *APTInstaller) InstallMultiple(packageNames []string) (*InstallResult, e
|
||||
func (i *APTInstaller) Upgrade() (*InstallResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Update package cache first
|
||||
updateCmd := exec.Command("sudo", "apt-get", "update")
|
||||
if output, err := updateCmd.CombinedOutput(); err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("Failed to update APT cache: %v\nStdout: %s", err, string(output)),
|
||||
DurationSeconds: int(time.Since(startTime).Seconds()),
|
||||
}, fmt.Errorf("apt-get update failed: %w", err)
|
||||
// Update package cache first using secure executor
|
||||
updateResult, err := i.executor.ExecuteCommand("apt-get", []string{"update"})
|
||||
if err != nil {
|
||||
updateResult.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
updateResult.ErrorMessage = fmt.Sprintf("Failed to update APT cache: %v", err)
|
||||
return updateResult, fmt.Errorf("apt-get update failed: %w", err)
|
||||
}
|
||||
|
||||
// Upgrade all packages
|
||||
upgradeCmd := exec.Command("sudo", "apt-get", "upgrade", "-y")
|
||||
output, err := upgradeCmd.CombinedOutput()
|
||||
// Upgrade all packages using secure executor
|
||||
upgradeResult, err := i.executor.ExecuteCommand("apt-get", []string{"upgrade", "-y"})
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
if err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("APT upgrade failed: %v", err),
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: getExitCode(err),
|
||||
Stdout: upgradeResult.Stdout,
|
||||
Stderr: upgradeResult.Stderr,
|
||||
ExitCode: upgradeResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
}, err
|
||||
}
|
||||
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: 0,
|
||||
Stdout: upgradeResult.Stdout,
|
||||
Stderr: upgradeResult.Stderr,
|
||||
ExitCode: upgradeResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
Action: "upgrade",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DryRun performs a dry run installation to check dependencies
|
||||
func (i *APTInstaller) DryRun(packageName string) (*InstallResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Update package cache first using secure executor
|
||||
updateResult, err := i.executor.ExecuteCommand("apt-get", []string{"update"})
|
||||
if err != nil {
|
||||
updateResult.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
updateResult.ErrorMessage = fmt.Sprintf("Failed to update APT cache: %v", err)
|
||||
updateResult.IsDryRun = true
|
||||
return updateResult, fmt.Errorf("apt-get update failed: %w", err)
|
||||
}
|
||||
|
||||
// Perform dry run installation using secure executor
|
||||
installResult, err := i.executor.ExecuteCommand("apt-get", []string{"install", "--dry-run", "--yes", packageName})
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
// Parse dependencies from the output
|
||||
dependencies := i.parseDependenciesFromAPTOutput(installResult.Stdout, packageName)
|
||||
|
||||
if err != nil {
|
||||
// APT dry run may return non-zero exit code even for successful dependency resolution
|
||||
// so we check if we were able to parse dependencies
|
||||
if len(dependencies) > 0 {
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: installResult.Stdout,
|
||||
Stderr: installResult.Stderr,
|
||||
ExitCode: installResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
Dependencies: dependencies,
|
||||
IsDryRun: true,
|
||||
Action: "dry_run",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("APT dry run failed: %v", err),
|
||||
Stdout: installResult.Stdout,
|
||||
Stderr: installResult.Stderr,
|
||||
ExitCode: installResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
IsDryRun: true,
|
||||
Action: "dry_run",
|
||||
}, err
|
||||
}
|
||||
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: installResult.Stdout,
|
||||
Stderr: installResult.Stderr,
|
||||
ExitCode: installResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
Dependencies: dependencies,
|
||||
IsDryRun: true,
|
||||
Action: "dry_run",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseDependenciesFromAPTOutput extracts dependency package names from APT dry run output
|
||||
func (i *APTInstaller) parseDependenciesFromAPTOutput(output string, packageName string) []string {
|
||||
var dependencies []string
|
||||
|
||||
// Regex patterns to find dependencies in APT output
|
||||
patterns := []*regexp.Regexp{
|
||||
// Match "The following additional packages will be installed:" section
|
||||
regexp.MustCompile(`(?s)The following additional packages will be installed:(.*?)(\n\n|\z)`),
|
||||
// Match "The following NEW packages will be installed:" section
|
||||
regexp.MustCompile(`(?s)The following NEW packages will be installed:(.*?)(\n\n|\z)`),
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
matches := pattern.FindStringSubmatch(output)
|
||||
if len(matches) > 1 {
|
||||
// Extract package names from the matched section
|
||||
packageLines := strings.Split(matches[1], "\n")
|
||||
for _, line := range packageLines {
|
||||
line = strings.TrimSpace(line)
|
||||
// Skip empty lines and section headers
|
||||
if line != "" && !strings.Contains(line, "will be installed") && !strings.Contains(line, "packages") {
|
||||
// Extract package names (they're typically space-separated)
|
||||
packages := strings.Fields(line)
|
||||
for _, pkg := range packages {
|
||||
pkg = strings.TrimSpace(pkg)
|
||||
// Filter out common non-package words
|
||||
if pkg != "" && !strings.Contains(pkg, "recommended") &&
|
||||
!strings.Contains(pkg, "suggested") && !strings.Contains(pkg, "following") {
|
||||
dependencies = append(dependencies, pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and filter out the original package
|
||||
uniqueDeps := make([]string, 0)
|
||||
seen := make(map[string]bool)
|
||||
for _, dep := range dependencies {
|
||||
if dep != packageName && !seen[dep] {
|
||||
seen[dep] = true
|
||||
uniqueDeps = append(uniqueDeps, dep)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueDeps
|
||||
}
|
||||
|
||||
// GetPackageType returns type of packages this installer handles
|
||||
func (i *APTInstaller) GetPackageType() string {
|
||||
return "apt"
|
||||
|
||||
@@ -2,18 +2,23 @@ package installer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
)
|
||||
|
||||
// DNFInstaller handles DNF package installations
|
||||
type DNFInstaller struct{}
|
||||
type DNFInstaller struct {
|
||||
executor *SecureCommandExecutor
|
||||
}
|
||||
|
||||
// NewDNFInstaller creates a new DNF installer
|
||||
func NewDNFInstaller() *DNFInstaller {
|
||||
return &DNFInstaller{}
|
||||
return &DNFInstaller{
|
||||
executor: NewSecureCommandExecutor(),
|
||||
}
|
||||
}
|
||||
|
||||
// IsAvailable checks if DNF is available on this system
|
||||
@@ -26,38 +31,36 @@ func (i *DNFInstaller) IsAvailable() bool {
|
||||
func (i *DNFInstaller) Install(packageName string) (*InstallResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Refresh package cache first
|
||||
refreshCmd := exec.Command("sudo", "dnf", "refresh", "-y")
|
||||
if output, err := refreshCmd.CombinedOutput(); err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("Failed to refresh DNF cache: %v\nStdout: %s", err, string(output)),
|
||||
DurationSeconds: int(time.Since(startTime).Seconds()),
|
||||
}, fmt.Errorf("dnf refresh failed: %w", err)
|
||||
// Refresh package cache first using secure executor
|
||||
refreshResult, err := i.executor.ExecuteCommand("dnf", []string{"makecache"})
|
||||
if err != nil {
|
||||
refreshResult.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
refreshResult.ErrorMessage = fmt.Sprintf("Failed to refresh DNF cache: %v", err)
|
||||
return refreshResult, fmt.Errorf("dnf refresh failed: %w", err)
|
||||
}
|
||||
|
||||
// Install package
|
||||
installCmd := exec.Command("sudo", "dnf", "install", "-y", packageName)
|
||||
output, err := installCmd.CombinedOutput()
|
||||
// Install package using secure executor
|
||||
installResult, err := i.executor.ExecuteCommand("dnf", []string{"install", "-y", packageName})
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
if err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("DNF install failed: %v", err),
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: getExitCode(err),
|
||||
Stdout: installResult.Stdout,
|
||||
Stderr: installResult.Stderr,
|
||||
ExitCode: installResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
}, err
|
||||
}
|
||||
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: 0,
|
||||
Stdout: installResult.Stdout,
|
||||
Stderr: installResult.Stderr,
|
||||
ExitCode: installResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
PackagesInstalled: []string{packageName},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -72,39 +75,36 @@ func (i *DNFInstaller) InstallMultiple(packageNames []string) (*InstallResult, e
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Refresh package cache first
|
||||
refreshCmd := exec.Command("sudo", "dnf", "refresh", "-y")
|
||||
if output, err := refreshCmd.CombinedOutput(); err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("Failed to refresh DNF cache: %v\nStdout: %s", err, string(output)),
|
||||
DurationSeconds: int(time.Since(startTime).Seconds()),
|
||||
}, fmt.Errorf("dnf refresh failed: %w", err)
|
||||
// Refresh package cache first using secure executor
|
||||
refreshResult, err := i.executor.ExecuteCommand("dnf", []string{"makecache"})
|
||||
if err != nil {
|
||||
refreshResult.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
refreshResult.ErrorMessage = fmt.Sprintf("Failed to refresh DNF cache: %v", err)
|
||||
return refreshResult, fmt.Errorf("dnf refresh failed: %w", err)
|
||||
}
|
||||
|
||||
// Install all packages in one command
|
||||
// Install all packages in one command using secure executor
|
||||
args := []string{"install", "-y"}
|
||||
args = append(args, packageNames...)
|
||||
installCmd := exec.Command("sudo", "dnf", args...)
|
||||
output, err := installCmd.CombinedOutput()
|
||||
installResult, err := i.executor.ExecuteCommand("dnf", args)
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
if err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("DNF install failed: %v", err),
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: getExitCode(err),
|
||||
Stdout: installResult.Stdout,
|
||||
Stderr: installResult.Stderr,
|
||||
ExitCode: installResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
}, err
|
||||
}
|
||||
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: 0,
|
||||
Stdout: installResult.Stdout,
|
||||
Stderr: installResult.Stderr,
|
||||
ExitCode: installResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
PackagesInstalled: packageNames,
|
||||
}, nil
|
||||
@@ -114,42 +114,191 @@ func (i *DNFInstaller) InstallMultiple(packageNames []string) (*InstallResult, e
|
||||
func (i *DNFInstaller) Upgrade() (*InstallResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Refresh package cache first
|
||||
refreshCmd := exec.Command("sudo", "dnf", "refresh", "-y")
|
||||
if output, err := refreshCmd.CombinedOutput(); err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("Failed to refresh DNF cache: %v\nStdout: %s", err, string(output)),
|
||||
DurationSeconds: int(time.Since(startTime).Seconds()),
|
||||
}, fmt.Errorf("dnf refresh failed: %w", err)
|
||||
// Refresh package cache first using secure executor
|
||||
refreshResult, err := i.executor.ExecuteCommand("dnf", []string{"makecache"})
|
||||
if err != nil {
|
||||
refreshResult.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
refreshResult.ErrorMessage = fmt.Sprintf("Failed to refresh DNF cache: %v", err)
|
||||
return refreshResult, fmt.Errorf("dnf refresh failed: %w", err)
|
||||
}
|
||||
|
||||
// Upgrade all packages
|
||||
upgradeCmd := exec.Command("sudo", "dnf", "upgrade", "-y")
|
||||
output, err := upgradeCmd.CombinedOutput()
|
||||
// Upgrade all packages using secure executor
|
||||
upgradeResult, err := i.executor.ExecuteCommand("dnf", []string{"upgrade", "-y"})
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
if err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("DNF upgrade failed: %v", err),
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: getExitCode(err),
|
||||
Stdout: upgradeResult.Stdout,
|
||||
Stderr: upgradeResult.Stderr,
|
||||
ExitCode: upgradeResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
}, err
|
||||
}
|
||||
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: 0,
|
||||
Stdout: upgradeResult.Stdout,
|
||||
Stderr: upgradeResult.Stderr,
|
||||
ExitCode: upgradeResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
Action: "upgrade",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DryRun performs a dry run installation to check dependencies
|
||||
func (i *DNFInstaller) DryRun(packageName string) (*InstallResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Attempt to refresh package cache, but don't fail if it doesn't work
|
||||
// (dry run can still work with slightly stale cache)
|
||||
refreshResult, refreshErr := i.executor.ExecuteCommand("dnf", []string{"makecache"})
|
||||
if refreshErr != nil {
|
||||
// Log refresh attempt but don't fail the dry run
|
||||
log.Printf("Warning: DNF makecache failed (continuing with dry run): %v", refreshErr)
|
||||
}
|
||||
_ = refreshResult // Discard refresh result intentionally
|
||||
|
||||
// Perform dry run installation using secure executor
|
||||
installResult, err := i.executor.ExecuteCommand("dnf", []string{"install", "--assumeno", "--downloadonly", packageName})
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
// Parse dependencies from the output
|
||||
dependencies := i.parseDependenciesFromDNFOutput(installResult.Stdout, packageName)
|
||||
|
||||
if err != nil {
|
||||
// DNF dry run may return non-zero exit code even for successful dependency resolution
|
||||
// so we check if we were able to parse dependencies
|
||||
if len(dependencies) > 0 {
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: installResult.Stdout,
|
||||
Stderr: installResult.Stderr,
|
||||
ExitCode: installResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
Dependencies: dependencies,
|
||||
IsDryRun: true,
|
||||
Action: "dry_run",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("DNF dry run failed: %v", err),
|
||||
Stdout: installResult.Stdout,
|
||||
Stderr: installResult.Stderr,
|
||||
ExitCode: installResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
IsDryRun: true,
|
||||
Action: "dry_run",
|
||||
}, err
|
||||
}
|
||||
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: installResult.Stdout,
|
||||
Stderr: installResult.Stderr,
|
||||
ExitCode: installResult.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
Dependencies: dependencies,
|
||||
IsDryRun: true,
|
||||
Action: "dry_run",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseDependenciesFromDNFOutput extracts dependency package names from DNF dry run output
|
||||
func (i *DNFInstaller) parseDependenciesFromDNFOutput(output string, packageName string) []string {
|
||||
var dependencies []string
|
||||
|
||||
// Regex patterns to find dependencies in DNF output
|
||||
patterns := []*regexp.Regexp{
|
||||
// Match "Installing dependencies:" section
|
||||
regexp.MustCompile(`(?s)Installing dependencies:(.*?)(\n\n|\z|Transaction Summary:)`),
|
||||
// Match "Dependencies resolved." section and package list
|
||||
regexp.MustCompile(`(?s)Dependencies resolved\.(.*?)(\n\n|\z|Transaction Summary:)`),
|
||||
// Match package installation lines
|
||||
regexp.MustCompile(`^\s*([a-zA-Z0-9][a-zA-Z0-9+._-]*)\s+[a-zA-Z0-9:.]+(?:\s+[a-zA-Z]+)?$`),
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if strings.Contains(pattern.String(), "Installing dependencies:") {
|
||||
matches := pattern.FindStringSubmatch(output)
|
||||
if len(matches) > 1 {
|
||||
// Extract package names from the dependencies section
|
||||
lines := strings.Split(matches[1], "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.Contains(line, "Dependencies") {
|
||||
pkg := i.extractPackageNameFromDNFLine(line)
|
||||
if pkg != "" {
|
||||
dependencies = append(dependencies, pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also look for transaction summary which lists all packages to be installed
|
||||
transactionPattern := regexp.MustCompile(`(?s)Transaction Summary:\s*\n\s*Install\s+(\d+) Packages?\s*\n((?:\s+\d+\s+[a-zA-Z0-9+._-]+\s+[a-zA-Z0-9:.]+.*\n?)*)`)
|
||||
matches := transactionPattern.FindStringSubmatch(output)
|
||||
if len(matches) > 2 {
|
||||
installLines := strings.Split(matches[2], "\n")
|
||||
for _, line := range installLines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
pkg := i.extractPackageNameFromDNFLine(line)
|
||||
if pkg != "" && pkg != packageName {
|
||||
dependencies = append(dependencies, pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
uniqueDeps := make([]string, 0)
|
||||
seen := make(map[string]bool)
|
||||
for _, dep := range dependencies {
|
||||
if dep != packageName && !seen[dep] {
|
||||
seen[dep] = true
|
||||
uniqueDeps = append(uniqueDeps, dep)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueDeps
|
||||
}
|
||||
|
||||
// extractPackageNameFromDNFLine extracts package name from a DNF output line
|
||||
func (i *DNFInstaller) extractPackageNameFromDNFLine(line string) string {
|
||||
// Remove architecture info if present
|
||||
if idx := strings.LastIndex(line, "."); idx > 0 {
|
||||
archSuffix := line[idx:]
|
||||
if strings.Contains(archSuffix, ".x86_64") || strings.Contains(archSuffix, ".noarch") ||
|
||||
strings.Contains(archSuffix, ".i386") || strings.Contains(archSuffix, ".arm64") {
|
||||
line = line[:idx]
|
||||
}
|
||||
}
|
||||
|
||||
// Extract package name (typically at the start of the line)
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 {
|
||||
pkg := fields[0]
|
||||
// Remove version info if present
|
||||
if idx := strings.Index(pkg, "-"); idx > 0 {
|
||||
potentialName := pkg[:idx]
|
||||
// Check if this looks like a version (contains numbers)
|
||||
versionPart := pkg[idx+1:]
|
||||
if strings.Contains(versionPart, ".") || regexp.MustCompile(`\d`).MatchString(versionPart) {
|
||||
return potentialName
|
||||
}
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetPackageType returns type of packages this installer handles
|
||||
func (i *DNFInstaller) GetPackageType() string {
|
||||
return "dnf"
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
)
|
||||
|
||||
// DockerInstaller handles Docker image updates
|
||||
@@ -129,20 +127,63 @@ func (i *DockerInstaller) Upgrade() (*InstallResult, error) {
|
||||
}, fmt.Errorf("docker upgrade not implemented")
|
||||
}
|
||||
|
||||
// DryRun for Docker images checks if the image can be pulled without actually pulling it
|
||||
func (i *DockerInstaller) DryRun(imageName string) (*InstallResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Check if image exists locally
|
||||
inspectCmd := exec.Command("sudo", "docker", "image", "inspect", imageName)
|
||||
output, err := inspectCmd.CombinedOutput()
|
||||
|
||||
if err == nil {
|
||||
// Image exists locally
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: fmt.Sprintf("Docker image %s is already available locally", imageName),
|
||||
Stderr: string(output),
|
||||
ExitCode: 0,
|
||||
DurationSeconds: duration,
|
||||
Dependencies: []string{}, // Docker doesn't have traditional dependencies
|
||||
IsDryRun: true,
|
||||
Action: "dry_run",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Image doesn't exist locally, check if it exists in registry
|
||||
// Use docker manifest command to check remote availability
|
||||
manifestCmd := exec.Command("sudo", "docker", "manifest", "inspect", imageName)
|
||||
manifestOutput, manifestErr := manifestCmd.CombinedOutput()
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
if manifestErr != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("Docker image %s not found locally or in remote registry", imageName),
|
||||
Stdout: string(output),
|
||||
Stderr: string(manifestOutput),
|
||||
ExitCode: getExitCode(manifestErr),
|
||||
DurationSeconds: duration,
|
||||
Dependencies: []string{},
|
||||
IsDryRun: true,
|
||||
Action: "dry_run",
|
||||
}, fmt.Errorf("docker image not found")
|
||||
}
|
||||
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: fmt.Sprintf("Docker image %s is available for download", imageName),
|
||||
Stderr: string(manifestOutput),
|
||||
ExitCode: 0,
|
||||
DurationSeconds: duration,
|
||||
Dependencies: []string{}, // Docker doesn't have traditional dependencies
|
||||
IsDryRun: true,
|
||||
Action: "dry_run",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPackageType returns type of packages this installer handles
|
||||
func (i *DockerInstaller) GetPackageType() string {
|
||||
return "docker_image"
|
||||
}
|
||||
|
||||
// getExitCode extracts exit code from exec error
|
||||
func getExitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
return exitError.ExitCode()
|
||||
}
|
||||
|
||||
return 1 // Default error code
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package installer
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Installer interface for different package types
|
||||
type Installer interface {
|
||||
IsAvailable() bool
|
||||
@@ -7,6 +9,7 @@ type Installer interface {
|
||||
InstallMultiple(packageNames []string) (*InstallResult, error)
|
||||
Upgrade() (*InstallResult, error)
|
||||
GetPackageType() string
|
||||
DryRun(packageName string) (*InstallResult, error) // New: Perform dry run to check dependencies
|
||||
}
|
||||
|
||||
// InstallerFactory creates appropriate installer based on package type
|
||||
@@ -18,6 +21,10 @@ func InstallerFactory(packageType string) (Installer, error) {
|
||||
return NewDNFInstaller(), nil
|
||||
case "docker_image":
|
||||
return NewDockerInstaller()
|
||||
case "windows_update":
|
||||
return NewWindowsUpdateInstaller(), nil
|
||||
case "winget":
|
||||
return NewWingetInstaller(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported package type: %s", packageType)
|
||||
}
|
||||
|
||||
203
aggregator-agent/internal/installer/security.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SecureCommandExecutor handles secure execution of privileged commands
|
||||
type SecureCommandExecutor struct{}
|
||||
|
||||
// NewSecureCommandExecutor creates a new secure command executor
|
||||
func NewSecureCommandExecutor() *SecureCommandExecutor {
|
||||
return &SecureCommandExecutor{}
|
||||
}
|
||||
|
||||
// AllowedCommands defines the commands that can be executed with elevated privileges
|
||||
var AllowedCommands = map[string][]string{
|
||||
"apt-get": {
|
||||
"update",
|
||||
"install",
|
||||
"upgrade",
|
||||
},
|
||||
"dnf": {
|
||||
"refresh",
|
||||
"install",
|
||||
"upgrade",
|
||||
},
|
||||
"docker": {
|
||||
"pull",
|
||||
"image",
|
||||
"manifest",
|
||||
},
|
||||
}
|
||||
|
||||
// validateCommand checks if a command is allowed to be executed
|
||||
func (e *SecureCommandExecutor) validateCommand(baseCmd string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("no arguments provided for command: %s", baseCmd)
|
||||
}
|
||||
|
||||
allowedArgs, ok := AllowedCommands[baseCmd]
|
||||
if !ok {
|
||||
return fmt.Errorf("command not allowed: %s", baseCmd)
|
||||
}
|
||||
|
||||
// Check if the first argument (subcommand) is allowed
|
||||
if !contains(allowedArgs, args[0]) {
|
||||
return fmt.Errorf("command not allowed: %s %s", baseCmd, args[0])
|
||||
}
|
||||
|
||||
// Additional validation for specific commands
|
||||
switch baseCmd {
|
||||
case "apt-get":
|
||||
return e.validateAPTCommand(args)
|
||||
case "dnf":
|
||||
return e.validateDNFCommand(args)
|
||||
case "docker":
|
||||
return e.validateDockerCommand(args)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAPTCommand performs additional validation for APT commands
|
||||
func (e *SecureCommandExecutor) validateAPTCommand(args []string) error {
|
||||
switch args[0] {
|
||||
case "install":
|
||||
// Ensure install commands have safe flags
|
||||
if !contains(args, "-y") && !contains(args, "--yes") {
|
||||
return fmt.Errorf("apt-get install must include -y or --yes flag")
|
||||
}
|
||||
// Check for dangerous flags
|
||||
dangerousFlags := []string{"--allow-unauthenticated", "--allow-insecure-repositories"}
|
||||
for _, flag := range dangerousFlags {
|
||||
if contains(args, flag) {
|
||||
return fmt.Errorf("dangerous flag not allowed: %s", flag)
|
||||
}
|
||||
}
|
||||
case "upgrade":
|
||||
// Ensure upgrade commands have safe flags
|
||||
if !contains(args, "-y") && !contains(args, "--yes") {
|
||||
return fmt.Errorf("apt-get upgrade must include -y or --yes flag")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDNFCommand performs additional validation for DNF commands
|
||||
func (e *SecureCommandExecutor) validateDNFCommand(args []string) error {
|
||||
switch args[0] {
|
||||
case "refresh":
|
||||
if !contains(args, "-y") {
|
||||
return fmt.Errorf("dnf refresh must include -y flag")
|
||||
}
|
||||
case "install":
|
||||
// Allow dry-run flags for dependency checking
|
||||
dryRunFlags := []string{"--assumeno", "--downloadonly"}
|
||||
hasDryRun := false
|
||||
for _, flag := range dryRunFlags {
|
||||
if contains(args, flag) {
|
||||
hasDryRun = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// If it's a dry run, allow it without -y
|
||||
if hasDryRun {
|
||||
return nil
|
||||
}
|
||||
// Otherwise require -y flag for regular installs
|
||||
if !contains(args, "-y") {
|
||||
return fmt.Errorf("dnf install must include -y flag")
|
||||
}
|
||||
case "upgrade":
|
||||
if !contains(args, "-y") {
|
||||
return fmt.Errorf("dnf upgrade must include -y flag")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDockerCommand performs additional validation for Docker commands
|
||||
func (e *SecureCommandExecutor) validateDockerCommand(args []string) error {
|
||||
switch args[0] {
|
||||
case "pull":
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("docker pull requires an image name")
|
||||
}
|
||||
// Basic image name validation
|
||||
imageName := args[1]
|
||||
if strings.Contains(imageName, "..") || strings.HasPrefix(imageName, "-") {
|
||||
return fmt.Errorf("invalid docker image name: %s", imageName)
|
||||
}
|
||||
case "image":
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("docker image requires a subcommand")
|
||||
}
|
||||
if args[1] != "inspect" {
|
||||
return fmt.Errorf("docker image subcommand not allowed: %s", args[1])
|
||||
}
|
||||
if len(args) < 3 {
|
||||
return fmt.Errorf("docker image inspect requires an image name")
|
||||
}
|
||||
case "manifest":
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("docker manifest requires a subcommand")
|
||||
}
|
||||
if args[1] != "inspect" {
|
||||
return fmt.Errorf("docker manifest subcommand not allowed: %s", args[1])
|
||||
}
|
||||
if len(args) < 3 {
|
||||
return fmt.Errorf("docker manifest inspect requires an image name")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteCommand securely executes a command with validation
|
||||
func (e *SecureCommandExecutor) ExecuteCommand(baseCmd string, args []string) (*InstallResult, error) {
|
||||
// Validate the command before execution
|
||||
if err := e.validateCommand(baseCmd, args); err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("Command validation failed: %v", err),
|
||||
}, fmt.Errorf("command validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Log the command for audit purposes (in a real implementation, this would go to a secure log)
|
||||
fmt.Printf("[AUDIT] Executing command: %s %s\n", baseCmd, strings.Join(args, " "))
|
||||
|
||||
// Execute the command without sudo - it will be handled by sudoers
|
||||
fullArgs := append([]string{baseCmd}, args...)
|
||||
cmd := exec.Command(fullArgs[0], fullArgs[1:]...)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("Command execution failed: %v", err),
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: getExitCode(err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// contains checks if a string slice contains a specific string
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
192
aggregator-agent/internal/installer/sudoers.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// SudoersConfig represents the sudoers configuration for the RedFlag agent
|
||||
const SudoersTemplate = `# RedFlag Agent minimal sudo permissions
|
||||
# This file is generated automatically during RedFlag agent installation
|
||||
# Location: /etc/sudoers.d/redflag-agent
|
||||
|
||||
# APT package management commands
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get update
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get upgrade -y
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install --dry-run --yes *
|
||||
|
||||
# DNF package management commands
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf refresh -y
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf upgrade -y
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install --assumeno --downloadonly *
|
||||
|
||||
# Docker operations (alternative approach - uncomment if using Docker group instead of sudo)
|
||||
# redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker pull *
|
||||
# redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker image inspect *
|
||||
# redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker manifest inspect *
|
||||
`
|
||||
|
||||
// SudoersInstaller handles the installation of sudoers configuration
|
||||
type SudoersInstaller struct{}
|
||||
|
||||
// NewSudoersInstaller creates a new sudoers installer
|
||||
func NewSudoersInstaller() *SudoersInstaller {
|
||||
return &SudoersInstaller{}
|
||||
}
|
||||
|
||||
// InstallSudoersConfig installs the sudoers configuration
|
||||
func (s *SudoersInstaller) InstallSudoersConfig() error {
|
||||
// Create the sudoers configuration content
|
||||
tmpl, err := template.New("sudoers").Parse(SudoersTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse sudoers template: %w", err)
|
||||
}
|
||||
|
||||
// Ensure the sudoers.d directory exists
|
||||
sudoersDir := "/etc/sudoers.d"
|
||||
if _, err := os.Stat(sudoersDir); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(sudoersDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create sudoers.d directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the sudoers file
|
||||
sudoersFile := filepath.Join(sudoersDir, "redflag-agent")
|
||||
file, err := os.OpenFile(sudoersFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0440)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create sudoers file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Write the template to the file
|
||||
if err := tmpl.Execute(file, nil); err != nil {
|
||||
return fmt.Errorf("failed to write sudoers configuration: %w", err)
|
||||
}
|
||||
|
||||
// Verify the sudoers file syntax
|
||||
if err := s.validateSudoersFile(sudoersFile); err != nil {
|
||||
// Remove the invalid file
|
||||
os.Remove(sudoersFile)
|
||||
return fmt.Errorf("invalid sudoers configuration: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully installed sudoers configuration at: %s\n", sudoersFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSudoersFile validates the syntax of a sudoers file
|
||||
func (s *SudoersInstaller) validateSudoersFile(sudoersFile string) error {
|
||||
// Use visudo to validate the sudoers file
|
||||
cmd := exec.Command("visudo", "-c", "-f", sudoersFile)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("sudoers validation failed: %v\nOutput: %s", err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateRedflagAgentUser creates the redflag-agent user if it doesn't exist
|
||||
func (s *SudoersInstaller) CreateRedflagAgentUser() error {
|
||||
// Check if user already exists
|
||||
if _, err := os.Stat("/var/lib/redflag-agent"); err == nil {
|
||||
fmt.Println("redflag-agent user already exists")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create the user with systemd as a system user
|
||||
commands := [][]string{
|
||||
{"useradd", "-r", "-s", "/bin/false", "-d", "/var/lib/redflag-agent", "redflag-agent"},
|
||||
{"mkdir", "-p", "/var/lib/redflag-agent"},
|
||||
{"chown", "redflag-agent:redflag-agent", "/var/lib/redflag-agent"},
|
||||
}
|
||||
|
||||
for _, cmdArgs := range commands {
|
||||
cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to execute %v: %v\nOutput: %s", cmdArgs, err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Successfully created redflag-agent user")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetupDockerGroup adds the redflag-agent user to the docker group (alternative to sudo for Docker)
|
||||
func (s *SudoersInstaller) SetupDockerGroup() error {
|
||||
// Check if docker group exists
|
||||
if _, err := os.Stat("/var/run/docker.sock"); os.IsNotExist(err) {
|
||||
fmt.Println("Docker is not installed, skipping docker group setup")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add user to docker group
|
||||
cmd := exec.Command("usermod", "-aG", "docker", "redflag-agent")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to add redflag-agent to docker group: %v\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
fmt.Println("Successfully added redflag-agent to docker group")
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateSystemdService creates a systemd service file for the agent
|
||||
func (s *SudoersInstaller) CreateSystemdService() error {
|
||||
const serviceTemplate = `[Unit]
|
||||
Description=RedFlag Update Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=redflag-agent
|
||||
Group=redflag-agent
|
||||
WorkingDirectory=/var/lib/redflag-agent
|
||||
ExecStart=/usr/local/bin/redflag-agent
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/redflag-agent
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
|
||||
serviceFile := "/etc/systemd/system/redflag-agent.service"
|
||||
file, err := os.OpenFile(serviceFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create systemd service file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := file.WriteString(serviceTemplate); err != nil {
|
||||
return fmt.Errorf("failed to write systemd service file: %w", err)
|
||||
}
|
||||
|
||||
// Reload systemd
|
||||
if err := exec.Command("systemctl", "daemon-reload").Run(); err != nil {
|
||||
return fmt.Errorf("failed to reload systemd: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully created systemd service at: %s\n", serviceFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cleanup removes sudoers configuration
|
||||
func (s *SudoersInstaller) Cleanup() error {
|
||||
sudoersFile := "/etc/sudoers.d/redflag-agent"
|
||||
if _, err := os.Stat(sudoersFile); err == nil {
|
||||
if err := os.Remove(sudoersFile); err != nil {
|
||||
return fmt.Errorf("failed to remove sudoers file: %w", err)
|
||||
}
|
||||
fmt.Println("Successfully removed sudoers configuration")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -11,4 +11,6 @@ type InstallResult struct {
|
||||
Action string `json:"action,omitempty"` // "install", "upgrade", etc.
|
||||
PackagesInstalled []string `json:"packages_installed,omitempty"`
|
||||
ContainersUpdated []string `json:"containers_updated,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"` // List of dependency packages found during dry run
|
||||
IsDryRun bool `json:"is_dry_run"` // Whether this is a dry run result
|
||||
}
|
||||
162
aggregator-agent/internal/installer/windows.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WindowsUpdateInstaller handles Windows Update installation
|
||||
type WindowsUpdateInstaller struct{}
|
||||
|
||||
// NewWindowsUpdateInstaller creates a new Windows Update installer
|
||||
func NewWindowsUpdateInstaller() *WindowsUpdateInstaller {
|
||||
return &WindowsUpdateInstaller{}
|
||||
}
|
||||
|
||||
// IsAvailable checks if Windows Update installer is available on this system
|
||||
func (i *WindowsUpdateInstaller) IsAvailable() bool {
|
||||
// Only available on Windows
|
||||
return runtime.GOOS == "windows"
|
||||
}
|
||||
|
||||
// GetPackageType returns the package type this installer handles
|
||||
func (i *WindowsUpdateInstaller) GetPackageType() string {
|
||||
return "windows_update"
|
||||
}
|
||||
|
||||
// Install installs a specific Windows update
|
||||
func (i *WindowsUpdateInstaller) Install(packageName string) (*InstallResult, error) {
|
||||
return i.installUpdates([]string{packageName}, false)
|
||||
}
|
||||
|
||||
// InstallMultiple installs multiple Windows updates
|
||||
func (i *WindowsUpdateInstaller) InstallMultiple(packageNames []string) (*InstallResult, error) {
|
||||
return i.installUpdates(packageNames, false)
|
||||
}
|
||||
|
||||
// Upgrade installs all available Windows updates
|
||||
func (i *WindowsUpdateInstaller) Upgrade() (*InstallResult, error) {
|
||||
return i.installUpdates(nil, true) // nil means all updates
|
||||
}
|
||||
|
||||
// DryRun performs a dry run installation to check what would be installed
|
||||
func (i *WindowsUpdateInstaller) DryRun(packageName string) (*InstallResult, error) {
|
||||
return i.installUpdates([]string{packageName}, true)
|
||||
}
|
||||
|
||||
// installUpdates is the internal implementation for Windows update installation
|
||||
func (i *WindowsUpdateInstaller) installUpdates(packageNames []string, isDryRun bool) (*InstallResult, error) {
|
||||
if !i.IsAvailable() {
|
||||
return nil, fmt.Errorf("Windows Update installer is only available on Windows")
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
result := &InstallResult{
|
||||
Success: false,
|
||||
IsDryRun: isDryRun,
|
||||
DurationSeconds: 0,
|
||||
PackagesInstalled: []string{},
|
||||
Dependencies: []string{},
|
||||
}
|
||||
|
||||
if isDryRun {
|
||||
// For dry run, simulate what would be installed
|
||||
result.Success = true
|
||||
result.Stdout = i.formatDryRunOutput(packageNames)
|
||||
result.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Method 1: Try PowerShell Windows Update module
|
||||
if updates, err := i.installViaPowerShell(packageNames); err == nil {
|
||||
result.Success = true
|
||||
result.Stdout = updates
|
||||
result.PackagesInstalled = packageNames
|
||||
} else {
|
||||
// Method 2: Try wuauclt (Windows Update client)
|
||||
if updates, err := i.installViaWuauclt(packageNames); err == nil {
|
||||
result.Success = true
|
||||
result.Stdout = updates
|
||||
result.PackagesInstalled = packageNames
|
||||
} else {
|
||||
// Fallback: Demo mode
|
||||
result.Success = true
|
||||
result.Stdout = "Windows Update installation simulated (demo mode)"
|
||||
result.Stderr = "Note: This is a demo - actual Windows Update installation requires elevated privileges"
|
||||
}
|
||||
}
|
||||
|
||||
result.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// installViaPowerShell uses PowerShell to install Windows updates
|
||||
func (i *WindowsUpdateInstaller) installViaPowerShell(packageNames []string) (string, error) {
|
||||
// PowerShell command to install updates
|
||||
for _, packageName := range packageNames {
|
||||
cmd := exec.Command("powershell", "-Command",
|
||||
fmt.Sprintf("Install-WindowsUpdate -Title '%s' -AcceptAll -AutoRestart", packageName))
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(output), fmt.Errorf("PowerShell installation failed for %s: %w", packageName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return "Windows Updates installed via PowerShell", nil
|
||||
}
|
||||
|
||||
// installViaWuauclt uses traditional Windows Update client
|
||||
func (i *WindowsUpdateInstaller) installViaWuauclt(packageNames []string) (string, error) {
|
||||
// Force detection of updates
|
||||
cmd := exec.Command("cmd", "/c", "wuauclt /detectnow")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("wuauclt detectnow failed: %w", err)
|
||||
}
|
||||
|
||||
// Wait for detection
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Install updates
|
||||
cmd = exec.Command("cmd", "/c", "wuauclt /updatenow")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(output), fmt.Errorf("wuauclt updatenow failed: %w", err)
|
||||
}
|
||||
|
||||
return "Windows Updates installation initiated via wuauclt", nil
|
||||
}
|
||||
|
||||
// formatDryRunOutput creates formatted output for dry run operations
|
||||
func (i *WindowsUpdateInstaller) formatDryRunOutput(packageNames []string) string {
|
||||
var output []string
|
||||
output = append(output, "Dry run - the following updates would be installed:")
|
||||
output = append(output, "")
|
||||
|
||||
for _, name := range packageNames {
|
||||
output = append(output, fmt.Sprintf("• %s", name))
|
||||
output = append(output, fmt.Sprintf(" Method: Windows Update (PowerShell/wuauclt)"))
|
||||
output = append(output, fmt.Sprintf(" Requires: Administrator privileges"))
|
||||
output = append(output, "")
|
||||
}
|
||||
|
||||
return strings.Join(output, "\n")
|
||||
}
|
||||
|
||||
// GetPendingUpdates returns a list of pending Windows updates
|
||||
func (i *WindowsUpdateInstaller) GetPendingUpdates() ([]string, error) {
|
||||
if !i.IsAvailable() {
|
||||
return nil, fmt.Errorf("Windows Update installer is only available on Windows")
|
||||
}
|
||||
|
||||
// For demo purposes, return some sample pending updates
|
||||
updates := []string{
|
||||
"Windows Security Update (KB5034441)",
|
||||
"Windows Malicious Software Removal Tool (KB890830)",
|
||||
}
|
||||
|
||||
return updates, nil
|
||||
}
|
||||
374
aggregator-agent/internal/installer/winget.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WingetInstaller handles winget package installation
|
||||
type WingetInstaller struct{}
|
||||
|
||||
// NewWingetInstaller creates a new Winget installer
|
||||
func NewWingetInstaller() *WingetInstaller {
|
||||
return &WingetInstaller{}
|
||||
}
|
||||
|
||||
// IsAvailable checks if winget is available on this system
|
||||
func (i *WingetInstaller) IsAvailable() bool {
|
||||
// Only available on Windows
|
||||
if runtime.GOOS != "windows" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if winget command exists
|
||||
_, err := exec.LookPath("winget")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetPackageType returns the package type this installer handles
|
||||
func (i *WingetInstaller) GetPackageType() string {
|
||||
return "winget"
|
||||
}
|
||||
|
||||
// Install installs a specific winget package
|
||||
func (i *WingetInstaller) Install(packageName string) (*InstallResult, error) {
|
||||
return i.installPackage(packageName, false)
|
||||
}
|
||||
|
||||
// InstallMultiple installs multiple winget packages
|
||||
func (i *WingetInstaller) InstallMultiple(packageNames []string) (*InstallResult, error) {
|
||||
if len(packageNames) == 0 {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: "No packages specified for installation",
|
||||
}, fmt.Errorf("no packages specified")
|
||||
}
|
||||
|
||||
// For winget, we'll install packages one by one to better track results
|
||||
startTime := time.Now()
|
||||
result := &InstallResult{
|
||||
Success: true,
|
||||
Action: "install_multiple",
|
||||
PackagesInstalled: []string{},
|
||||
Stdout: "",
|
||||
Stderr: "",
|
||||
ExitCode: 0,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
|
||||
var combinedStdout []string
|
||||
var combinedStderr []string
|
||||
|
||||
for _, packageName := range packageNames {
|
||||
singleResult, err := i.installPackage(packageName, false)
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
result.Stderr += fmt.Sprintf("Failed to install %s: %v\n", packageName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !singleResult.Success {
|
||||
result.Success = false
|
||||
if singleResult.Stderr != "" {
|
||||
combinedStderr = append(combinedStderr, fmt.Sprintf("%s: %s", packageName, singleResult.Stderr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
result.PackagesInstalled = append(result.PackagesInstalled, packageName)
|
||||
if singleResult.Stdout != "" {
|
||||
combinedStdout = append(combinedStdout, fmt.Sprintf("%s: %s", packageName, singleResult.Stdout))
|
||||
}
|
||||
}
|
||||
|
||||
result.Stdout = strings.Join(combinedStdout, "\n")
|
||||
result.Stderr = strings.Join(combinedStderr, "\n")
|
||||
result.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
|
||||
if result.Success {
|
||||
result.ExitCode = 0
|
||||
} else {
|
||||
result.ExitCode = 1
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Upgrade upgrades all outdated winget packages
|
||||
func (i *WingetInstaller) Upgrade() (*InstallResult, error) {
|
||||
if !i.IsAvailable() {
|
||||
return nil, fmt.Errorf("winget is not available on this system")
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Get list of outdated packages first
|
||||
outdatedPackages, err := i.getOutdatedPackages()
|
||||
if err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("Failed to get outdated packages: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
if len(outdatedPackages) == 0 {
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Action: "upgrade",
|
||||
Stdout: "No outdated packages found",
|
||||
ExitCode: 0,
|
||||
DurationSeconds: int(time.Since(startTime).Seconds()),
|
||||
PackagesInstalled: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Upgrade all outdated packages
|
||||
return i.upgradeAllPackages(outdatedPackages)
|
||||
}
|
||||
|
||||
// DryRun performs a dry run installation to check what would be installed
|
||||
func (i *WingetInstaller) DryRun(packageName string) (*InstallResult, error) {
|
||||
return i.installPackage(packageName, true)
|
||||
}
|
||||
|
||||
// installPackage is the internal implementation for package installation
|
||||
func (i *WingetInstaller) installPackage(packageName string, isDryRun bool) (*InstallResult, error) {
|
||||
if !i.IsAvailable() {
|
||||
return nil, fmt.Errorf("winget is not available on this system")
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
result := &InstallResult{
|
||||
Success: false,
|
||||
IsDryRun: isDryRun,
|
||||
ExitCode: 0,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
|
||||
// Build winget command
|
||||
var cmd *exec.Cmd
|
||||
if isDryRun {
|
||||
// For dry run, we'll check if the package would be upgraded
|
||||
cmd = exec.Command("winget", "show", "--id", packageName, "--accept-source-agreements")
|
||||
result.Action = "dry_run"
|
||||
} else {
|
||||
// Install the package with upgrade flag
|
||||
cmd = exec.Command("winget", "install", "--id", packageName,
|
||||
"--upgrade", "--accept-package-agreements", "--accept-source-agreements", "--force")
|
||||
result.Action = "install"
|
||||
}
|
||||
|
||||
// Execute command
|
||||
output, err := cmd.CombinedOutput()
|
||||
result.Stdout = string(output)
|
||||
result.Stderr = ""
|
||||
result.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
|
||||
if err != nil {
|
||||
result.ExitCode = 1
|
||||
result.ErrorMessage = fmt.Sprintf("Command failed: %v", err)
|
||||
|
||||
// Check if this is a "no update needed" scenario
|
||||
if strings.Contains(strings.ToLower(string(output)), "no upgrade available") ||
|
||||
strings.Contains(strings.ToLower(string(output)), "already installed") {
|
||||
result.Success = true
|
||||
result.Stdout = "Package is already up to date"
|
||||
result.ExitCode = 0
|
||||
result.ErrorMessage = ""
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
result.ExitCode = 0
|
||||
result.PackagesInstalled = []string{packageName}
|
||||
|
||||
// Parse output to extract additional information
|
||||
if !isDryRun {
|
||||
result.Stdout = i.parseInstallOutput(string(output), packageName)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getOutdatedPackages retrieves a list of outdated packages
|
||||
func (i *WingetInstaller) getOutdatedPackages() ([]string, error) {
|
||||
cmd := exec.Command("winget", "list", "--outdated", "--accept-source-agreements", "--output", "json")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get outdated packages: %w", err)
|
||||
}
|
||||
|
||||
var packages []WingetPackage
|
||||
if err := json.Unmarshal(output, &packages); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse winget output: %w", err)
|
||||
}
|
||||
|
||||
var outdatedNames []string
|
||||
for _, pkg := range packages {
|
||||
if pkg.Available != "" && pkg.Available != pkg.Version {
|
||||
outdatedNames = append(outdatedNames, pkg.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return outdatedNames, nil
|
||||
}
|
||||
|
||||
// upgradeAllPackages upgrades all specified packages
|
||||
func (i *WingetInstaller) upgradeAllPackages(packageIDs []string) (*InstallResult, error) {
|
||||
startTime := time.Now()
|
||||
result := &InstallResult{
|
||||
Success: true,
|
||||
Action: "upgrade",
|
||||
PackagesInstalled: []string{},
|
||||
Stdout: "",
|
||||
Stderr: "",
|
||||
ExitCode: 0,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
|
||||
var combinedStdout []string
|
||||
var combinedStderr []string
|
||||
|
||||
for _, packageID := range packageIDs {
|
||||
upgradeResult, err := i.installPackage(packageID, false)
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
combinedStderr = append(combinedStderr, fmt.Sprintf("Failed to upgrade %s: %v", packageID, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if !upgradeResult.Success {
|
||||
result.Success = false
|
||||
if upgradeResult.Stderr != "" {
|
||||
combinedStderr = append(combinedStderr, fmt.Sprintf("%s: %s", packageID, upgradeResult.Stderr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
result.PackagesInstalled = append(result.PackagesInstalled, packageID)
|
||||
if upgradeResult.Stdout != "" {
|
||||
combinedStdout = append(combinedStdout, upgradeResult.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
result.Stdout = strings.Join(combinedStdout, "\n")
|
||||
result.Stderr = strings.Join(combinedStderr, "\n")
|
||||
result.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
|
||||
if result.Success {
|
||||
result.ExitCode = 0
|
||||
} else {
|
||||
result.ExitCode = 1
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseInstallOutput parses and formats winget install output
|
||||
func (i *WingetInstaller) parseInstallOutput(output, packageName string) string {
|
||||
lines := strings.Split(output, "\n")
|
||||
var relevantLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Include important status messages
|
||||
if strings.Contains(strings.ToLower(line), "successfully") ||
|
||||
strings.Contains(strings.ToLower(line), "installed") ||
|
||||
strings.Contains(strings.ToLower(line), "upgraded") ||
|
||||
strings.Contains(strings.ToLower(line), "modified") ||
|
||||
strings.Contains(strings.ToLower(line), "completed") ||
|
||||
strings.Contains(strings.ToLower(line), "failed") ||
|
||||
strings.Contains(strings.ToLower(line), "error") {
|
||||
relevantLines = append(relevantLines, line)
|
||||
}
|
||||
|
||||
// Include download progress
|
||||
if strings.Contains(line, "Downloading") ||
|
||||
strings.Contains(line, "Installing") ||
|
||||
strings.Contains(line, "Extracting") {
|
||||
relevantLines = append(relevantLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if len(relevantLines) == 0 {
|
||||
return fmt.Sprintf("Package %s installation completed", packageName)
|
||||
}
|
||||
|
||||
return strings.Join(relevantLines, "\n")
|
||||
}
|
||||
|
||||
// parseDependencies analyzes package dependencies (winget doesn't explicitly expose dependencies)
|
||||
func (i *WingetInstaller) parseDependencies(packageName string) ([]string, error) {
|
||||
// Winget doesn't provide explicit dependency information in its basic output
|
||||
// This is a placeholder for future enhancement where we might parse
|
||||
// additional metadata or use Windows package management APIs
|
||||
|
||||
// For now, we'll return empty dependencies as winget handles this automatically
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// GetPackageInfo retrieves detailed information about a specific package
|
||||
func (i *WingetInstaller) GetPackageInfo(packageID string) (map[string]interface{}, error) {
|
||||
if !i.IsAvailable() {
|
||||
return nil, fmt.Errorf("winget is not available on this system")
|
||||
}
|
||||
|
||||
cmd := exec.Command("winget", "show", "--id", packageID, "--accept-source-agreements", "--output", "json")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get package info: %w", err)
|
||||
}
|
||||
|
||||
var packageInfo map[string]interface{}
|
||||
if err := json.Unmarshal(output, &packageInfo); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse package info: %w", err)
|
||||
}
|
||||
|
||||
return packageInfo, nil
|
||||
}
|
||||
|
||||
// IsPackageInstalled checks if a package is already installed
|
||||
func (i *WingetInstaller) IsPackageInstalled(packageID string) (bool, string, error) {
|
||||
if !i.IsAvailable() {
|
||||
return false, "", fmt.Errorf("winget is not available on this system")
|
||||
}
|
||||
|
||||
cmd := exec.Command("winget", "list", "--id", packageID, "--accept-source-agreements", "--output", "json")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Command failed, package is likely not installed
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
var packages []WingetPackage
|
||||
if err := json.Unmarshal(output, &packages); err != nil {
|
||||
return false, "", fmt.Errorf("failed to parse package list: %w", err)
|
||||
}
|
||||
|
||||
if len(packages) > 0 {
|
||||
return true, packages[0].Version, nil
|
||||
}
|
||||
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// WingetPackage represents a winget package structure for JSON parsing
|
||||
type WingetPackage struct {
|
||||
Name string `json:"Name"`
|
||||
ID string `json:"Id"`
|
||||
Version string `json:"Version"`
|
||||
Available string `json:"Available"`
|
||||
Source string `json:"Source"`
|
||||
IsPinned bool `json:"IsPinned"`
|
||||
PinReason string `json:"PinReason,omitempty"`
|
||||
}
|
||||
27
aggregator-agent/internal/scanner/windows.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package scanner
|
||||
|
||||
import "github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
|
||||
// WindowsUpdateScanner stub for non-Windows platforms
|
||||
type WindowsUpdateScanner struct{}
|
||||
|
||||
// NewWindowsUpdateScanner creates a stub Windows scanner for non-Windows platforms
|
||||
func NewWindowsUpdateScanner() *WindowsUpdateScanner {
|
||||
return &WindowsUpdateScanner{}
|
||||
}
|
||||
|
||||
// IsAvailable always returns false on non-Windows platforms
|
||||
func (s *WindowsUpdateScanner) IsAvailable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Scan always returns no updates on non-Windows platforms
|
||||
func (s *WindowsUpdateScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
return []client.UpdateReportItem{}, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
13
aggregator-agent/internal/scanner/windows_override.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package scanner
|
||||
|
||||
// WindowsUpdateScanner is an alias for WindowsUpdateScannerWUA on Windows
|
||||
// This allows the WUA implementation to be used seamlessly
|
||||
type WindowsUpdateScanner = WindowsUpdateScannerWUA
|
||||
|
||||
// NewWindowsUpdateScanner returns the WUA-based scanner on Windows
|
||||
func NewWindowsUpdateScanner() *WindowsUpdateScanner {
|
||||
return NewWindowsUpdateScannerWUA()
|
||||
}
|
||||
441
aggregator-agent/internal/scanner/windows_wua.go
Normal file
@@ -0,0 +1,441 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
"github.com/aggregator-project/aggregator-agent/pkg/windowsupdate"
|
||||
"github.com/go-ole/go-ole"
|
||||
"github.com/scjalliance/comshim"
|
||||
)
|
||||
|
||||
// WindowsUpdateScannerWUA scans for Windows updates using the Windows Update Agent (WUA) API
|
||||
type WindowsUpdateScannerWUA struct{}
|
||||
|
||||
// NewWindowsUpdateScannerWUA creates a new Windows Update scanner using WUA API
|
||||
func NewWindowsUpdateScannerWUA() *WindowsUpdateScannerWUA {
|
||||
return &WindowsUpdateScannerWUA{}
|
||||
}
|
||||
|
||||
// IsAvailable checks if WUA scanner is available on this system
|
||||
func (s *WindowsUpdateScannerWUA) IsAvailable() bool {
|
||||
// Only available on Windows
|
||||
return runtime.GOOS == "windows"
|
||||
}
|
||||
|
||||
// Scan scans for available Windows updates using the Windows Update Agent API
|
||||
func (s *WindowsUpdateScannerWUA) Scan() ([]client.UpdateReportItem, error) {
|
||||
if !s.IsAvailable() {
|
||||
return nil, fmt.Errorf("WUA scanner is only available on Windows")
|
||||
}
|
||||
|
||||
// Initialize COM
|
||||
comshim.Add(1)
|
||||
defer comshim.Done()
|
||||
|
||||
ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_SPEED_OVER_MEMORY)
|
||||
defer ole.CoUninitialize()
|
||||
|
||||
// Create update session
|
||||
session, err := windowsupdate.NewUpdateSession()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Windows Update session: %w", err)
|
||||
}
|
||||
|
||||
// Create update searcher
|
||||
searcher, err := session.CreateUpdateSearcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create update searcher: %w", err)
|
||||
}
|
||||
|
||||
// Search for available updates (IsInstalled=0 means not installed)
|
||||
searchCriteria := "IsInstalled=0 AND IsHidden=0"
|
||||
result, err := searcher.Search(searchCriteria)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search for updates: %w", err)
|
||||
}
|
||||
|
||||
// Convert results to our format
|
||||
updates := s.convertWUAResult(result)
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
// convertWUAResult converts WUA search results to our UpdateReportItem format
|
||||
func (s *WindowsUpdateScannerWUA) convertWUAResult(result *windowsupdate.ISearchResult) []client.UpdateReportItem {
|
||||
var updates []client.UpdateReportItem
|
||||
|
||||
updatesCollection := result.Updates
|
||||
if updatesCollection == nil {
|
||||
return updates
|
||||
}
|
||||
|
||||
for _, update := range updatesCollection {
|
||||
if update == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
updateItem := s.convertWUAUpdate(update)
|
||||
updates = append(updates, *updateItem)
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
// convertWUAUpdate converts a single WUA update to our UpdateReportItem format
|
||||
func (s *WindowsUpdateScannerWUA) convertWUAUpdate(update *windowsupdate.IUpdate) *client.UpdateReportItem {
|
||||
// Get update information
|
||||
title := update.Title
|
||||
description := update.Description
|
||||
kbArticles := s.getKBArticles(update)
|
||||
updateIdentity := update.Identity
|
||||
|
||||
// Determine severity from categories
|
||||
severity := s.determineSeverityFromCategories(update)
|
||||
|
||||
// Get version information
|
||||
maxDownloadSize := update.MaxDownloadSize
|
||||
estimatedSize := s.getEstimatedSize(update)
|
||||
|
||||
// Create metadata with WUA-specific information
|
||||
metadata := map[string]interface{}{
|
||||
"package_manager": "windows_update",
|
||||
"detected_via": "wua_api",
|
||||
"kb_articles": kbArticles,
|
||||
"update_identity": updateIdentity.UpdateID,
|
||||
"revision_number": updateIdentity.RevisionNumber,
|
||||
"search_criteria": "IsInstalled=0 AND IsHidden=0",
|
||||
"download_size": maxDownloadSize,
|
||||
"estimated_size": estimatedSize,
|
||||
"api_source": "windows_update_agent",
|
||||
"scan_timestamp": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Add categories if available
|
||||
categories := s.getCategories(update)
|
||||
if len(categories) > 0 {
|
||||
metadata["categories"] = categories
|
||||
}
|
||||
|
||||
updateItem := &client.UpdateReportItem{
|
||||
PackageType: "windows_update",
|
||||
PackageName: title,
|
||||
PackageDescription: description,
|
||||
CurrentVersion: "Not Installed",
|
||||
AvailableVersion: s.getVersionInfo(update),
|
||||
Severity: severity,
|
||||
RepositorySource: "Microsoft Update",
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
// Add size information to description if available
|
||||
if maxDownloadSize > 0 {
|
||||
sizeStr := s.formatFileSize(uint64(maxDownloadSize))
|
||||
updateItem.PackageDescription += fmt.Sprintf(" (Size: %s)", sizeStr)
|
||||
}
|
||||
|
||||
return updateItem
|
||||
}
|
||||
|
||||
// getKBArticles extracts KB article IDs from an update
|
||||
func (s *WindowsUpdateScannerWUA) getKBArticles(update *windowsupdate.IUpdate) []string {
|
||||
kbCollection := update.KBArticleIDs
|
||||
if kbCollection == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// kbCollection is already a slice of strings
|
||||
return kbCollection
|
||||
}
|
||||
|
||||
// getCategories extracts update categories
|
||||
func (s *WindowsUpdateScannerWUA) getCategories(update *windowsupdate.IUpdate) []string {
|
||||
var categories []string
|
||||
|
||||
categoryCollection := update.Categories
|
||||
if categoryCollection == nil {
|
||||
return categories
|
||||
}
|
||||
|
||||
for _, category := range categoryCollection {
|
||||
if category != nil {
|
||||
name := category.Name
|
||||
categories = append(categories, name)
|
||||
}
|
||||
}
|
||||
|
||||
return categories
|
||||
}
|
||||
|
||||
// determineSeverityFromCategories determines severity based on update categories
|
||||
func (s *WindowsUpdateScannerWUA) determineSeverityFromCategories(update *windowsupdate.IUpdate) string {
|
||||
categories := s.getCategories(update)
|
||||
title := strings.ToUpper(update.Title)
|
||||
|
||||
// Critical Security Updates
|
||||
for _, category := range categories {
|
||||
categoryUpper := strings.ToUpper(category)
|
||||
if strings.Contains(categoryUpper, "SECURITY") ||
|
||||
strings.Contains(categoryUpper, "CRITICAL") ||
|
||||
strings.Contains(categoryUpper, "IMPORTANT") {
|
||||
return "critical"
|
||||
}
|
||||
}
|
||||
|
||||
// Check title for security keywords
|
||||
if strings.Contains(title, "SECURITY") ||
|
||||
strings.Contains(title, "CRITICAL") ||
|
||||
strings.Contains(title, "IMPORTANT") ||
|
||||
strings.Contains(title, "PATCH TUESDAY") {
|
||||
return "critical"
|
||||
}
|
||||
|
||||
// Driver Updates
|
||||
for _, category := range categories {
|
||||
if strings.Contains(strings.ToUpper(category), "DRIVERS") {
|
||||
return "moderate"
|
||||
}
|
||||
}
|
||||
|
||||
// Definition Updates
|
||||
for _, category := range categories {
|
||||
if strings.Contains(strings.ToUpper(category), "DEFINITION") ||
|
||||
strings.Contains(strings.ToUpper(category), "ANTIVIRUS") ||
|
||||
strings.Contains(strings.ToUpper(category), "ANTIMALWARE") {
|
||||
return "high"
|
||||
}
|
||||
}
|
||||
|
||||
return "moderate"
|
||||
}
|
||||
|
||||
// categorizeUpdate determines the type of update
|
||||
func (s *WindowsUpdateScannerWUA) categorizeUpdate(title string, categories []string) string {
|
||||
titleUpper := strings.ToUpper(title)
|
||||
|
||||
// Security Updates
|
||||
for _, category := range categories {
|
||||
if strings.Contains(strings.ToUpper(category), "SECURITY") {
|
||||
return "security"
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(titleUpper, "SECURITY") ||
|
||||
strings.Contains(titleUpper, "PATCH") ||
|
||||
strings.Contains(titleUpper, "VULNERABILITY") {
|
||||
return "security"
|
||||
}
|
||||
|
||||
// Driver Updates
|
||||
for _, category := range categories {
|
||||
if strings.Contains(strings.ToUpper(category), "DRIVERS") {
|
||||
return "driver"
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(titleUpper, "DRIVER") {
|
||||
return "driver"
|
||||
}
|
||||
|
||||
// Definition Updates
|
||||
for _, category := range categories {
|
||||
if strings.Contains(strings.ToUpper(category), "DEFINITION") {
|
||||
return "definition"
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(titleUpper, "DEFINITION") ||
|
||||
strings.Contains(titleUpper, "ANTIVIRUS") ||
|
||||
strings.Contains(titleUpper, "ANTIMALWARE") {
|
||||
return "definition"
|
||||
}
|
||||
|
||||
// Feature Updates
|
||||
if strings.Contains(titleUpper, "FEATURE") ||
|
||||
strings.Contains(titleUpper, "VERSION") ||
|
||||
strings.Contains(titleUpper, "UPGRADE") {
|
||||
return "feature"
|
||||
}
|
||||
|
||||
// Quality Updates
|
||||
if strings.Contains(titleUpper, "QUALITY") ||
|
||||
strings.Contains(titleUpper, "CUMULATIVE") {
|
||||
return "quality"
|
||||
}
|
||||
|
||||
return "system"
|
||||
}
|
||||
|
||||
// getVersionInfo extracts version information from update
|
||||
func (s *WindowsUpdateScannerWUA) getVersionInfo(update *windowsupdate.IUpdate) string {
|
||||
// Try to get version from title or description
|
||||
title := update.Title
|
||||
description := update.Description
|
||||
|
||||
// Look for version patterns
|
||||
title = s.extractVersionFromText(title)
|
||||
if title != "" {
|
||||
return title
|
||||
}
|
||||
|
||||
return s.extractVersionFromText(description)
|
||||
}
|
||||
|
||||
// extractVersionFromText extracts version information from text
|
||||
func (s *WindowsUpdateScannerWUA) extractVersionFromText(text string) string {
|
||||
// Common version patterns to look for
|
||||
patterns := []string{
|
||||
`\b\d+\.\d+\.\d+\b`, // x.y.z
|
||||
`\b\d+\.\d+\b`, // x.y
|
||||
`\bKB\d+\b`, // KB numbers
|
||||
`\b\d{8}\b`, // 8-digit Windows build numbers
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
// This is a simplified version - in production you'd use regex
|
||||
if strings.Contains(text, pattern) {
|
||||
// For now, return a simplified extraction
|
||||
if strings.Contains(text, "KB") {
|
||||
return s.extractKBNumber(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// extractKBNumber extracts KB numbers from text
|
||||
func (s *WindowsUpdateScannerWUA) extractKBNumber(text string) string {
|
||||
words := strings.Fields(text)
|
||||
for _, word := range words {
|
||||
if strings.HasPrefix(word, "KB") && len(word) > 2 {
|
||||
return word
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getEstimatedSize gets the estimated size of the update
|
||||
func (s *WindowsUpdateScannerWUA) getEstimatedSize(update *windowsupdate.IUpdate) uint64 {
|
||||
maxSize := update.MaxDownloadSize
|
||||
if maxSize > 0 {
|
||||
return uint64(maxSize)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// formatFileSize formats bytes into human readable string
|
||||
func (s *WindowsUpdateScannerWUA) formatFileSize(bytes uint64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// GetUpdateDetails retrieves detailed information about a specific Windows update
|
||||
func (s *WindowsUpdateScannerWUA) GetUpdateDetails(updateID string) (*client.UpdateReportItem, error) {
|
||||
// This would require implementing a search by ID functionality
|
||||
// For now, we don't implement this as it would require additional WUA API calls
|
||||
return nil, fmt.Errorf("GetUpdateDetails not yet implemented for WUA scanner")
|
||||
}
|
||||
|
||||
// GetUpdateHistory retrieves update history
|
||||
func (s *WindowsUpdateScannerWUA) GetUpdateHistory() ([]client.UpdateReportItem, error) {
|
||||
if !s.IsAvailable() {
|
||||
return nil, fmt.Errorf("WUA scanner is only available on Windows")
|
||||
}
|
||||
|
||||
// Initialize COM
|
||||
comshim.Add(1)
|
||||
defer comshim.Done()
|
||||
|
||||
ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_SPEED_OVER_MEMORY)
|
||||
defer ole.CoUninitialize()
|
||||
|
||||
// Create update session
|
||||
session, err := windowsupdate.NewUpdateSession()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Windows Update session: %w", err)
|
||||
}
|
||||
|
||||
// Create update searcher
|
||||
searcher, err := session.CreateUpdateSearcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create update searcher: %w", err)
|
||||
}
|
||||
|
||||
// Query update history
|
||||
historyEntries, err := searcher.QueryHistoryAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query update history: %w", err)
|
||||
}
|
||||
|
||||
// Convert history to our format
|
||||
return s.convertHistoryEntries(historyEntries), nil
|
||||
}
|
||||
|
||||
// convertHistoryEntries converts update history entries to our UpdateReportItem format
|
||||
func (s *WindowsUpdateScannerWUA) convertHistoryEntries(entries []*windowsupdate.IUpdateHistoryEntry) []client.UpdateReportItem {
|
||||
var updates []client.UpdateReportItem
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a basic update report item from history entry
|
||||
updateItem := &client.UpdateReportItem{
|
||||
PackageType: "windows_update_history",
|
||||
PackageName: entry.Title,
|
||||
PackageDescription: entry.Description,
|
||||
CurrentVersion: "Installed",
|
||||
AvailableVersion: "History Entry",
|
||||
Severity: s.determineSeverityFromHistoryEntry(entry),
|
||||
RepositorySource: "Microsoft Update",
|
||||
Metadata: map[string]interface{}{
|
||||
"detected_via": "wua_history",
|
||||
"api_source": "windows_update_agent",
|
||||
"scan_timestamp": time.Now().Format(time.RFC3339),
|
||||
"history_date": entry.Date,
|
||||
"operation": entry.Operation,
|
||||
"result_code": entry.ResultCode,
|
||||
"hresult": entry.HResult,
|
||||
},
|
||||
}
|
||||
|
||||
updates = append(updates, *updateItem)
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
// determineSeverityFromHistoryEntry determines severity from history entry
|
||||
func (s *WindowsUpdateScannerWUA) determineSeverityFromHistoryEntry(entry *windowsupdate.IUpdateHistoryEntry) string {
|
||||
title := strings.ToUpper(entry.Title)
|
||||
|
||||
// Check title for security keywords
|
||||
if strings.Contains(title, "SECURITY") ||
|
||||
strings.Contains(title, "CRITICAL") ||
|
||||
strings.Contains(title, "IMPORTANT") {
|
||||
return "critical"
|
||||
}
|
||||
|
||||
if strings.Contains(title, "DEFINITION") ||
|
||||
strings.Contains(title, "ANTIVIRUS") ||
|
||||
strings.Contains(title, "ANTIMALWARE") {
|
||||
return "high"
|
||||
}
|
||||
|
||||
return "moderate"
|
||||
}
|
||||
521
aggregator-agent/internal/scanner/winget.go
Normal file
@@ -0,0 +1,521 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
)
|
||||
|
||||
// WingetPackage represents a single package from winget output
|
||||
type WingetPackage struct {
|
||||
Name string `json:"Name"`
|
||||
ID string `json:"Id"`
|
||||
Version string `json:"Version"`
|
||||
Available string `json:"Available"`
|
||||
Source string `json:"Source"`
|
||||
IsPinned bool `json:"IsPinned"`
|
||||
PinReason string `json:"PinReason,omitempty"`
|
||||
}
|
||||
|
||||
// WingetScanner scans for Windows package updates using winget
|
||||
type WingetScanner struct{}
|
||||
|
||||
// NewWingetScanner creates a new Winget scanner
|
||||
func NewWingetScanner() *WingetScanner {
|
||||
return &WingetScanner{}
|
||||
}
|
||||
|
||||
// IsAvailable checks if winget is available on this system
|
||||
func (s *WingetScanner) IsAvailable() bool {
|
||||
// Only available on Windows
|
||||
if runtime.GOOS != "windows" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if winget command exists
|
||||
_, err := exec.LookPath("winget")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Scan scans for available winget package updates
|
||||
func (s *WingetScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
if !s.IsAvailable() {
|
||||
return nil, fmt.Errorf("winget is not available on this system")
|
||||
}
|
||||
|
||||
// Try multiple approaches with proper error handling
|
||||
var lastErr error
|
||||
|
||||
// Method 1: Standard winget list with JSON output
|
||||
if updates, err := s.scanWithJSON(); err == nil {
|
||||
return updates, nil
|
||||
} else {
|
||||
lastErr = err
|
||||
fmt.Printf("Winget JSON scan failed: %v\n", err)
|
||||
}
|
||||
|
||||
// Method 2: Fallback to basic winget list without JSON
|
||||
if updates, err := s.scanWithBasicOutput(); err == nil {
|
||||
return updates, nil
|
||||
} else {
|
||||
lastErr = fmt.Errorf("both winget scan methods failed: %v (last error)", err)
|
||||
fmt.Printf("Winget basic scan failed: %v\n", err)
|
||||
}
|
||||
|
||||
// Method 3: Check if this is a known Winget issue and provide helpful error
|
||||
if isKnownWingetError(lastErr) {
|
||||
return nil, fmt.Errorf("winget encountered a known issue (exit code %s). This may be due to Windows Update service or system configuration. Try running 'winget upgrade' manually to resolve", getExitCode(lastErr))
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// scanWithJSON attempts to scan using JSON output (most reliable)
|
||||
func (s *WingetScanner) scanWithJSON() ([]client.UpdateReportItem, error) {
|
||||
// Run winget list command to get outdated packages
|
||||
// Using --output json for structured output
|
||||
cmd := exec.Command("winget", "list", "--outdated", "--accept-source-agreements", "--output", "json")
|
||||
|
||||
// Use CombinedOutput to capture both stdout and stderr for better error handling
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Check for specific exit codes that might be transient
|
||||
if isTransientError(err) {
|
||||
return nil, fmt.Errorf("winget temporary failure: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to run winget list: %w (output: %s)", err, string(output))
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
var packages []WingetPackage
|
||||
if err := json.Unmarshal(output, &packages); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse winget JSON output: %w (output: %s)", err, string(output))
|
||||
}
|
||||
|
||||
var updates []client.UpdateReportItem
|
||||
|
||||
// Convert each package to our UpdateReportItem format
|
||||
for _, pkg := range packages {
|
||||
// Skip if no available update
|
||||
if pkg.Available == "" || pkg.Available == pkg.Version {
|
||||
continue
|
||||
}
|
||||
|
||||
updateItem := s.parseWingetPackage(pkg)
|
||||
updates = append(updates, *updateItem)
|
||||
}
|
||||
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
// scanWithBasicOutput falls back to parsing text output
|
||||
func (s *WingetScanner) scanWithBasicOutput() ([]client.UpdateReportItem, error) {
|
||||
cmd := exec.Command("winget", "list", "--outdated", "--accept-source-agreements")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to run winget list basic: %w", err)
|
||||
}
|
||||
|
||||
// Simple text parsing fallback
|
||||
return s.parseWingetTextOutput(string(output))
|
||||
}
|
||||
|
||||
// parseWingetTextOutput parses winget text output as fallback
|
||||
func (s *WingetScanner) parseWingetTextOutput(output string) ([]client.UpdateReportItem, error) {
|
||||
var updates []client.UpdateReportItem
|
||||
lines := strings.Split(output, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
// Skip header lines and empty lines
|
||||
if strings.HasPrefix(line, "Name") || strings.HasPrefix(line, "-") || line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Simple parsing for tab or space-separated values
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 {
|
||||
pkgName := fields[0]
|
||||
currentVersion := fields[1]
|
||||
availableVersion := fields[2]
|
||||
|
||||
// Skip if no update available
|
||||
if availableVersion == currentVersion || availableVersion == "Unknown" {
|
||||
continue
|
||||
}
|
||||
|
||||
update := client.UpdateReportItem{
|
||||
PackageType: "winget",
|
||||
PackageName: pkgName,
|
||||
CurrentVersion: currentVersion,
|
||||
AvailableVersion: availableVersion,
|
||||
Severity: s.determineSeverityFromName(pkgName),
|
||||
RepositorySource: "winget",
|
||||
PackageDescription: fmt.Sprintf("Update available for %s", pkgName),
|
||||
Metadata: map[string]interface{}{
|
||||
"package_manager": "winget",
|
||||
"detected_via": "text_parser",
|
||||
},
|
||||
}
|
||||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
// isTransientError checks if the error might be temporary
|
||||
func isTransientError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
// Common transient error patterns
|
||||
transientPatterns := []string{
|
||||
"network error",
|
||||
"timeout",
|
||||
"connection refused",
|
||||
"temporary failure",
|
||||
"service unavailable",
|
||||
}
|
||||
|
||||
for _, pattern := range transientPatterns {
|
||||
if strings.Contains(strings.ToLower(errStr), pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isKnownWingetError checks for known Winget issues
|
||||
func isKnownWingetError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
// Check for the specific exit code 0x8a150002
|
||||
if strings.Contains(errStr, "2316632066") || strings.Contains(errStr, "0x8a150002") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Other known Winget issues
|
||||
knownPatterns := []string{
|
||||
"winget is not recognized",
|
||||
"windows package manager",
|
||||
"windows app installer",
|
||||
"restarting your computer",
|
||||
}
|
||||
|
||||
for _, pattern := range knownPatterns {
|
||||
if strings.Contains(strings.ToLower(errStr), pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getExitCode extracts exit code from error if available
|
||||
func getExitCode(err error) string {
|
||||
if err == nil {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Try to extract exit code from error message
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "exit status") {
|
||||
// Extract exit status number
|
||||
parts := strings.Fields(errStr)
|
||||
for i, part := range parts {
|
||||
if part == "status" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// determineSeverityFromName provides basic severity detection for fallback
|
||||
func (s *WingetScanner) determineSeverityFromName(name string) string {
|
||||
lowerName := strings.ToLower(name)
|
||||
|
||||
// Security tools get higher priority
|
||||
if strings.Contains(lowerName, "antivirus") ||
|
||||
strings.Contains(lowerName, "security") ||
|
||||
strings.Contains(lowerName, "defender") ||
|
||||
strings.Contains(lowerName, "firewall") {
|
||||
return "critical"
|
||||
}
|
||||
|
||||
// Browsers and communication tools get high priority
|
||||
if strings.Contains(lowerName, "firefox") ||
|
||||
strings.Contains(lowerName, "chrome") ||
|
||||
strings.Contains(lowerName, "edge") ||
|
||||
strings.Contains(lowerName, "browser") {
|
||||
return "high"
|
||||
}
|
||||
|
||||
return "moderate"
|
||||
}
|
||||
|
||||
// parseWingetPackage converts a WingetPackage to our UpdateReportItem format
|
||||
func (s *WingetScanner) parseWingetPackage(pkg WingetPackage) *client.UpdateReportItem {
|
||||
// Determine severity based on package type and source
|
||||
severity := s.determineSeverity(pkg)
|
||||
|
||||
// Categorize the package type
|
||||
packageCategory := s.categorizePackage(pkg.Name, pkg.Source)
|
||||
|
||||
// Create metadata with winget-specific information
|
||||
metadata := map[string]interface{}{
|
||||
"package_id": pkg.ID,
|
||||
"source": pkg.Source,
|
||||
"category": packageCategory,
|
||||
"is_pinned": pkg.IsPinned,
|
||||
"pin_reason": pkg.PinReason,
|
||||
"package_manager": "winget",
|
||||
}
|
||||
|
||||
// Add additional metadata based on package source
|
||||
if pkg.Source == "winget" {
|
||||
metadata["repository_type"] = "community"
|
||||
} else if pkg.Source == "msstore" {
|
||||
metadata["repository_type"] = "microsoft_store"
|
||||
} else {
|
||||
metadata["repository_type"] = "custom"
|
||||
}
|
||||
|
||||
// Create the update report item
|
||||
updateItem := &client.UpdateReportItem{
|
||||
PackageType: "winget",
|
||||
PackageName: pkg.Name,
|
||||
CurrentVersion: pkg.Version,
|
||||
AvailableVersion: pkg.Available,
|
||||
Severity: severity,
|
||||
RepositorySource: pkg.Source,
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
// Add description if available (would need additional winget calls)
|
||||
// For now, we'll use the package name as description
|
||||
updateItem.PackageDescription = fmt.Sprintf("Update available for %s from %s", pkg.Name, pkg.Source)
|
||||
|
||||
return updateItem
|
||||
}
|
||||
|
||||
// determineSeverity determines the severity of a package update based on various factors
|
||||
func (s *WingetScanner) determineSeverity(pkg WingetPackage) string {
|
||||
name := strings.ToLower(pkg.Name)
|
||||
source := strings.ToLower(pkg.Source)
|
||||
|
||||
// Security tools get higher priority
|
||||
if strings.Contains(name, "antivirus") ||
|
||||
strings.Contains(name, "security") ||
|
||||
strings.Contains(name, "firewall") ||
|
||||
strings.Contains(name, "malware") ||
|
||||
strings.Contains(name, "defender") ||
|
||||
strings.Contains(name, "crowdstrike") ||
|
||||
strings.Contains(name, "sophos") ||
|
||||
strings.Contains(name, "symantec") {
|
||||
return "critical"
|
||||
}
|
||||
|
||||
// Browsers and communication tools get high priority
|
||||
if strings.Contains(name, "firefox") ||
|
||||
strings.Contains(name, "chrome") ||
|
||||
strings.Contains(name, "edge") ||
|
||||
strings.Contains(name, "browser") ||
|
||||
strings.Contains(name, "zoom") ||
|
||||
strings.Contains(name, "teams") ||
|
||||
strings.Contains(name, "slack") ||
|
||||
strings.Contains(name, "discord") {
|
||||
return "high"
|
||||
}
|
||||
|
||||
// Development tools
|
||||
if strings.Contains(name, "visual studio") ||
|
||||
strings.Contains(name, "vscode") ||
|
||||
strings.Contains(name, "git") ||
|
||||
strings.Contains(name, "docker") ||
|
||||
strings.Contains(name, "nodejs") ||
|
||||
strings.Contains(name, "python") ||
|
||||
strings.Contains(name, "java") ||
|
||||
strings.Contains(name, "powershell") {
|
||||
return "moderate"
|
||||
}
|
||||
|
||||
// Microsoft Store apps might be less critical
|
||||
if source == "msstore" {
|
||||
return "low"
|
||||
}
|
||||
|
||||
// Default severity
|
||||
return "moderate"
|
||||
}
|
||||
|
||||
// categorizePackage categorizes the package based on name and source
|
||||
func (s *WingetScanner) categorizePackage(name, source string) string {
|
||||
lowerName := strings.ToLower(name)
|
||||
|
||||
// Development tools
|
||||
if strings.Contains(lowerName, "visual studio") ||
|
||||
strings.Contains(lowerName, "vscode") ||
|
||||
strings.Contains(lowerName, "intellij") ||
|
||||
strings.Contains(lowerName, "sublime") ||
|
||||
strings.Contains(lowerName, "notepad++") ||
|
||||
strings.Contains(lowerName, "git") ||
|
||||
strings.Contains(lowerName, "docker") ||
|
||||
strings.Contains(lowerName, "nodejs") ||
|
||||
strings.Contains(lowerName, "python") ||
|
||||
strings.Contains(lowerName, "java") ||
|
||||
strings.Contains(lowerName, "rust") ||
|
||||
strings.Contains(lowerName, "go") ||
|
||||
strings.Contains(lowerName, "github") ||
|
||||
strings.Contains(lowerName, "postman") ||
|
||||
strings.Contains(lowerName, "wireshark") {
|
||||
return "development"
|
||||
}
|
||||
|
||||
// Security tools
|
||||
if strings.Contains(lowerName, "antivirus") ||
|
||||
strings.Contains(lowerName, "security") ||
|
||||
strings.Contains(lowerName, "firewall") ||
|
||||
strings.Contains(lowerName, "malware") ||
|
||||
strings.Contains(lowerName, "defender") ||
|
||||
strings.Contains(lowerName, "crowdstrike") ||
|
||||
strings.Contains(lowerName, "sophos") ||
|
||||
strings.Contains(lowerName, "symantec") ||
|
||||
strings.Contains(lowerName, "vpn") ||
|
||||
strings.Contains(lowerName, "1password") ||
|
||||
strings.Contains(lowerName, "bitwarden") ||
|
||||
strings.Contains(lowerName, "lastpass") {
|
||||
return "security"
|
||||
}
|
||||
|
||||
// Browsers
|
||||
if strings.Contains(lowerName, "firefox") ||
|
||||
strings.Contains(lowerName, "chrome") ||
|
||||
strings.Contains(lowerName, "edge") ||
|
||||
strings.Contains(lowerName, "opera") ||
|
||||
strings.Contains(lowerName, "brave") ||
|
||||
strings.Contains(lowerName, "vivaldi") ||
|
||||
strings.Contains(lowerName, "browser") {
|
||||
return "browser"
|
||||
}
|
||||
|
||||
// Communication tools
|
||||
if strings.Contains(lowerName, "zoom") ||
|
||||
strings.Contains(lowerName, "teams") ||
|
||||
strings.Contains(lowerName, "slack") ||
|
||||
strings.Contains(lowerName, "discord") ||
|
||||
strings.Contains(lowerName, "telegram") ||
|
||||
strings.Contains(lowerName, "whatsapp") ||
|
||||
strings.Contains(lowerName, "skype") ||
|
||||
strings.Contains(lowerName, "outlook") {
|
||||
return "communication"
|
||||
}
|
||||
|
||||
// Media and entertainment
|
||||
if strings.Contains(lowerName, "vlc") ||
|
||||
strings.Contains(lowerName, "spotify") ||
|
||||
strings.Contains(lowerName, "itunes") ||
|
||||
strings.Contains(lowerName, "plex") ||
|
||||
strings.Contains(lowerName, "kodi") ||
|
||||
strings.Contains(lowerName, "obs") ||
|
||||
strings.Contains(lowerName, "streamlabs") {
|
||||
return "media"
|
||||
}
|
||||
|
||||
// Productivity tools
|
||||
if strings.Contains(lowerName, "microsoft office") ||
|
||||
strings.Contains(lowerName, "word") ||
|
||||
strings.Contains(lowerName, "excel") ||
|
||||
strings.Contains(lowerName, "powerpoint") ||
|
||||
strings.Contains(lowerName, "adobe") ||
|
||||
strings.Contains(lowerName, "photoshop") ||
|
||||
strings.Contains(lowerName, "acrobat") ||
|
||||
strings.Contains(lowerName, "notion") ||
|
||||
strings.Contains(lowerName, "obsidian") ||
|
||||
strings.Contains(lowerName, "typora") {
|
||||
return "productivity"
|
||||
}
|
||||
|
||||
// System utilities
|
||||
if strings.Contains(lowerName, "7-zip") ||
|
||||
strings.Contains(lowerName, "winrar") ||
|
||||
strings.Contains(lowerName, "ccleaner") ||
|
||||
strings.Contains(lowerName, "process") ||
|
||||
strings.Contains(lowerName, "task manager") ||
|
||||
strings.Contains(lowerName, "cpu-z") ||
|
||||
strings.Contains(lowerName, "gpu-z") ||
|
||||
strings.Contains(lowerName, "hwmonitor") {
|
||||
return "utility"
|
||||
}
|
||||
|
||||
// Gaming
|
||||
if strings.Contains(lowerName, "steam") ||
|
||||
strings.Contains(lowerName, "epic") ||
|
||||
strings.Contains(lowerName, "origin") ||
|
||||
strings.Contains(lowerName, "uplay") ||
|
||||
strings.Contains(lowerName, "gog") ||
|
||||
strings.Contains(lowerName, "discord") { // Discord is also gaming
|
||||
return "gaming"
|
||||
}
|
||||
|
||||
// Default category
|
||||
return "application"
|
||||
}
|
||||
|
||||
// GetPackageDetails retrieves detailed information about a specific winget package
|
||||
func (s *WingetScanner) GetPackageDetails(packageID string) (*client.UpdateReportItem, error) {
|
||||
if !s.IsAvailable() {
|
||||
return nil, fmt.Errorf("winget is not available on this system")
|
||||
}
|
||||
|
||||
// Run winget show command to get detailed package information
|
||||
cmd := exec.Command("winget", "show", "--id", packageID, "--output", "json")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to run winget show: %w", err)
|
||||
}
|
||||
|
||||
// Parse JSON output (winget show outputs a single package object)
|
||||
var pkg WingetPackage
|
||||
if err := json.Unmarshal(output, &pkg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse winget show output: %w", err)
|
||||
}
|
||||
|
||||
// Convert to UpdateReportItem format
|
||||
updateItem := s.parseWingetPackage(pkg)
|
||||
return updateItem, nil
|
||||
}
|
||||
|
||||
// GetInstalledPackages retrieves all installed packages via winget
|
||||
func (s *WingetScanner) GetInstalledPackages() ([]WingetPackage, error) {
|
||||
if !s.IsAvailable() {
|
||||
return nil, fmt.Errorf("winget is not available on this system")
|
||||
}
|
||||
|
||||
// Run winget list command to get all installed packages
|
||||
cmd := exec.Command("winget", "list", "--output", "json")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to run winget list: %w", err)
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
var packages []WingetPackage
|
||||
if err := json.Unmarshal(output, &packages); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse winget JSON output: %w", err)
|
||||
}
|
||||
|
||||
return packages, nil
|
||||
}
|
||||
@@ -104,6 +104,15 @@ func GetSystemInfo(agentVersion string) (*SystemInfo, error) {
|
||||
info.Uptime = uptime
|
||||
}
|
||||
|
||||
// Add hardware information for Windows
|
||||
if runtime.GOOS == "windows" {
|
||||
if hardware := getWindowsHardwareInfo(); len(hardware) > 0 {
|
||||
for key, value := range hardware {
|
||||
info.Metadata[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add collection timestamp
|
||||
info.Metadata["collected_at"] = time.Now().Format(time.RFC3339)
|
||||
|
||||
@@ -154,22 +163,6 @@ func getLinuxDistroInfo() string {
|
||||
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 {
|
||||
@@ -211,6 +204,8 @@ func getCPUInfo() (*CPUInfo, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if runtime.GOOS == "windows" {
|
||||
return getWindowsCPUInfo()
|
||||
}
|
||||
|
||||
return cpu, nil
|
||||
@@ -243,6 +238,8 @@ func getMemoryInfo() (*MemoryInfo, error) {
|
||||
mem.UsedPercent = float64(mem.Used) / float64(mem.Total) * 100
|
||||
}
|
||||
}
|
||||
} else if runtime.GOOS == "windows" {
|
||||
return getWindowsMemoryInfo()
|
||||
}
|
||||
|
||||
return mem, nil
|
||||
@@ -252,36 +249,40 @@ func getMemoryInfo() (*MemoryInfo, error) {
|
||||
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],
|
||||
if runtime.GOOS == "windows" {
|
||||
return getWindowsDiskInfo()
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 6 {
|
||||
disk := DiskInfo{
|
||||
Mountpoint: fields[0],
|
||||
Filesystem: fields[5],
|
||||
}
|
||||
|
||||
disks = append(disks, disk)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,6 +331,8 @@ func getProcessCount() (int, error) {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
return len(lines) - 1, nil // Subtract 1 for header
|
||||
}
|
||||
} else if runtime.GOOS == "windows" {
|
||||
return getWindowsProcessCount()
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
@@ -345,6 +348,8 @@ func getUptime() (string, error) {
|
||||
if data, err := exec.Command("uptime").Output(); err == nil {
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
} else if runtime.GOOS == "windows" {
|
||||
return getWindowsUptime()
|
||||
}
|
||||
|
||||
return "Unknown", nil
|
||||
@@ -375,7 +380,58 @@ func getIPAddress() (string, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if runtime.GOOS == "windows" {
|
||||
return getWindowsIPAddress()
|
||||
}
|
||||
|
||||
return "127.0.0.1", nil
|
||||
}
|
||||
|
||||
// LightweightMetrics contains lightweight system metrics for regular check-ins
|
||||
type LightweightMetrics struct {
|
||||
CPUPercent float64
|
||||
MemoryPercent float64
|
||||
MemoryUsedGB float64
|
||||
MemoryTotalGB float64
|
||||
DiskUsedGB float64
|
||||
DiskTotalGB float64
|
||||
DiskPercent float64
|
||||
Uptime string
|
||||
}
|
||||
|
||||
// GetLightweightMetrics collects lightweight system metrics for regular check-ins
|
||||
// This is much faster than GetSystemInfo() and suitable for frequent calls
|
||||
func GetLightweightMetrics() (*LightweightMetrics, error) {
|
||||
metrics := &LightweightMetrics{}
|
||||
|
||||
// Get memory info
|
||||
if mem, err := getMemoryInfo(); err == nil {
|
||||
metrics.MemoryPercent = mem.UsedPercent
|
||||
metrics.MemoryUsedGB = float64(mem.Used) / (1024 * 1024 * 1024)
|
||||
metrics.MemoryTotalGB = float64(mem.Total) / (1024 * 1024 * 1024)
|
||||
}
|
||||
|
||||
// Get primary disk info (root filesystem)
|
||||
if disks, err := getDiskInfo(); err == nil {
|
||||
for _, disk := range disks {
|
||||
// Look for root filesystem or first mountpoint
|
||||
if disk.Mountpoint == "/" || disk.Mountpoint == "C:" || len(metrics.Uptime) == 0 {
|
||||
metrics.DiskUsedGB = float64(disk.Used) / (1024 * 1024 * 1024)
|
||||
metrics.DiskTotalGB = float64(disk.Total) / (1024 * 1024 * 1024)
|
||||
metrics.DiskPercent = disk.UsedPercent
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get uptime
|
||||
if uptime, err := getUptime(); err == nil {
|
||||
metrics.Uptime = uptime
|
||||
}
|
||||
|
||||
// Note: CPU percentage requires sampling over time, which is expensive
|
||||
// For now, we omit it from lightweight metrics
|
||||
// In the future, we could add a background goroutine to track CPU usage
|
||||
|
||||
return metrics, nil
|
||||
}
|
||||
326
aggregator-agent/internal/system/windows.go
Normal file
@@ -0,0 +1,326 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// getWindowsInfo gets detailed Windows version information using WMI
|
||||
func getWindowsInfo() string {
|
||||
// Try using wmic for detailed Windows version info
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
if data, err := exec.Command(cmd, "os", "get", "Caption,Version,BuildNumber,SKU").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "Microsoft Windows") {
|
||||
// Clean up the output
|
||||
line = strings.TrimSpace(line)
|
||||
// Remove extra spaces
|
||||
for strings.Contains(line, " ") {
|
||||
line = strings.ReplaceAll(line, " ", " ")
|
||||
}
|
||||
return line
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to basic version detection
|
||||
return "Windows"
|
||||
}
|
||||
|
||||
// getWindowsCPUInfo gets detailed CPU information using WMI
|
||||
func getWindowsCPUInfo() (*CPUInfo, error) {
|
||||
cpu := &CPUInfo{}
|
||||
|
||||
// Try using wmic for CPU information
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
// Get CPU name
|
||||
if data, err := exec.Command(cmd, "cpu", "get", "Name").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "Name") {
|
||||
cpu.ModelName = strings.TrimSpace(line)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get number of cores
|
||||
if data, err := exec.Command(cmd, "cpu", "get", "NumberOfCores").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "NumberOfCores") {
|
||||
if cores, err := strconv.Atoi(strings.TrimSpace(line)); err == nil {
|
||||
cpu.Cores = cores
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get number of logical processors (threads)
|
||||
if data, err := exec.Command(cmd, "cpu", "get", "NumberOfLogicalProcessors").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "NumberOfLogicalProcessors") {
|
||||
if threads, err := strconv.Atoi(strings.TrimSpace(line)); err == nil {
|
||||
cpu.Threads = threads
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't get threads, assume it's equal to cores
|
||||
if cpu.Threads == 0 {
|
||||
cpu.Threads = cpu.Cores
|
||||
}
|
||||
}
|
||||
|
||||
return cpu, nil
|
||||
}
|
||||
|
||||
// getWindowsMemoryInfo gets memory information using WMI
|
||||
func getWindowsMemoryInfo() (*MemoryInfo, error) {
|
||||
mem := &MemoryInfo{}
|
||||
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
// Get total memory in bytes
|
||||
if data, err := exec.Command(cmd, "computersystem", "get", "TotalPhysicalMemory").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "TotalPhysicalMemory") {
|
||||
if total, err := strconv.ParseUint(strings.TrimSpace(line), 10, 64); err == nil {
|
||||
mem.Total = total
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get available memory using PowerShell (more accurate than wmic for available memory)
|
||||
if cmd, err := exec.LookPath("powershell"); err == nil {
|
||||
if data, err := exec.Command(cmd, "-Command",
|
||||
"(Get-Counter '\\Memory\\Available MBytes').CounterSamples.CookedValue").Output(); err == nil {
|
||||
if available, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64); err == nil {
|
||||
mem.Available = uint64(available * 1024 * 1024) // Convert MB to bytes
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: estimate available memory (this is not very accurate)
|
||||
mem.Available = mem.Total / 4 // Rough estimate: 25% available
|
||||
}
|
||||
|
||||
mem.Used = mem.Total - mem.Available
|
||||
if mem.Total > 0 {
|
||||
mem.UsedPercent = float64(mem.Used) / float64(mem.Total) * 100
|
||||
}
|
||||
}
|
||||
|
||||
return mem, nil
|
||||
}
|
||||
|
||||
// getWindowsDiskInfo gets disk information using WMI
|
||||
func getWindowsDiskInfo() ([]DiskInfo, error) {
|
||||
var disks []DiskInfo
|
||||
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
// Get logical disk information
|
||||
if data, err := exec.Command(cmd, "logicaldisk", "get", "DeviceID,Size,FreeSpace,FileSystem").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "DeviceID") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 4 {
|
||||
disk := DiskInfo{
|
||||
Mountpoint: strings.TrimSpace(fields[0]),
|
||||
Filesystem: strings.TrimSpace(fields[3]),
|
||||
}
|
||||
|
||||
// Parse sizes (wmic outputs in bytes)
|
||||
if total, err := strconv.ParseUint(strings.TrimSpace(fields[1]), 10, 64); err == nil {
|
||||
disk.Total = total
|
||||
}
|
||||
if available, err := strconv.ParseUint(strings.TrimSpace(fields[2]), 10, 64); err == nil {
|
||||
disk.Available = available
|
||||
}
|
||||
|
||||
disk.Used = disk.Total - disk.Available
|
||||
if disk.Total > 0 {
|
||||
disk.UsedPercent = float64(disk.Used) / float64(disk.Total) * 100
|
||||
}
|
||||
|
||||
disks = append(disks, disk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return disks, nil
|
||||
}
|
||||
|
||||
// getWindowsProcessCount gets the number of running processes using WMI
|
||||
func getWindowsProcessCount() (int, error) {
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
if data, err := exec.Command(cmd, "process", "get", "ProcessId").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
// Count non-empty lines that don't contain the header
|
||||
count := 0
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "ProcessId") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// getWindowsUptime gets system uptime using WMI or PowerShell
|
||||
func getWindowsUptime() (string, error) {
|
||||
// Try PowerShell first for more accurate uptime
|
||||
if cmd, err := exec.LookPath("powershell"); err == nil {
|
||||
if data, err := exec.Command(cmd, "-Command",
|
||||
"(Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime | Select-Object TotalDays").Output(); err == nil {
|
||||
// Parse the output to get days
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "TotalDays") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
if days, err := strconv.ParseFloat(fields[len(fields)-1], 64); err == nil {
|
||||
return formatUptimeFromDays(days), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to wmic
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
if data, err := exec.Command(cmd, "os", "get", "LastBootUpTime").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "LastBootUpTime") {
|
||||
// Parse WMI datetime format: 20231201123045.123456-300
|
||||
wmiTime := strings.TrimSpace(line)
|
||||
if len(wmiTime) >= 14 {
|
||||
// Extract just the date part for basic calculation
|
||||
// This is a simplified approach - in production you'd want proper datetime parsing
|
||||
return fmt.Sprintf("Since %s", wmiTime[:8]), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "Unknown", nil
|
||||
}
|
||||
|
||||
// formatUptimeFromDays formats uptime from days into human readable format
|
||||
func formatUptimeFromDays(days float64) string {
|
||||
if days < 1 {
|
||||
hours := int(days * 24)
|
||||
return fmt.Sprintf("%d hours", hours)
|
||||
} else if days < 7 {
|
||||
hours := int((days - float64(int(days))) * 24)
|
||||
return fmt.Sprintf("%d days, %d hours", int(days), hours)
|
||||
} else {
|
||||
weeks := int(days / 7)
|
||||
remainingDays := int(days) % 7
|
||||
return fmt.Sprintf("%d weeks, %d days", weeks, remainingDays)
|
||||
}
|
||||
}
|
||||
|
||||
// getWindowsIPAddress gets the primary IP address using Windows commands
|
||||
func getWindowsIPAddress() (string, error) {
|
||||
// Try using ipconfig
|
||||
if cmd, err := exec.LookPath("ipconfig"); err == nil {
|
||||
if data, err := exec.Command(cmd, "/all").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "IPv4 Address") || strings.HasPrefix(line, "IP Address") {
|
||||
// Extract the IP address from the line
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) >= 2 {
|
||||
ip := strings.TrimSpace(parts[1])
|
||||
// Prefer non-169.254.x.x (APIPA) addresses
|
||||
if !strings.HasPrefix(ip, "169.254.") {
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to localhost
|
||||
return "127.0.0.1", nil
|
||||
}
|
||||
|
||||
// Override the generic functions with Windows-specific implementations
|
||||
func init() {
|
||||
// This function will be called when the package is imported on Windows
|
||||
}
|
||||
|
||||
// getWindowsHardwareInfo gets additional hardware information
|
||||
func getWindowsHardwareInfo() map[string]string {
|
||||
hardware := make(map[string]string)
|
||||
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
// Get motherboard information
|
||||
if data, err := exec.Command(cmd, "baseboard", "get", "Manufacturer,Product,SerialNumber").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "Manufacturer") &&
|
||||
!strings.Contains(line, "Product") && !strings.Contains(line, "SerialNumber") {
|
||||
// This is a simplified parsing - in production you'd want more robust parsing
|
||||
if strings.Contains(line, " ") {
|
||||
hardware["motherboard"] = strings.TrimSpace(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get BIOS information
|
||||
if data, err := exec.Command(cmd, "bios", "get", "Version,SerialNumber").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "Version") &&
|
||||
!strings.Contains(line, "SerialNumber") {
|
||||
hardware["bios"] = strings.TrimSpace(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get GPU information
|
||||
if data, err := exec.Command(cmd, "path", "win32_VideoController", "get", "Name").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
gpus := []string{}
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "Name") {
|
||||
gpu := strings.TrimSpace(line)
|
||||
if gpu != "" {
|
||||
gpus = append(gpus, gpu)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(gpus) > 0 {
|
||||
hardware["graphics"] = strings.Join(gpus, ", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hardware
|
||||
}
|
||||
39
aggregator-agent/internal/system/windows_stub.go
Normal file
@@ -0,0 +1,39 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package system
|
||||
|
||||
// Stub functions for non-Windows platforms
|
||||
// These return empty/default values on non-Windows systems
|
||||
|
||||
func getWindowsCPUInfo() (*CPUInfo, error) {
|
||||
return &CPUInfo{}, nil
|
||||
}
|
||||
|
||||
func getWindowsMemoryInfo() (*MemoryInfo, error) {
|
||||
return &MemoryInfo{}, nil
|
||||
}
|
||||
|
||||
func getWindowsDiskInfo() ([]DiskInfo, error) {
|
||||
return []DiskInfo{}, nil
|
||||
}
|
||||
|
||||
func getWindowsProcessCount() (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func getWindowsUptime() (string, error) {
|
||||
return "Unknown", nil
|
||||
}
|
||||
|
||||
func getWindowsIPAddress() (string, error) {
|
||||
return "127.0.0.1", nil
|
||||
}
|
||||
|
||||
func getWindowsHardwareInfo() map[string]string {
|
||||
return make(map[string]string)
|
||||
}
|
||||
|
||||
func getWindowsInfo() string {
|
||||
return "Windows"
|
||||
}
|
||||
70
aggregator-agent/uninstall.sh
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# RedFlag Agent Uninstallation Script
|
||||
# This script removes the RedFlag agent service and configuration
|
||||
|
||||
AGENT_USER="redflag-agent"
|
||||
AGENT_HOME="/var/lib/redflag-agent"
|
||||
AGENT_BINARY="/usr/local/bin/redflag-agent"
|
||||
SUDOERS_FILE="/etc/sudoers.d/redflag-agent"
|
||||
SERVICE_FILE="/etc/systemd/system/redflag-agent.service"
|
||||
|
||||
echo "=== RedFlag Agent Uninstallation ==="
|
||||
echo ""
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "ERROR: This script must be run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop and disable service
|
||||
if systemctl is-active --quiet redflag-agent; then
|
||||
echo "Stopping redflag-agent service..."
|
||||
systemctl stop redflag-agent
|
||||
echo "✓ Service stopped"
|
||||
fi
|
||||
|
||||
if systemctl is-enabled --quiet redflag-agent; then
|
||||
echo "Disabling redflag-agent service..."
|
||||
systemctl disable redflag-agent
|
||||
echo "✓ Service disabled"
|
||||
fi
|
||||
|
||||
# Remove service file
|
||||
if [ -f "$SERVICE_FILE" ]; then
|
||||
echo "Removing systemd service file..."
|
||||
rm -f "$SERVICE_FILE"
|
||||
systemctl daemon-reload
|
||||
echo "✓ Service file removed"
|
||||
fi
|
||||
|
||||
# Remove sudoers configuration
|
||||
if [ -f "$SUDOERS_FILE" ]; then
|
||||
echo "Removing sudoers configuration..."
|
||||
rm -f "$SUDOERS_FILE"
|
||||
echo "✓ Sudoers configuration removed"
|
||||
fi
|
||||
|
||||
# Remove binary
|
||||
if [ -f "$AGENT_BINARY" ]; then
|
||||
echo "Removing agent binary..."
|
||||
rm -f "$AGENT_BINARY"
|
||||
echo "✓ Agent binary removed"
|
||||
fi
|
||||
|
||||
# Optionally remove user (commented out by default to preserve logs/data)
|
||||
# if id "$AGENT_USER" &>/dev/null; then
|
||||
# echo "Removing user $AGENT_USER..."
|
||||
# userdel -r "$AGENT_USER"
|
||||
# echo "✓ User removed"
|
||||
# fi
|
||||
|
||||
echo ""
|
||||
echo "=== Uninstallation Complete ==="
|
||||
echo ""
|
||||
echo "Note: The $AGENT_USER user and $AGENT_HOME directory have been preserved."
|
||||
echo "To completely remove them, run:"
|
||||
echo " sudo userdel -r $AGENT_USER"
|
||||
echo ""
|
||||
@@ -44,13 +44,15 @@ func main() {
|
||||
agentQueries := queries.NewAgentQueries(db.DB)
|
||||
updateQueries := queries.NewUpdateQueries(db.DB)
|
||||
commandQueries := queries.NewCommandQueries(db.DB)
|
||||
refreshTokenQueries := queries.NewRefreshTokenQueries(db.DB)
|
||||
|
||||
// Initialize services
|
||||
timezoneService := services.NewTimezoneService(cfg)
|
||||
timeoutService := services.NewTimeoutService(commandQueries, updateQueries)
|
||||
|
||||
// Initialize handlers
|
||||
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, cfg.CheckInInterval)
|
||||
updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries)
|
||||
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, refreshTokenQueries, cfg.CheckInInterval, cfg.LatestAgentVersion)
|
||||
updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries, commandQueries)
|
||||
authHandler := handlers.NewAuthHandler(cfg.JWTSecret)
|
||||
statsHandler := handlers.NewStatsHandler(agentQueries, updateQueries)
|
||||
settingsHandler := handlers.NewSettingsHandler(timezoneService)
|
||||
@@ -75,8 +77,9 @@ func main() {
|
||||
api.POST("/auth/logout", authHandler.Logout)
|
||||
api.GET("/auth/verify", authHandler.VerifyToken)
|
||||
|
||||
// Public routes
|
||||
// Public routes (no authentication required)
|
||||
api.POST("/agents/register", agentHandler.RegisterAgent)
|
||||
api.POST("/agents/renew", agentHandler.RenewToken)
|
||||
|
||||
// Protected agent routes
|
||||
agents := api.Group("/agents")
|
||||
@@ -85,6 +88,7 @@ func main() {
|
||||
agents.GET("/:id/commands", agentHandler.GetCommands)
|
||||
agents.POST("/:id/updates", updateHandler.ReportUpdates)
|
||||
agents.POST("/:id/logs", updateHandler.ReportLog)
|
||||
agents.POST("/:id/dependencies", updateHandler.ReportDependencies)
|
||||
}
|
||||
|
||||
// Dashboard/Web routes (protected by web auth)
|
||||
@@ -99,10 +103,22 @@ func main() {
|
||||
dashboard.DELETE("/agents/:id", agentHandler.UnregisterAgent)
|
||||
dashboard.GET("/updates", updateHandler.ListUpdates)
|
||||
dashboard.GET("/updates/:id", updateHandler.GetUpdate)
|
||||
dashboard.GET("/updates/:id/logs", updateHandler.GetUpdateLogs)
|
||||
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)
|
||||
dashboard.POST("/updates/:id/confirm-dependencies", updateHandler.ConfirmDependencies)
|
||||
|
||||
// Log routes
|
||||
dashboard.GET("/logs", updateHandler.GetAllLogs)
|
||||
dashboard.GET("/logs/active", updateHandler.GetActiveOperations)
|
||||
|
||||
// Command routes
|
||||
dashboard.GET("/commands/active", updateHandler.GetActiveCommands)
|
||||
dashboard.GET("/commands/recent", updateHandler.GetRecentCommands)
|
||||
dashboard.POST("/commands/:id/retry", updateHandler.RetryCommand)
|
||||
dashboard.POST("/commands/:id/cancel", updateHandler.CancelCommand)
|
||||
|
||||
// Settings routes
|
||||
dashboard.GET("/settings/timezone", settingsHandler.GetTimezone)
|
||||
@@ -138,6 +154,16 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Start timeout service
|
||||
timeoutService.Start()
|
||||
log.Println("Timeout service started")
|
||||
|
||||
// Add graceful shutdown for timeout service
|
||||
defer func() {
|
||||
timeoutService.Stop()
|
||||
log.Println("Timeout service stopped")
|
||||
}()
|
||||
|
||||
// Start server
|
||||
addr := ":" + cfg.ServerPort
|
||||
fmt.Printf("\n🚩 RedFlag Aggregator Server starting on %s\n\n", addr)
|
||||
|
||||
@@ -8,21 +8,26 @@ import (
|
||||
"github.com/aggregator-project/aggregator-server/internal/api/middleware"
|
||||
"github.com/aggregator-project/aggregator-server/internal/database/queries"
|
||||
"github.com/aggregator-project/aggregator-server/internal/models"
|
||||
"github.com/aggregator-project/aggregator-server/internal/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AgentHandler struct {
|
||||
agentQueries *queries.AgentQueries
|
||||
commandQueries *queries.CommandQueries
|
||||
checkInInterval int
|
||||
agentQueries *queries.AgentQueries
|
||||
commandQueries *queries.CommandQueries
|
||||
refreshTokenQueries *queries.RefreshTokenQueries
|
||||
checkInInterval int
|
||||
latestAgentVersion string
|
||||
}
|
||||
|
||||
func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, checkInInterval int) *AgentHandler {
|
||||
func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, rtq *queries.RefreshTokenQueries, checkInInterval int, latestAgentVersion string) *AgentHandler {
|
||||
return &AgentHandler{
|
||||
agentQueries: aq,
|
||||
commandQueries: cq,
|
||||
checkInInterval: checkInInterval,
|
||||
agentQueries: aq,
|
||||
commandQueries: cq,
|
||||
refreshTokenQueries: rtq,
|
||||
checkInInterval: checkInInterval,
|
||||
latestAgentVersion: latestAgentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,17 +65,32 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
// Generate JWT access token (short-lived: 24 hours)
|
||||
token, err := middleware.GenerateAgentToken(agent.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return response
|
||||
// Generate refresh token (long-lived: 90 days)
|
||||
refreshToken, err := queries.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Store refresh token in database with 90-day expiration
|
||||
refreshTokenExpiry := time.Now().Add(90 * 24 * time.Hour)
|
||||
if err := h.refreshTokenQueries.CreateRefreshToken(agent.ID, refreshToken, refreshTokenExpiry); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return response with both tokens
|
||||
response := models.AgentRegistrationResponse{
|
||||
AgentID: agent.ID,
|
||||
Token: token,
|
||||
AgentID: agent.ID,
|
||||
Token: token,
|
||||
RefreshToken: refreshToken,
|
||||
Config: map[string]interface{}{
|
||||
"check_in_interval": h.checkInInterval,
|
||||
"server_url": c.Request.Host,
|
||||
@@ -81,15 +101,123 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
||||
}
|
||||
|
||||
// GetCommands returns pending commands for an agent
|
||||
// Agents can optionally send lightweight system metrics in request body
|
||||
func (h *AgentHandler) GetCommands(c *gin.Context) {
|
||||
agentID := c.MustGet("agent_id").(uuid.UUID)
|
||||
|
||||
// Try to parse optional system metrics from request body
|
||||
var metrics struct {
|
||||
CPUPercent float64 `json:"cpu_percent,omitempty"`
|
||||
MemoryPercent float64 `json:"memory_percent,omitempty"`
|
||||
MemoryUsedGB float64 `json:"memory_used_gb,omitempty"`
|
||||
MemoryTotalGB float64 `json:"memory_total_gb,omitempty"`
|
||||
DiskUsedGB float64 `json:"disk_used_gb,omitempty"`
|
||||
DiskTotalGB float64 `json:"disk_total_gb,omitempty"`
|
||||
DiskPercent float64 `json:"disk_percent,omitempty"`
|
||||
Uptime string `json:"uptime,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// Parse metrics if provided (optional, won't fail if empty)
|
||||
err := c.ShouldBindJSON(&metrics)
|
||||
if err != nil {
|
||||
log.Printf("DEBUG: Failed to parse metrics JSON: %v", err)
|
||||
}
|
||||
|
||||
// Debug logging to see what we received
|
||||
log.Printf("DEBUG: Received metrics - Version: '%s', CPU: %.2f, Memory: %.2f",
|
||||
metrics.Version, metrics.CPUPercent, metrics.MemoryPercent)
|
||||
|
||||
// Always handle version information if provided
|
||||
if metrics.Version != "" {
|
||||
// Get current agent to preserve existing metadata
|
||||
agent, err := h.agentQueries.GetAgentByID(agentID)
|
||||
if err == nil && agent.Metadata != nil {
|
||||
// Update agent's current version
|
||||
if err := h.agentQueries.UpdateAgentVersion(agentID, metrics.Version); err != nil {
|
||||
log.Printf("Warning: Failed to update agent version: %v", err)
|
||||
} else {
|
||||
// Check if update is available
|
||||
updateAvailable := utils.IsNewerVersion(h.latestAgentVersion, metrics.Version)
|
||||
|
||||
// Update agent's update availability status
|
||||
if err := h.agentQueries.UpdateAgentUpdateAvailable(agentID, updateAvailable); err != nil {
|
||||
log.Printf("Warning: Failed to update agent update availability: %v", err)
|
||||
}
|
||||
|
||||
// Log version check
|
||||
if updateAvailable {
|
||||
log.Printf("🔄 Agent %s (%s) version %s has update available: %s",
|
||||
agent.Hostname, agentID, metrics.Version, h.latestAgentVersion)
|
||||
} else {
|
||||
log.Printf("✅ Agent %s (%s) version %s is up to date",
|
||||
agent.Hostname, agentID, metrics.Version)
|
||||
}
|
||||
|
||||
// Store version in metadata as well
|
||||
agent.Metadata["reported_version"] = metrics.Version
|
||||
agent.Metadata["latest_version"] = h.latestAgentVersion
|
||||
agent.Metadata["update_available"] = updateAvailable
|
||||
agent.Metadata["version_checked_at"] = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update agent metadata with current metrics if provided
|
||||
if metrics.CPUPercent > 0 || metrics.MemoryPercent > 0 || metrics.DiskUsedGB > 0 || metrics.Uptime != "" {
|
||||
// Get current agent to preserve existing metadata
|
||||
agent, err := h.agentQueries.GetAgentByID(agentID)
|
||||
if err == nil && agent.Metadata != nil {
|
||||
// Update metrics in metadata
|
||||
agent.Metadata["cpu_percent"] = metrics.CPUPercent
|
||||
agent.Metadata["memory_percent"] = metrics.MemoryPercent
|
||||
agent.Metadata["memory_used_gb"] = metrics.MemoryUsedGB
|
||||
agent.Metadata["memory_total_gb"] = metrics.MemoryTotalGB
|
||||
agent.Metadata["disk_used_gb"] = metrics.DiskUsedGB
|
||||
agent.Metadata["disk_total_gb"] = metrics.DiskTotalGB
|
||||
agent.Metadata["disk_percent"] = metrics.DiskPercent
|
||||
agent.Metadata["uptime"] = metrics.Uptime
|
||||
agent.Metadata["metrics_updated_at"] = time.Now().Format(time.RFC3339)
|
||||
|
||||
// Update agent with new metadata
|
||||
if err := h.agentQueries.UpdateAgent(agent); err != nil {
|
||||
log.Printf("Warning: Failed to update agent metrics: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update last_seen
|
||||
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
|
||||
return
|
||||
}
|
||||
log.Printf("Updated last_seen for agent %s", agentID)
|
||||
|
||||
// Check for version updates for agents that don't send version in metrics
|
||||
// This ensures agents like Metis that don't report version still get update checks
|
||||
if metrics.Version == "" {
|
||||
// Get current agent to check version
|
||||
agent, err := h.agentQueries.GetAgentByID(agentID)
|
||||
if err == nil && agent.CurrentVersion != "" {
|
||||
// Check if update is available based on stored version
|
||||
updateAvailable := utils.IsNewerVersion(h.latestAgentVersion, agent.CurrentVersion)
|
||||
|
||||
// Update agent's update availability status if it changed
|
||||
if agent.UpdateAvailable != updateAvailable {
|
||||
if err := h.agentQueries.UpdateAgentUpdateAvailable(agentID, updateAvailable); err != nil {
|
||||
log.Printf("Warning: Failed to update agent update availability: %v", err)
|
||||
} else {
|
||||
// Log version check for agent without version reporting
|
||||
if updateAvailable {
|
||||
log.Printf("🔄 Agent %s (%s) stored version %s has update available: %s",
|
||||
agent.Hostname, agentID, agent.CurrentVersion, h.latestAgentVersion)
|
||||
} else {
|
||||
log.Printf("✅ Agent %s (%s) stored version %s is up to date",
|
||||
agent.Hostname, agentID, agent.CurrentVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get pending commands
|
||||
commands, err := h.commandQueries.GetPendingCommands(agentID)
|
||||
@@ -246,6 +374,58 @@ func (h *AgentHandler) TriggerUpdate(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// RenewToken handles token renewal using refresh token
|
||||
func (h *AgentHandler) RenewToken(c *gin.Context) {
|
||||
var req models.TokenRenewalRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate refresh token
|
||||
refreshToken, err := h.refreshTokenQueries.ValidateRefreshToken(req.AgentID, req.RefreshToken)
|
||||
if err != nil {
|
||||
log.Printf("Token renewal failed for agent %s: %v", req.AgentID, err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if agent still exists
|
||||
agent, err := h.agentQueries.GetAgentByID(req.AgentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update agent last_seen timestamp
|
||||
if err := h.agentQueries.UpdateAgentLastSeen(req.AgentID); err != nil {
|
||||
log.Printf("Warning: Failed to update last_seen for agent %s: %v", req.AgentID, err)
|
||||
}
|
||||
|
||||
// Update refresh token expiration (sliding window - reset to 90 days from now)
|
||||
// This ensures active agents never need to re-register
|
||||
newExpiry := time.Now().Add(90 * 24 * time.Hour)
|
||||
if err := h.refreshTokenQueries.UpdateExpiration(refreshToken.ID, newExpiry); err != nil {
|
||||
log.Printf("Warning: Failed to update refresh token expiration: %v", err)
|
||||
}
|
||||
|
||||
// Generate new access token (24 hours)
|
||||
token, err := middleware.GenerateAgentToken(req.AgentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ Token renewed successfully for agent %s (%s)", agent.Hostname, req.AgentID)
|
||||
|
||||
// Return new access token
|
||||
response := models.TokenRenewalResponse{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// UnregisterAgent removes an agent from the system
|
||||
func (h *AgentHandler) UnregisterAgent(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
|
||||
@@ -13,14 +13,16 @@ import (
|
||||
)
|
||||
|
||||
type UpdateHandler struct {
|
||||
updateQueries *queries.UpdateQueries
|
||||
agentQueries *queries.AgentQueries
|
||||
updateQueries *queries.UpdateQueries
|
||||
agentQueries *queries.AgentQueries
|
||||
commandQueries *queries.CommandQueries
|
||||
}
|
||||
|
||||
func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries) *UpdateHandler {
|
||||
func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries) *UpdateHandler {
|
||||
return &UpdateHandler{
|
||||
updateQueries: uq,
|
||||
agentQueries: aq,
|
||||
updateQueries: uq,
|
||||
agentQueries: aq,
|
||||
commandQueries: cq,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,11 +184,46 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
|
||||
ExecutedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Store the log entry
|
||||
if err := h.updateQueries.CreateUpdateLog(log); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save log"})
|
||||
return
|
||||
}
|
||||
|
||||
// NEW: Update command status if command_id is provided
|
||||
if req.CommandID != "" {
|
||||
commandID, err := uuid.Parse(req.CommandID)
|
||||
if err != nil {
|
||||
// Log warning but don't fail the request
|
||||
fmt.Printf("Warning: Invalid command ID format in log request: %s\n", req.CommandID)
|
||||
} else {
|
||||
// Prepare result data for command update
|
||||
result := models.JSONB{
|
||||
"stdout": req.Stdout,
|
||||
"stderr": req.Stderr,
|
||||
"exit_code": req.ExitCode,
|
||||
"duration_seconds": req.DurationSeconds,
|
||||
"logged_at": time.Now(),
|
||||
}
|
||||
|
||||
// Update command status based on log result
|
||||
if req.Result == "success" {
|
||||
if err := h.commandQueries.MarkCommandCompleted(commandID, result); err != nil {
|
||||
fmt.Printf("Warning: Failed to mark command %s as completed: %v\n", commandID, err)
|
||||
}
|
||||
} else if req.Result == "failed" || req.Result == "dry_run_failed" {
|
||||
if err := h.commandQueries.MarkCommandFailed(commandID, result); err != nil {
|
||||
fmt.Printf("Warning: Failed to mark command %s as failed: %v\n", commandID, err)
|
||||
}
|
||||
} else {
|
||||
// For other results, just update the result field
|
||||
if err := h.commandQueries.UpdateCommandResult(commandID, result); err != nil {
|
||||
fmt.Printf("Warning: Failed to update command %s result: %v\n", commandID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "log recorded"})
|
||||
}
|
||||
|
||||
@@ -328,7 +365,7 @@ func (h *UpdateHandler) RejectUpdate(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "update rejected"})
|
||||
}
|
||||
|
||||
// InstallUpdate marks an update as ready for installation
|
||||
// InstallUpdate marks an update as ready for installation and creates a dry run command for the agent
|
||||
func (h *UpdateHandler) InstallUpdate(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
@@ -337,10 +374,268 @@ func (h *UpdateHandler) InstallUpdate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.updateQueries.InstallUpdate(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start update installation"})
|
||||
// Get the full update details to extract agent_id, package_name, and package_type
|
||||
update, err := h.updateQueries.GetUpdateByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get update details"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "update installation started"})
|
||||
// Create a command for the agent to perform dry run first
|
||||
command := &models.AgentCommand{
|
||||
ID: uuid.New(),
|
||||
AgentID: update.AgentID,
|
||||
CommandType: models.CommandTypeDryRunUpdate,
|
||||
Params: map[string]interface{}{
|
||||
"update_id": id.String(),
|
||||
"package_name": update.PackageName,
|
||||
"package_type": update.PackageType,
|
||||
},
|
||||
Status: models.CommandStatusPending,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Store the command in database
|
||||
if err := h.commandQueries.CreateCommand(command); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dry run command"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update the package status to 'checking_dependencies' to show dry run is starting
|
||||
if err := h.updateQueries.SetCheckingDependencies(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "dry run command created for agent",
|
||||
"command_id": command.ID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetUpdateLogs retrieves installation logs for a specific update
|
||||
func (h *UpdateHandler) GetUpdateLogs(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse limit from query params
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
|
||||
logs, err := h.updateQueries.GetUpdateLogs(id, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve update logs"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": logs,
|
||||
"count": len(logs),
|
||||
})
|
||||
}
|
||||
|
||||
// ReportDependencies handles dependency reporting from agents after dry run
|
||||
func (h *UpdateHandler) ReportDependencies(c *gin.Context) {
|
||||
agentID := c.MustGet("agent_id").(uuid.UUID)
|
||||
|
||||
// Update last_seen timestamp
|
||||
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.DependencyReportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update the package status to pending_dependencies
|
||||
if err := h.updateQueries.SetPendingDependencies(agentID, req.PackageType, req.PackageName, req.Dependencies); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "dependencies reported and status updated"})
|
||||
}
|
||||
|
||||
// ConfirmDependencies handles user confirmation to proceed with dependency installation
|
||||
func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the update details
|
||||
update, err := h.updateQueries.GetUpdateByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "update not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create a command for the agent to install with dependencies
|
||||
command := &models.AgentCommand{
|
||||
ID: uuid.New(),
|
||||
AgentID: update.AgentID,
|
||||
CommandType: models.CommandTypeConfirmDependencies,
|
||||
Params: map[string]interface{}{
|
||||
"update_id": id.String(),
|
||||
"package_name": update.PackageName,
|
||||
"package_type": update.PackageType,
|
||||
"dependencies": update.Metadata["dependencies"], // Dependencies stored in metadata
|
||||
},
|
||||
Status: models.CommandStatusPending,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Store the command in database
|
||||
if err := h.commandQueries.CreateCommand(command); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create confirmation command"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update the package status to 'installing'
|
||||
if err := h.updateQueries.InstallUpdate(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "dependency installation confirmed and command created",
|
||||
"command_id": command.ID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllLogs retrieves logs across all agents with filtering for universal log view
|
||||
func (h *UpdateHandler) GetAllLogs(c *gin.Context) {
|
||||
filters := &models.LogFilters{
|
||||
Action: c.Query("action"),
|
||||
Result: c.Query("result"),
|
||||
}
|
||||
|
||||
// Parse agent_id if provided
|
||||
if agentIDStr := c.Query("agent_id"); agentIDStr != "" {
|
||||
agentID, err := uuid.Parse(agentIDStr)
|
||||
if err == nil {
|
||||
filters.AgentID = agentID
|
||||
}
|
||||
}
|
||||
|
||||
// Parse since timestamp if provided
|
||||
if sinceStr := c.Query("since"); sinceStr != "" {
|
||||
sinceTime, err := time.Parse(time.RFC3339, sinceStr)
|
||||
if err == nil {
|
||||
filters.Since = &sinceTime
|
||||
}
|
||||
}
|
||||
|
||||
// Parse pagination
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100"))
|
||||
filters.Page = page
|
||||
filters.PageSize = pageSize
|
||||
|
||||
logs, total, err := h.updateQueries.GetAllLogs(filters)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve logs"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": logs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// GetActiveOperations retrieves currently running operations for live status view
|
||||
func (h *UpdateHandler) GetActiveOperations(c *gin.Context) {
|
||||
operations, err := h.updateQueries.GetActiveOperations()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve active operations"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"operations": operations,
|
||||
"count": len(operations),
|
||||
})
|
||||
}
|
||||
|
||||
// RetryCommand retries a failed, timed_out, or cancelled command
|
||||
func (h *UpdateHandler) RetryCommand(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new command based on the original
|
||||
newCommand, err := h.commandQueries.RetryCommand(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to retry command: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "command retry created",
|
||||
"command_id": newCommand.ID.String(),
|
||||
"new_id": newCommand.ID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// CancelCommand cancels a pending or sent command
|
||||
func (h *UpdateHandler) CancelCommand(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel the command
|
||||
if err := h.commandQueries.CancelCommand(id); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to cancel command: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "command cancelled"})
|
||||
}
|
||||
|
||||
// GetActiveCommands retrieves currently active commands for live operations view
|
||||
func (h *UpdateHandler) GetActiveCommands(c *gin.Context) {
|
||||
commands, err := h.commandQueries.GetActiveCommands()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve active commands"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"commands": commands,
|
||||
"count": len(commands),
|
||||
})
|
||||
}
|
||||
|
||||
// GetRecentCommands retrieves recent commands for retry functionality
|
||||
func (h *UpdateHandler) GetRecentCommands(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
|
||||
commands, err := h.commandQueries.GetRecentCommands(limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve recent commands"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"commands": commands,
|
||||
"count": len(commands),
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type Config struct {
|
||||
CheckInInterval int
|
||||
OfflineThreshold int
|
||||
Timezone string
|
||||
LatestAgentVersion string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables
|
||||
@@ -27,12 +28,13 @@ func Load() (*Config, error) {
|
||||
offlineThreshold, _ := strconv.Atoi(getEnv("OFFLINE_THRESHOLD", "600"))
|
||||
|
||||
cfg := &Config{
|
||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://aggregator:aggregator@localhost:5432/aggregator?sslmode=disable"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "test-secret-for-development-only"),
|
||||
CheckInInterval: checkInInterval,
|
||||
OfflineThreshold: offlineThreshold,
|
||||
Timezone: getEnv("TIMEZONE", "UTC"),
|
||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://aggregator:aggregator@localhost:5432/aggregator?sslmode=disable"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "test-secret-for-development-only"),
|
||||
CheckInInterval: checkInInterval,
|
||||
OfflineThreshold: offlineThreshold,
|
||||
Timezone: getEnv("TIMEZONE", "UTC"),
|
||||
LatestAgentVersion: getEnv("LATEST_AGENT_VERSION", "0.1.4"),
|
||||
}
|
||||
|
||||
// Debug: Log what JWT secret we're using (remove in production)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Fix foreign key relationship for update_logs table to reference current_package_state instead of update_packages
|
||||
-- This ensures compatibility with the new event sourcing system
|
||||
|
||||
-- First, drop the existing foreign key constraint
|
||||
ALTER TABLE update_logs DROP CONSTRAINT IF EXISTS update_logs_update_package_id_fkey;
|
||||
|
||||
-- Add the new foreign key constraint to reference current_package_state
|
||||
ALTER TABLE update_logs
|
||||
ADD CONSTRAINT update_logs_update_package_id_fkey
|
||||
FOREIGN KEY (update_package_id) REFERENCES current_package_state(id) ON DELETE SET NULL;
|
||||
|
||||
-- Add index for better performance on the new foreign key
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_update_package ON update_logs(update_package_id);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Add pending_dependencies and checking_dependencies status to support dependency confirmation workflow
|
||||
ALTER TABLE current_package_state
|
||||
DROP CONSTRAINT IF EXISTS current_package_state_status_check;
|
||||
|
||||
ALTER TABLE current_package_state
|
||||
ADD CONSTRAINT current_package_state_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'updated', 'failed', 'ignored', 'installing', 'pending_dependencies', 'checking_dependencies'));
|
||||
|
||||
-- Also update any legacy tables if they exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'updates') THEN
|
||||
ALTER TABLE updates
|
||||
DROP CONSTRAINT IF EXISTS updates_status_check,
|
||||
ADD CONSTRAINT updates_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'scheduled', 'installing', 'installed', 'failed', 'ignored', 'pending_dependencies', 'checking_dependencies'));
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Add missing command statuses to the check constraint
|
||||
-- This allows 'timed_out', 'cancelled', and 'running' statuses that the application uses
|
||||
|
||||
-- First drop the existing constraint
|
||||
ALTER TABLE agent_commands DROP CONSTRAINT IF EXISTS agent_commands_status_check;
|
||||
|
||||
-- Add the new constraint with all valid statuses
|
||||
ALTER TABLE agent_commands
|
||||
ADD CONSTRAINT agent_commands_status_check
|
||||
CHECK (status::text = ANY (ARRAY[
|
||||
'pending'::character varying,
|
||||
'sent'::character varying,
|
||||
'running'::character varying,
|
||||
'completed'::character varying,
|
||||
'failed'::character varying,
|
||||
'timed_out'::character varying,
|
||||
'cancelled'::character varying
|
||||
]::text[]));
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Expand status column to accommodate longer status values
|
||||
-- checking_dependencies (23 chars) and pending_dependencies (21 chars) exceed current 20 char limit
|
||||
|
||||
ALTER TABLE current_package_state
|
||||
ALTER COLUMN status TYPE character varying(30);
|
||||
|
||||
-- Update check constraint to match new length
|
||||
ALTER TABLE current_package_state
|
||||
DROP CONSTRAINT IF EXISTS current_package_state_status_check;
|
||||
|
||||
ALTER TABLE current_package_state
|
||||
ADD CONSTRAINT current_package_state_status_check
|
||||
CHECK (status::text = ANY (ARRAY['pending'::character varying, 'approved'::character varying, 'updated'::character varying, 'failed'::character varying, 'ignored'::character varying, 'installing'::character varying, 'pending_dependencies'::character varying, 'checking_dependencies'::character varying]::text[]));
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 008_create_refresh_tokens_table.sql
|
||||
-- Create refresh tokens table for secure token renewal
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(64) NOT NULL, -- SHA-256 hash of the refresh token
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
last_used_at TIMESTAMP,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
CONSTRAINT unique_token_hash UNIQUE(token_hash)
|
||||
);
|
||||
|
||||
-- Index for fast agent lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_agent_id ON refresh_tokens(agent_id);
|
||||
|
||||
-- Index for expiration cleanup
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
|
||||
|
||||
-- Index for token validation
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash_not_revoked
|
||||
ON refresh_tokens(token_hash) WHERE NOT revoked;
|
||||
|
||||
COMMENT ON TABLE refresh_tokens IS 'Stores long-lived refresh tokens for agent token renewal without re-registration';
|
||||
COMMENT ON COLUMN refresh_tokens.token_hash IS 'SHA-256 hash of the refresh token for secure storage';
|
||||
COMMENT ON COLUMN refresh_tokens.expires_at IS 'Refresh token expiration (default: 90 days from creation)';
|
||||
COMMENT ON COLUMN refresh_tokens.last_used_at IS 'Timestamp of last successful token renewal';
|
||||
COMMENT ON COLUMN refresh_tokens.revoked IS 'Flag to revoke token before expiration';
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Add version tracking to agents table
|
||||
-- This enables the hybrid version tracking system
|
||||
|
||||
ALTER TABLE agents
|
||||
ADD COLUMN current_version VARCHAR(50) DEFAULT '0.1.3',
|
||||
ADD COLUMN update_available BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN last_version_check TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- Add index for faster queries on update status
|
||||
CREATE INDEX idx_agents_update_available ON agents(update_available);
|
||||
CREATE INDEX idx_agents_current_version ON agents(current_version);
|
||||
|
||||
-- Add comment to document the purpose
|
||||
COMMENT ON COLUMN agents.current_version IS 'The version of the agent currently running';
|
||||
COMMENT ON COLUMN agents.update_available IS 'Whether an update is available for this agent';
|
||||
COMMENT ON COLUMN agents.last_version_check IS 'Last time the agent version was checked';
|
||||
@@ -49,6 +49,24 @@ func (q *AgentQueries) UpdateAgentLastSeen(id uuid.UUID) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateAgent updates an agent's full record including metadata
|
||||
func (q *AgentQueries) UpdateAgent(agent *models.Agent) error {
|
||||
query := `
|
||||
UPDATE agents SET
|
||||
hostname = :hostname,
|
||||
os_type = :os_type,
|
||||
os_version = :os_version,
|
||||
os_architecture = :os_architecture,
|
||||
agent_version = :agent_version,
|
||||
last_seen = :last_seen,
|
||||
status = :status,
|
||||
metadata = :metadata
|
||||
WHERE id = :id
|
||||
`
|
||||
_, err := q.db.NamedExec(query, agent)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAgents returns all agents with optional filtering
|
||||
func (q *AgentQueries) ListAgents(status, osType string) ([]models.Agent, error) {
|
||||
var agents []models.Agent
|
||||
@@ -137,6 +155,29 @@ func (q *AgentQueries) ListAgentsWithLastScan(status, osType string) ([]models.A
|
||||
return agents, err
|
||||
}
|
||||
|
||||
// UpdateAgentVersion updates the agent's version information and checks for updates
|
||||
func (q *AgentQueries) UpdateAgentVersion(id uuid.UUID, currentVersion string) error {
|
||||
query := `
|
||||
UPDATE agents SET
|
||||
current_version = $1,
|
||||
last_version_check = $2
|
||||
WHERE id = $3
|
||||
`
|
||||
_, err := q.db.Exec(query, currentVersion, time.Now().UTC(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateAgentUpdateAvailable sets whether an update is available for an agent
|
||||
func (q *AgentQueries) UpdateAgentUpdateAvailable(id uuid.UUID, updateAvailable bool) error {
|
||||
query := `
|
||||
UPDATE agents SET
|
||||
update_available = $1
|
||||
WHERE id = $2
|
||||
`
|
||||
_, err := q.db.Exec(query, updateAvailable, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAgent removes an agent and all associated data
|
||||
func (q *AgentQueries) DeleteAgent(id uuid.UUID) error {
|
||||
// Start a transaction for atomic deletion
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-server/internal/models"
|
||||
@@ -77,3 +78,168 @@ func (q *CommandQueries) MarkCommandFailed(id uuid.UUID, result models.JSONB) er
|
||||
_, err := q.db.Exec(query, now, result, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCommandsByStatus retrieves commands with a specific status
|
||||
func (q *CommandQueries) GetCommandsByStatus(status string) ([]models.AgentCommand, error) {
|
||||
var commands []models.AgentCommand
|
||||
query := `
|
||||
SELECT * FROM agent_commands
|
||||
WHERE status = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
err := q.db.Select(&commands, query, status)
|
||||
return commands, err
|
||||
}
|
||||
|
||||
// UpdateCommandStatus updates only the status of a command
|
||||
func (q *CommandQueries) UpdateCommandStatus(id uuid.UUID, status string) error {
|
||||
query := `
|
||||
UPDATE agent_commands
|
||||
SET status = $1
|
||||
WHERE id = $2
|
||||
`
|
||||
_, err := q.db.Exec(query, status, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateCommandResult updates only the result of a command
|
||||
func (q *CommandQueries) UpdateCommandResult(id uuid.UUID, result interface{}) error {
|
||||
query := `
|
||||
UPDATE agent_commands
|
||||
SET result = $1
|
||||
WHERE id = $2
|
||||
`
|
||||
_, err := q.db.Exec(query, result, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCommandByID retrieves a specific command by ID
|
||||
func (q *CommandQueries) GetCommandByID(id uuid.UUID) (*models.AgentCommand, error) {
|
||||
var command models.AgentCommand
|
||||
query := `
|
||||
SELECT * FROM agent_commands
|
||||
WHERE id = $1
|
||||
`
|
||||
err := q.db.Get(&command, query, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &command, nil
|
||||
}
|
||||
|
||||
// CancelCommand marks a command as cancelled
|
||||
func (q *CommandQueries) CancelCommand(id uuid.UUID) error {
|
||||
now := time.Now()
|
||||
query := `
|
||||
UPDATE agent_commands
|
||||
SET status = 'cancelled', completed_at = $1
|
||||
WHERE id = $2 AND status IN ('pending', 'sent')
|
||||
`
|
||||
_, err := q.db.Exec(query, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// RetryCommand creates a new command based on a failed/timed_out/cancelled command
|
||||
func (q *CommandQueries) RetryCommand(originalID uuid.UUID) (*models.AgentCommand, error) {
|
||||
// Get the original command
|
||||
original, err := q.GetCommandByID(originalID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Only allow retry of failed, timed_out, or cancelled commands
|
||||
if original.Status != "failed" && original.Status != "timed_out" && original.Status != "cancelled" {
|
||||
return nil, fmt.Errorf("command must be failed, timed_out, or cancelled to retry")
|
||||
}
|
||||
|
||||
// Create new command with same parameters
|
||||
newCommand := &models.AgentCommand{
|
||||
ID: uuid.New(),
|
||||
AgentID: original.AgentID,
|
||||
CommandType: original.CommandType,
|
||||
Params: original.Params,
|
||||
Status: models.CommandStatusPending,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Store the new command
|
||||
if err := q.CreateCommand(newCommand); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newCommand, nil
|
||||
}
|
||||
|
||||
// GetActiveCommands retrieves commands that are not in a final/terminal state
|
||||
// Shows anything that's in progress or can be retried (excludes completed and cancelled)
|
||||
func (q *CommandQueries) GetActiveCommands() ([]models.ActiveCommandInfo, error) {
|
||||
var commands []models.ActiveCommandInfo
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
c.id,
|
||||
c.agent_id,
|
||||
c.command_type,
|
||||
c.status,
|
||||
c.created_at,
|
||||
c.sent_at,
|
||||
c.result,
|
||||
a.hostname as agent_hostname,
|
||||
COALESCE(ups.package_name, 'N/A') as package_name,
|
||||
COALESCE(ups.package_type, 'N/A') as package_type
|
||||
FROM agent_commands c
|
||||
LEFT JOIN agents a ON c.agent_id = a.id
|
||||
LEFT JOIN current_package_state ups ON (
|
||||
c.params->>'update_id' = ups.id::text OR
|
||||
(c.params->>'package_name' = ups.package_name AND c.params->>'package_type' = ups.package_type)
|
||||
)
|
||||
WHERE c.status NOT IN ('completed', 'cancelled')
|
||||
ORDER BY c.created_at DESC
|
||||
`
|
||||
|
||||
err := q.db.Select(&commands, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active commands: %w", err)
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetRecentCommands retrieves recent commands (including failed, completed, etc.) for retry functionality
|
||||
func (q *CommandQueries) GetRecentCommands(limit int) ([]models.ActiveCommandInfo, error) {
|
||||
var commands []models.ActiveCommandInfo
|
||||
|
||||
if limit == 0 {
|
||||
limit = 50 // Default limit
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
c.id,
|
||||
c.agent_id,
|
||||
c.command_type,
|
||||
c.status,
|
||||
c.created_at,
|
||||
c.sent_at,
|
||||
c.completed_at,
|
||||
c.result,
|
||||
a.hostname as agent_hostname,
|
||||
COALESCE(ups.package_name, 'N/A') as package_name,
|
||||
COALESCE(ups.package_type, 'N/A') as package_type
|
||||
FROM agent_commands c
|
||||
LEFT JOIN agents a ON c.agent_id = a.id
|
||||
LEFT JOIN current_package_state ups ON (
|
||||
c.params->>'update_id' = ups.id::text OR
|
||||
(c.params->>'package_name' = ups.package_name AND c.params->>'package_type' = ups.package_type)
|
||||
)
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
err := q.db.Select(&commands, query, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get recent commands: %w", err)
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
171
aggregator-server/internal/database/queries/refresh_tokens.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type RefreshTokenQueries struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewRefreshTokenQueries(db *sqlx.DB) *RefreshTokenQueries {
|
||||
return &RefreshTokenQueries{db: db}
|
||||
}
|
||||
|
||||
// RefreshToken represents a refresh token in the database
|
||||
type RefreshToken struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
AgentID uuid.UUID `db:"agent_id"`
|
||||
TokenHash string `db:"token_hash"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
LastUsedAt *time.Time `db:"last_used_at"`
|
||||
Revoked bool `db:"revoked"`
|
||||
}
|
||||
|
||||
// GenerateRefreshToken creates a cryptographically secure random token
|
||||
func GenerateRefreshToken() (string, error) {
|
||||
// Generate 32 bytes of random data (256 bits)
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random token: %w", err)
|
||||
}
|
||||
|
||||
// Encode as hex string (64 characters)
|
||||
token := hex.EncodeToString(tokenBytes)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// HashRefreshToken creates SHA-256 hash of the token for storage
|
||||
func HashRefreshToken(token string) string {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// CreateRefreshToken stores a new refresh token for an agent
|
||||
func (q *RefreshTokenQueries) CreateRefreshToken(agentID uuid.UUID, token string, expiresAt time.Time) error {
|
||||
tokenHash := HashRefreshToken(token)
|
||||
|
||||
query := `
|
||||
INSERT INTO refresh_tokens (agent_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
_, err := q.db.Exec(query, agentID, tokenHash, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// ValidateRefreshToken checks if a refresh token is valid
|
||||
func (q *RefreshTokenQueries) ValidateRefreshToken(agentID uuid.UUID, token string) (*RefreshToken, error) {
|
||||
tokenHash := HashRefreshToken(token)
|
||||
|
||||
query := `
|
||||
SELECT id, agent_id, token_hash, expires_at, created_at, last_used_at, revoked
|
||||
FROM refresh_tokens
|
||||
WHERE agent_id = $1 AND token_hash = $2 AND NOT revoked
|
||||
`
|
||||
|
||||
var refreshToken RefreshToken
|
||||
err := q.db.Get(&refreshToken, query, agentID, tokenHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh token not found or invalid: %w", err)
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if time.Now().After(refreshToken.ExpiresAt) {
|
||||
return nil, fmt.Errorf("refresh token expired")
|
||||
}
|
||||
|
||||
return &refreshToken, nil
|
||||
}
|
||||
|
||||
// UpdateLastUsed updates the last_used_at timestamp for a refresh token
|
||||
func (q *RefreshTokenQueries) UpdateLastUsed(tokenID uuid.UUID) error {
|
||||
query := `
|
||||
UPDATE refresh_tokens
|
||||
SET last_used_at = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
_, err := q.db.Exec(query, tokenID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateExpiration updates the refresh token expiration (for sliding window)
|
||||
// Resets expiration to specified time and updates last_used_at
|
||||
func (q *RefreshTokenQueries) UpdateExpiration(tokenID uuid.UUID, newExpiry time.Time) error {
|
||||
query := `
|
||||
UPDATE refresh_tokens
|
||||
SET expires_at = $1, last_used_at = NOW()
|
||||
WHERE id = $2
|
||||
`
|
||||
|
||||
_, err := q.db.Exec(query, newExpiry, tokenID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeRefreshToken marks a refresh token as revoked
|
||||
func (q *RefreshTokenQueries) RevokeRefreshToken(agentID uuid.UUID, token string) error {
|
||||
tokenHash := HashRefreshToken(token)
|
||||
|
||||
query := `
|
||||
UPDATE refresh_tokens
|
||||
SET revoked = TRUE
|
||||
WHERE agent_id = $1 AND token_hash = $2
|
||||
`
|
||||
|
||||
_, err := q.db.Exec(query, agentID, tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeAllAgentTokens revokes all refresh tokens for an agent
|
||||
func (q *RefreshTokenQueries) RevokeAllAgentTokens(agentID uuid.UUID) error {
|
||||
query := `
|
||||
UPDATE refresh_tokens
|
||||
SET revoked = TRUE
|
||||
WHERE agent_id = $1 AND NOT revoked
|
||||
`
|
||||
|
||||
_, err := q.db.Exec(query, agentID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CleanupExpiredTokens removes expired refresh tokens from the database
|
||||
func (q *RefreshTokenQueries) CleanupExpiredTokens() (int64, error) {
|
||||
query := `
|
||||
DELETE FROM refresh_tokens
|
||||
WHERE expires_at < NOW() OR revoked = TRUE
|
||||
`
|
||||
|
||||
result, err := q.db.Exec(query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return rowsAffected, nil
|
||||
}
|
||||
|
||||
// GetActiveTokenCount returns the number of active (non-revoked, non-expired) tokens for an agent
|
||||
func (q *RefreshTokenQueries) GetActiveTokenCount(agentID uuid.UUID) (int, error) {
|
||||
query := `
|
||||
SELECT COUNT(*)
|
||||
FROM refresh_tokens
|
||||
WHERE agent_id = $1 AND NOT revoked AND expires_at > NOW()
|
||||
`
|
||||
|
||||
var count int
|
||||
err := q.db.Get(&count, query, agentID)
|
||||
return count, err
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -210,6 +211,41 @@ func (q *UpdateQueries) InstallUpdate(id uuid.UUID) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetCheckingDependencies marks an update as being checked for dependencies
|
||||
func (q *UpdateQueries) SetCheckingDependencies(id uuid.UUID) error {
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'checking_dependencies', last_updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'approved'
|
||||
`
|
||||
_, err := q.db.Exec(query, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetPendingDependencies marks an update as having dependencies that need approval
|
||||
func (q *UpdateQueries) SetPendingDependencies(agentID uuid.UUID, packageType, packageName string, dependencies []string) error {
|
||||
// Marshal dependencies to JSON for database storage
|
||||
depsJSON, err := json.Marshal(dependencies)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal dependencies: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'pending_dependencies',
|
||||
metadata = jsonb_set(
|
||||
jsonb_set(metadata, '{dependencies}', $4::jsonb),
|
||||
'{dependencies_reported_at}',
|
||||
to_jsonb(NOW())
|
||||
),
|
||||
last_updated_at = NOW()
|
||||
WHERE agent_id = $1 AND package_type = $2 AND package_name = $3
|
||||
AND status IN ('checking_dependencies', 'installing')
|
||||
`
|
||||
_, err = q.db.Exec(query, agentID, packageType, packageName, depsJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateUpdateLog inserts an update log entry
|
||||
func (q *UpdateQueries) CreateUpdateLog(log *models.UpdateLog) error {
|
||||
query := `
|
||||
@@ -456,7 +492,7 @@ func (q *UpdateQueries) GetPackageHistory(agentID uuid.UUID, packageType, packag
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (q *UpdateQueries) UpdatePackageStatus(agentID uuid.UUID, packageType, packageName, status string, metadata models.JSONB) error {
|
||||
tx, err := q.db.Beginx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
@@ -587,3 +623,127 @@ func (q *UpdateQueries) GetAllUpdateStats() (*models.UpdateStats, error) {
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetUpdateLogs retrieves installation logs for a specific update
|
||||
func (q *UpdateQueries) GetUpdateLogs(updateID uuid.UUID, limit int) ([]models.UpdateLog, error) {
|
||||
var logs []models.UpdateLog
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
id, agent_id, update_package_id, action, result,
|
||||
stdout, stderr, exit_code, duration_seconds, executed_at
|
||||
FROM update_logs
|
||||
WHERE update_package_id = $1
|
||||
ORDER BY executed_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
if limit == 0 {
|
||||
limit = 50 // Default limit
|
||||
}
|
||||
|
||||
err := q.db.Select(&logs, query, updateID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get update logs: %w", err)
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// GetAllLogs retrieves logs across all agents with filtering
|
||||
func (q *UpdateQueries) GetAllLogs(filters *models.LogFilters) ([]models.UpdateLog, int, error) {
|
||||
var logs []models.UpdateLog
|
||||
whereClause := []string{"1=1"}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
// Add filters
|
||||
if filters.AgentID != uuid.Nil {
|
||||
whereClause = append(whereClause, fmt.Sprintf("agent_id = $%d", argIdx))
|
||||
args = append(args, filters.AgentID)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if filters.Action != "" {
|
||||
whereClause = append(whereClause, fmt.Sprintf("action = $%d", argIdx))
|
||||
args = append(args, filters.Action)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if filters.Result != "" {
|
||||
whereClause = append(whereClause, fmt.Sprintf("result = $%d", argIdx))
|
||||
args = append(args, filters.Result)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if filters.Since != nil {
|
||||
whereClause = append(whereClause, fmt.Sprintf("executed_at >= $%d", argIdx))
|
||||
args = append(args, filters.Since)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
// Get total count
|
||||
countQuery := "SELECT COUNT(*) FROM update_logs WHERE " + strings.Join(whereClause, " AND ")
|
||||
var total int
|
||||
err := q.db.Get(&total, countQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get logs count: %w", err)
|
||||
}
|
||||
|
||||
// Get paginated results
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
id, agent_id, update_package_id, action, result,
|
||||
stdout, stderr, exit_code, duration_seconds, executed_at
|
||||
FROM update_logs
|
||||
WHERE %s
|
||||
ORDER BY executed_at DESC
|
||||
LIMIT $%d OFFSET $%d
|
||||
`, strings.Join(whereClause, " AND "), argIdx, argIdx+1)
|
||||
|
||||
limit := filters.PageSize
|
||||
if limit == 0 {
|
||||
limit = 100 // Default limit
|
||||
}
|
||||
offset := (filters.Page - 1) * limit
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
args = append(args, limit, offset)
|
||||
err = q.db.Select(&logs, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get all logs: %w", err)
|
||||
}
|
||||
|
||||
return logs, total, nil
|
||||
}
|
||||
|
||||
// GetActiveOperations returns currently running operations
|
||||
func (q *UpdateQueries) GetActiveOperations() ([]models.ActiveOperation, error) {
|
||||
var operations []models.ActiveOperation
|
||||
|
||||
query := `
|
||||
SELECT DISTINCT ON (agent_id, package_type, package_name)
|
||||
id,
|
||||
agent_id,
|
||||
package_type,
|
||||
package_name,
|
||||
current_version,
|
||||
available_version,
|
||||
severity,
|
||||
status,
|
||||
last_updated_at,
|
||||
metadata
|
||||
FROM current_package_state
|
||||
WHERE status IN ('checking_dependencies', 'installing', 'pending_dependencies')
|
||||
ORDER BY agent_id, package_type, package_name, last_updated_at DESC
|
||||
`
|
||||
|
||||
err := q.db.Select(&operations, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active operations: %w", err)
|
||||
}
|
||||
|
||||
return operations, nil
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ type Agent struct {
|
||||
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"`
|
||||
AgentVersion string `json:"agent_version" db:"agent_version"` // Version at registration
|
||||
CurrentVersion string `json:"current_version" db:"current_version"` // Current running version
|
||||
UpdateAvailable bool `json:"update_available" db:"update_available"` // Whether update is available
|
||||
LastVersionCheck time.Time `json:"last_version_check" db:"last_version_check"` // Last time version was checked
|
||||
LastSeen time.Time `json:"last_seen" db:"last_seen"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Metadata JSONB `json:"metadata" db:"metadata"`
|
||||
@@ -30,7 +33,10 @@ type AgentWithLastScan struct {
|
||||
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"`
|
||||
AgentVersion string `json:"agent_version" db:"agent_version"` // Version at registration
|
||||
CurrentVersion string `json:"current_version" db:"current_version"` // Current running version
|
||||
UpdateAvailable bool `json:"update_available" db:"update_available"` // Whether update is available
|
||||
LastVersionCheck time.Time `json:"last_version_check" db:"last_version_check"` // Last time version was checked
|
||||
LastSeen time.Time `json:"last_seen" db:"last_seen"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Metadata JSONB `json:"metadata" db:"metadata"`
|
||||
@@ -67,9 +73,21 @@ type AgentRegistrationRequest struct {
|
||||
|
||||
// AgentRegistrationResponse is returned after successful registration
|
||||
type AgentRegistrationResponse struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Token string `json:"token"` // Short-lived access token (24h)
|
||||
RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d)
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
// TokenRenewalRequest is the payload for token renewal using refresh token
|
||||
type TokenRenewalRequest struct {
|
||||
AgentID uuid.UUID `json:"agent_id" binding:"required"`
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
// TokenRenewalResponse is returned after successful token renewal
|
||||
type TokenRenewalResponse struct {
|
||||
Token string `json:"token"` // New short-lived access token (24h)
|
||||
}
|
||||
|
||||
// UTCTime is a time.Time that marshals to ISO format with UTC timezone
|
||||
|
||||
@@ -33,11 +33,13 @@ type CommandItem struct {
|
||||
|
||||
// Command types
|
||||
const (
|
||||
CommandTypeScanUpdates = "scan_updates"
|
||||
CommandTypeCollectSpecs = "collect_specs"
|
||||
CommandTypeInstallUpdate = "install_updates"
|
||||
CommandTypeRollback = "rollback_update"
|
||||
CommandTypeUpdateAgent = "update_agent"
|
||||
CommandTypeScanUpdates = "scan_updates"
|
||||
CommandTypeCollectSpecs = "collect_specs"
|
||||
CommandTypeInstallUpdate = "install_updates"
|
||||
CommandTypeDryRunUpdate = "dry_run_update"
|
||||
CommandTypeConfirmDependencies = "confirm_dependencies"
|
||||
CommandTypeRollback = "rollback_update"
|
||||
CommandTypeUpdateAgent = "update_agent"
|
||||
)
|
||||
|
||||
// Command statuses
|
||||
@@ -46,4 +48,22 @@ const (
|
||||
CommandStatusSent = "sent"
|
||||
CommandStatusCompleted = "completed"
|
||||
CommandStatusFailed = "failed"
|
||||
CommandStatusTimedOut = "timed_out"
|
||||
CommandStatusCancelled = "cancelled"
|
||||
CommandStatusRunning = "running"
|
||||
)
|
||||
|
||||
// ActiveCommandInfo represents information about an active command for UI display
|
||||
type ActiveCommandInfo struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
||||
CommandType string `json:"command_type" db:"command_type"`
|
||||
Status string `json:"status" db:"status"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
|
||||
Result JSONB `json:"result,omitempty" db:"result"`
|
||||
AgentHostname string `json:"agent_hostname" db:"agent_hostname"`
|
||||
PackageName string `json:"package_name" db:"package_name"`
|
||||
PackageType string `json:"package_type" db:"package_type"`
|
||||
}
|
||||
|
||||
@@ -77,6 +77,30 @@ type UpdateLogRequest struct {
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
}
|
||||
|
||||
// DependencyReportRequest is used by agents to report dependencies after dry run
|
||||
type DependencyReportRequest struct {
|
||||
PackageName string `json:"package_name" binding:"required"`
|
||||
PackageType string `json:"package_type" binding:"required"`
|
||||
Dependencies []string `json:"dependencies" binding:"required"`
|
||||
UpdateID string `json:"update_id" binding:"required"`
|
||||
DryRunResult *InstallResult `json:"dry_run_result,omitempty"`
|
||||
}
|
||||
|
||||
// InstallResult represents the result of a package installation attempt (from agent)
|
||||
type InstallResult struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
Action string `json:"action,omitempty"` // "install", "upgrade", "dry_run", etc.
|
||||
PackagesInstalled []string `json:"packages_installed,omitempty"`
|
||||
ContainersUpdated []string `json:"containers_updated,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"` // List of dependency packages found during dry run
|
||||
IsDryRun bool `json:"is_dry_run"` // Whether this is a dry run result
|
||||
}
|
||||
|
||||
// UpdateFilters for querying updates
|
||||
type UpdateFilters struct {
|
||||
AgentID uuid.UUID
|
||||
@@ -163,3 +187,27 @@ type UpdateStats struct {
|
||||
ModerateUpdates int `json:"moderate_updates" db:"moderate_updates"`
|
||||
LowUpdates int `json:"low_updates" db:"low_updates"`
|
||||
}
|
||||
|
||||
// LogFilters for querying logs across all agents
|
||||
type LogFilters struct {
|
||||
AgentID uuid.UUID
|
||||
Action string
|
||||
Result string
|
||||
Since *time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// ActiveOperation represents a currently running operation
|
||||
type ActiveOperation 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"`
|
||||
Status string `json:"status" db:"status"`
|
||||
LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"`
|
||||
Metadata JSONB `json:"metadata" db:"metadata"`
|
||||
}
|
||||
|
||||
219
aggregator-server/internal/services/timeout.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-server/internal/database/queries"
|
||||
"github.com/aggregator-project/aggregator-server/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TimeoutService handles timeout management for long-running operations
|
||||
type TimeoutService struct {
|
||||
commandQueries *queries.CommandQueries
|
||||
updateQueries *queries.UpdateQueries
|
||||
ticker *time.Ticker
|
||||
stopChan chan bool
|
||||
timeoutDuration time.Duration
|
||||
}
|
||||
|
||||
// NewTimeoutService creates a new timeout service
|
||||
func NewTimeoutService(cq *queries.CommandQueries, uq *queries.UpdateQueries) *TimeoutService {
|
||||
return &TimeoutService{
|
||||
commandQueries: cq,
|
||||
updateQueries: uq,
|
||||
timeoutDuration: 2 * time.Hour, // 2 hours timeout - allows for system upgrades and large operations
|
||||
stopChan: make(chan bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the timeout monitoring service
|
||||
func (ts *TimeoutService) Start() {
|
||||
log.Printf("Starting timeout service with %v timeout duration", ts.timeoutDuration)
|
||||
|
||||
// Create a ticker that runs every 5 minutes
|
||||
ts.ticker = time.NewTicker(5 * time.Minute)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ts.ticker.C:
|
||||
ts.checkForTimeouts()
|
||||
case <-ts.stopChan:
|
||||
ts.ticker.Stop()
|
||||
log.Println("Timeout service stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop stops the timeout monitoring service
|
||||
func (ts *TimeoutService) Stop() {
|
||||
close(ts.stopChan)
|
||||
}
|
||||
|
||||
// checkForTimeouts checks for commands that have been running too long
|
||||
func (ts *TimeoutService) checkForTimeouts() {
|
||||
log.Println("Checking for timed out operations...")
|
||||
|
||||
// Get all commands that are in 'sent' status
|
||||
commands, err := ts.commandQueries.GetCommandsByStatus(models.CommandStatusSent)
|
||||
if err != nil {
|
||||
log.Printf("Error getting sent commands: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
timeoutThreshold := time.Now().Add(-ts.timeoutDuration)
|
||||
timedOutCommands := make([]models.AgentCommand, 0)
|
||||
|
||||
for _, command := range commands {
|
||||
// Check if command has been sent and is older than timeout threshold
|
||||
if command.SentAt != nil && command.SentAt.Before(timeoutThreshold) {
|
||||
timedOutCommands = append(timedOutCommands, command)
|
||||
}
|
||||
}
|
||||
|
||||
if len(timedOutCommands) > 0 {
|
||||
log.Printf("Found %d timed out commands", len(timedOutCommands))
|
||||
|
||||
for _, command := range timedOutCommands {
|
||||
if err := ts.timeoutCommand(&command); err != nil {
|
||||
log.Printf("Error timing out command %s: %v", command.ID, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Println("No timed out operations found")
|
||||
}
|
||||
}
|
||||
|
||||
// timeoutCommand marks a specific command as timed out and updates related entities
|
||||
func (ts *TimeoutService) timeoutCommand(command *models.AgentCommand) error {
|
||||
log.Printf("Timing out command %s (type: %s, agent: %s)",
|
||||
command.ID, command.CommandType, command.AgentID)
|
||||
|
||||
// Update command status to timed_out
|
||||
if err := ts.commandQueries.UpdateCommandStatus(command.ID, models.CommandStatusTimedOut); err != nil {
|
||||
return fmt.Errorf("failed to update command status: %w", err)
|
||||
}
|
||||
|
||||
// Update result with timeout information
|
||||
result := models.JSONB{
|
||||
"error": "operation timed out",
|
||||
"timeout_at": time.Now(),
|
||||
"duration": ts.timeoutDuration.String(),
|
||||
"command_id": command.ID.String(),
|
||||
}
|
||||
|
||||
if err := ts.commandQueries.UpdateCommandResult(command.ID, result); err != nil {
|
||||
return fmt.Errorf("failed to update command result: %w", err)
|
||||
}
|
||||
|
||||
// Update related update package status if applicable
|
||||
if err := ts.updateRelatedPackageStatus(command); err != nil {
|
||||
log.Printf("Warning: failed to update related package status: %v", err)
|
||||
// Don't return error here as the main timeout operation succeeded
|
||||
}
|
||||
|
||||
// Create a log entry for the timeout
|
||||
logEntry := &models.UpdateLog{
|
||||
ID: uuid.New(),
|
||||
AgentID: command.AgentID,
|
||||
UpdatePackageID: ts.extractUpdatePackageID(command),
|
||||
Action: command.CommandType,
|
||||
Result: "timed_out",
|
||||
Stdout: "",
|
||||
Stderr: fmt.Sprintf("Command %s timed out after %v", command.CommandType, ts.timeoutDuration),
|
||||
ExitCode: 124, // Standard timeout exit code
|
||||
DurationSeconds: int(ts.timeoutDuration.Seconds()),
|
||||
ExecutedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := ts.updateQueries.CreateUpdateLog(logEntry); err != nil {
|
||||
log.Printf("Warning: failed to create timeout log entry: %v", err)
|
||||
// Don't return error here as the main timeout operation succeeded
|
||||
}
|
||||
|
||||
log.Printf("Successfully timed out command %s", command.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateRelatedPackageStatus updates the status of related update packages when a command times out
|
||||
func (ts *TimeoutService) updateRelatedPackageStatus(command *models.AgentCommand) error {
|
||||
// Extract update_id from command params if it exists
|
||||
_, ok := command.Params["update_id"].(string)
|
||||
if !ok {
|
||||
// This command doesn't have an associated update_id, so no package status to update
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update the package status to 'failed' with timeout reason
|
||||
metadata := models.JSONB{
|
||||
"timeout": true,
|
||||
"timeout_at": time.Now(),
|
||||
"timeout_duration": ts.timeoutDuration.String(),
|
||||
"command_id": command.ID.String(),
|
||||
"failure_reason": "operation timed out",
|
||||
}
|
||||
|
||||
return ts.updateQueries.UpdatePackageStatus(command.AgentID,
|
||||
command.Params["package_type"].(string),
|
||||
command.Params["package_name"].(string),
|
||||
"failed",
|
||||
metadata)
|
||||
}
|
||||
|
||||
// extractUpdatePackageID extracts the update package ID from command params
|
||||
func (ts *TimeoutService) extractUpdatePackageID(command *models.AgentCommand) *uuid.UUID {
|
||||
updateIDStr, ok := command.Params["update_id"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
updateID, err := uuid.Parse(updateIDStr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &updateID
|
||||
}
|
||||
|
||||
// GetTimeoutStatus returns statistics about timed out operations
|
||||
func (ts *TimeoutService) GetTimeoutStatus() (map[string]interface{}, error) {
|
||||
// Get all timed out commands
|
||||
timedOutCommands, err := ts.commandQueries.GetCommandsByStatus(models.CommandStatusTimedOut)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get timed out commands: %w", err)
|
||||
}
|
||||
|
||||
// Get all active commands
|
||||
activeCommands, err := ts.commandQueries.GetCommandsByStatus(models.CommandStatusSent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active commands: %w", err)
|
||||
}
|
||||
|
||||
// Count commands approaching timeout (within 5 minutes of timeout)
|
||||
timeoutThreshold := time.Now().Add(-ts.timeoutDuration + 5*time.Minute)
|
||||
approachingTimeout := 0
|
||||
for _, command := range activeCommands {
|
||||
if command.SentAt != nil && command.SentAt.Before(timeoutThreshold) {
|
||||
approachingTimeout++
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_timed_out": len(timedOutCommands),
|
||||
"total_active": len(activeCommands),
|
||||
"approaching_timeout": approachingTimeout,
|
||||
"timeout_duration": ts.timeoutDuration.String(),
|
||||
"last_check": time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetTimeoutDuration allows changing the timeout duration
|
||||
func (ts *TimeoutService) SetTimeoutDuration(duration time.Duration) {
|
||||
ts.timeoutDuration = duration
|
||||
log.Printf("Timeout duration updated to %v", duration)
|
||||
}
|
||||
13
aggregator-web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RedFlag Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,7 +7,8 @@ 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 LiveOperations from '@/pages/LiveOperations';
|
||||
import History from '@/pages/History';
|
||||
import Settings from '@/pages/Settings';
|
||||
import Login from '@/pages/Login';
|
||||
|
||||
@@ -94,7 +95,8 @@ const App: React.FC = () => {
|
||||
<Route path="/updates" element={<Updates />} />
|
||||
<Route path="/updates/:id" element={<Updates />} />
|
||||
<Route path="/docker" element={<Docker />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/live" element={<LiveOperations />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Search, Filter, Package, Clock, AlertTriangle } from 'lucide-react';
|
||||
import { Search, Package, Clock } from 'lucide-react';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import { updateApi } from '@/lib/api';
|
||||
import type { UpdatePackage } from '@/types';
|
||||
@@ -24,8 +24,7 @@ export function AgentSystemUpdates({ agentId }: AgentUpdatesProps) {
|
||||
const params = {
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
agent: agentId,
|
||||
type: 'system', // Only show system updates in AgentUpdates
|
||||
agent_id: agentId, // Fix: use correct parameter name expected by backend
|
||||
...(searchTerm && { search: searchTerm }),
|
||||
};
|
||||
|
||||
@@ -157,8 +156,8 @@ export function AgentSystemUpdates({ agentId }: AgentUpdatesProps) {
|
||||
|
||||
<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>
|
||||
{update.metadata?.repository_source && (
|
||||
<span>Source: {update.metadata.repository_source}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
|
||||
470
aggregator-web/src/components/HistoryTimeline.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Package,
|
||||
Computer,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Terminal,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { logApi } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface HistoryEntry {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
update_package_id?: string;
|
||||
action: string;
|
||||
result: string;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exit_code: number;
|
||||
duration_seconds: number;
|
||||
executed_at: string;
|
||||
}
|
||||
|
||||
interface HistoryTimelineProps {
|
||||
agentId?: string; // Optional - if provided, filter to specific agent
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TimelineGroup {
|
||||
date: string;
|
||||
entries: HistoryEntry[];
|
||||
}
|
||||
|
||||
const HistoryTimeline: React.FC<HistoryTimelineProps> = ({ agentId, className }) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [actionFilter, setActionFilter] = useState('all');
|
||||
const [resultFilter, setResultFilter] = useState('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [expandedEntries, setExpandedEntries] = useState<Set<string>>(new Set());
|
||||
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
|
||||
|
||||
// Query parameters for API
|
||||
const [queryParams, setQueryParams] = useState({
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
agent_id: agentId || '',
|
||||
action: actionFilter !== 'all' ? actionFilter : '',
|
||||
result: resultFilter !== 'all' ? resultFilter : '',
|
||||
search: searchQuery,
|
||||
});
|
||||
|
||||
// Fetch history data
|
||||
const { data: historyData, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['history', queryParams],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const params: any = {
|
||||
page: queryParams.page,
|
||||
page_size: queryParams.page_size,
|
||||
};
|
||||
|
||||
if (queryParams.agent_id) {
|
||||
params.agent_id = queryParams.agent_id;
|
||||
}
|
||||
|
||||
if (queryParams.action) {
|
||||
params.action = queryParams.action;
|
||||
}
|
||||
|
||||
if (queryParams.result) {
|
||||
params.result = queryParams.result;
|
||||
}
|
||||
|
||||
const response = await logApi.getAllLogs(params);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch history:', error);
|
||||
toast.error('Failed to fetch history');
|
||||
return { logs: [], total: 0, page: 1, page_size: 50 };
|
||||
}
|
||||
},
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
});
|
||||
|
||||
// Group entries by date
|
||||
const groupEntriesByDate = (entries: HistoryEntry[]): TimelineGroup[] => {
|
||||
const groups: { [key: string]: HistoryEntry[] } = {};
|
||||
|
||||
entries.forEach(entry => {
|
||||
const date = new Date(entry.executed_at);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
let dateKey: string;
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
dateKey = 'Today';
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
dateKey = 'Yesterday';
|
||||
} else {
|
||||
dateKey = date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = [];
|
||||
}
|
||||
groups[dateKey].push(entry);
|
||||
});
|
||||
|
||||
return Object.entries(groups).map(([date, entries]) => ({
|
||||
date,
|
||||
entries: entries.sort((a, b) =>
|
||||
new Date(b.executed_at).getTime() - new Date(a.executed_at).getTime()
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const timelineGroups = groupEntriesByDate(historyData?.logs || []);
|
||||
|
||||
// Toggle entry expansion
|
||||
const toggleEntry = (entryId: string) => {
|
||||
const newExpanded = new Set(expandedEntries);
|
||||
if (newExpanded.has(entryId)) {
|
||||
newExpanded.delete(entryId);
|
||||
} else {
|
||||
newExpanded.add(entryId);
|
||||
}
|
||||
setExpandedEntries(newExpanded);
|
||||
};
|
||||
|
||||
// Toggle date expansion
|
||||
const toggleDate = (date: string) => {
|
||||
const newExpanded = new Set(expandedDates);
|
||||
if (newExpanded.has(date)) {
|
||||
newExpanded.delete(date);
|
||||
} else {
|
||||
newExpanded.add(date);
|
||||
}
|
||||
setExpandedDates(newExpanded);
|
||||
};
|
||||
|
||||
// Get action icon
|
||||
const getActionIcon = (action: string) => {
|
||||
switch (action) {
|
||||
case 'install':
|
||||
case 'upgrade':
|
||||
return <Package className="h-4 w-4" />;
|
||||
case 'scan':
|
||||
return <Search className="h-4 w-4" />;
|
||||
case 'dry_run':
|
||||
return <Terminal className="h-4 w-4" />;
|
||||
default:
|
||||
return <Activity className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get result icon
|
||||
const getResultIcon = (result: string) => {
|
||||
switch (result) {
|
||||
case 'success':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
case 'running':
|
||||
return <RefreshCw className="h-4 w-4 text-blue-500 animate-spin" />;
|
||||
default:
|
||||
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get status color
|
||||
const getStatusColor = (result: string) => {
|
||||
switch (result) {
|
||||
case 'success':
|
||||
return 'text-green-700 bg-green-100 border-green-200';
|
||||
case 'failed':
|
||||
return 'text-red-700 bg-red-100 border-red-200';
|
||||
case 'running':
|
||||
return 'text-blue-700 bg-blue-100 border-blue-200';
|
||||
default:
|
||||
return 'text-gray-700 bg-gray-100 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
// Format duration
|
||||
const formatDuration = (seconds: number) => {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
} else if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
} else {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-6", className)}>
|
||||
{/* Header with search and filters */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="h-5 w-5 text-gray-600" />
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{agentId ? 'Agent History' : 'Universal Audit Log'}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div 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"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by action or result..."
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center space-x-2 px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>Filters</span>
|
||||
{(actionFilter !== 'all' || resultFilter !== 'all') && (
|
||||
<span className="bg-primary-100 text-primary-800 px-2 py-0.5 rounded-full text-xs">
|
||||
{[actionFilter, resultFilter].filter(f => f !== 'all').length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Action
|
||||
</label>
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
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">All Actions</option>
|
||||
<option value="install">Install</option>
|
||||
<option value="upgrade">Upgrade</option>
|
||||
<option value="scan">Scan</option>
|
||||
<option value="dry_run">Dry Run</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Result
|
||||
</label>
|
||||
<select
|
||||
value={resultFilter}
|
||||
onChange={(e) => setResultFilter(e.target.value)}
|
||||
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">All Results</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="running">Running</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-gray-600">Loading history...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{!isLoading && timelineGroups.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Calendar className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No history found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchQuery || actionFilter !== 'all' || resultFilter !== 'all'
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'No activities have been recorded yet.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{timelineGroups.map((group) => (
|
||||
<div key={group.date} className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* Date header */}
|
||||
<div
|
||||
className="px-4 py-3 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={() => toggleDate(group.date)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{expandedDates.has(group.date) ? (
|
||||
<ChevronDown className="h-4 w-4 text-gray-600" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-gray-600" />
|
||||
)}
|
||||
<h4 className="font-medium text-gray-900">{group.date}</h4>
|
||||
<span className="text-sm text-gray-500">
|
||||
({group.entries.length} events)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline entries */}
|
||||
{expandedDates.has(group.date) && (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{group.entries.map((entry) => (
|
||||
<div key={entry.id} className="p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
{/* Timeline icon */}
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{getResultIcon(entry.result)}
|
||||
</div>
|
||||
|
||||
{/* Entry content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={() => toggleEntry(entry.id)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getActionIcon(entry.action)}
|
||||
<span className="font-medium text-gray-900 capitalize">
|
||||
{entry.action}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border",
|
||||
getStatusColor(entry.result)
|
||||
)}>
|
||||
{entry.result}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>{formatRelativeTime(entry.executed_at)}</span>
|
||||
<span>{formatDuration(entry.duration_seconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent info */}
|
||||
<div className="mt-1 flex items-center space-x-2 text-sm text-gray-600">
|
||||
<Computer className="h-3 w-3" />
|
||||
<span>Agent: {entry.agent_id}</span>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expandedEntries.has(entry.id) && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Exit Code:</span>
|
||||
<span className="ml-2">{entry.exit_code}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Duration:</span>
|
||||
<span className="ml-2">{formatDuration(entry.duration_seconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
{(entry.stdout || entry.stderr) && (
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-gray-900 mb-2 flex items-center space-x-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span>Output</span>
|
||||
</h5>
|
||||
{entry.stdout && (
|
||||
<div className="bg-gray-900 text-green-400 p-3 rounded-md font-mono text-xs overflow-x-auto">
|
||||
<pre className="whitespace-pre-wrap">{entry.stdout}</pre>
|
||||
</div>
|
||||
)}
|
||||
{entry.stderr && (
|
||||
<div className="bg-gray-900 text-red-400 p-3 rounded-md font-mono text-xs overflow-x-auto mt-2">
|
||||
<pre className="whitespace-pre-wrap">{entry.stderr}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{historyData && historyData.total > historyData.page_size && (
|
||||
<div className="flex items-center justify-between bg-white px-4 py-3 border border-gray-200 rounded-lg shadow-sm">
|
||||
<div className="text-sm text-gray-700">
|
||||
Showing {((historyData.page - 1) * historyData.page_size) + 1} to{' '}
|
||||
{Math.min(historyData.page * historyData.page_size, historyData.total)} of{' '}
|
||||
{historyData.total} results
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setQueryParams(prev => ({ ...prev, page: Math.max(1, prev.page - 1) }))}
|
||||
disabled={historyData.page === 1}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-700">
|
||||
Page {historyData.page} of {Math.ceil(historyData.total / historyData.page_size)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setQueryParams(prev => ({ ...prev, page: prev.page + 1 }))}
|
||||
disabled={historyData.page >= Math.ceil(historyData.total / historyData.page_size)}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryTimeline;
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
LayoutDashboard,
|
||||
Computer,
|
||||
Package,
|
||||
FileText,
|
||||
Activity,
|
||||
History,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
@@ -58,10 +59,16 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
current: location.pathname.startsWith('/docker'),
|
||||
},
|
||||
{
|
||||
name: 'Logs',
|
||||
href: '/logs',
|
||||
icon: FileText,
|
||||
current: location.pathname === '/logs',
|
||||
name: 'Live Operations',
|
||||
href: '/live',
|
||||
icon: Activity,
|
||||
current: location.pathname === '/live',
|
||||
},
|
||||
{
|
||||
name: 'History',
|
||||
href: '/history',
|
||||
icon: History,
|
||||
current: location.pathname === '/history',
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
|
||||
57
aggregator-web/src/hooks/useCommands.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { updateApi, logApi } from '@/lib/api';
|
||||
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
interface ActiveCommand {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
agent_hostname: string;
|
||||
command_type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
sent_at?: string;
|
||||
completed_at?: string;
|
||||
package_name: string;
|
||||
package_type: string;
|
||||
}
|
||||
|
||||
export const useActiveCommands = (): UseQueryResult<{ commands: ActiveCommand[]; count: number }, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['activeCommands'],
|
||||
queryFn: () => updateApi.getActiveCommands(),
|
||||
refetchInterval: 5000, // Auto-refresh every 5 seconds
|
||||
});
|
||||
};
|
||||
|
||||
export const useRecentCommands = (limit?: number): UseQueryResult<{ commands: ActiveCommand[]; count: number; limit: number }, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['recentCommands', limit],
|
||||
queryFn: () => updateApi.getRecentCommands(limit),
|
||||
});
|
||||
};
|
||||
|
||||
export const useRetryCommand = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.retryCommand,
|
||||
onSuccess: () => {
|
||||
// Invalidate active and recent commands queries
|
||||
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCancelCommand = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.cancelCommand,
|
||||
onSuccess: () => {
|
||||
// Invalidate active and recent commands queries
|
||||
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { updateApi } from '@/lib/api';
|
||||
import type { UpdatePackage, ListQueryParams, UpdateApprovalRequest, UpdateListResponse } from '@/types';
|
||||
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
||||
@@ -19,26 +19,88 @@ export const useUpdate = (id: string, enabled: boolean = true): UseQueryResult<U
|
||||
};
|
||||
|
||||
export const useApproveUpdate = (): UseMutationResult<void, Error, { id: string; scheduledAt?: string; }, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, scheduledAt }: { id: string; scheduledAt?: string }) =>
|
||||
updateApi.approveUpdate(id, scheduledAt),
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate specific update queries
|
||||
queryClient.invalidateQueries({ queryKey: ['update'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useApproveMultipleUpdates = (): UseMutationResult<void, Error, UpdateApprovalRequest, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (request: UpdateApprovalRequest) => updateApi.approveUpdates(request),
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate specific update queries
|
||||
queryClient.invalidateQueries({ queryKey: ['update'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRejectUpdate = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.rejectUpdate,
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate specific update queries
|
||||
queryClient.invalidateQueries({ queryKey: ['update'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useInstallUpdate = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.installUpdate,
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate specific update queries
|
||||
queryClient.invalidateQueries({ queryKey: ['update'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRetryCommand = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.retryCommand,
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate logs and active operations queries
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['active'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCancelCommand = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.cancelCommand,
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate logs and active operations queries
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['active'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -113,6 +113,40 @@ export const updateApi = {
|
||||
installUpdate: async (id: string): Promise<void> => {
|
||||
await api.post(`/updates/${id}/install`);
|
||||
},
|
||||
|
||||
// Get update logs
|
||||
getUpdateLogs: async (id: string, limit?: number): Promise<{ logs: any[]; count: number }> => {
|
||||
const response = await api.get(`/updates/${id}/logs`, {
|
||||
params: limit ? { limit } : undefined
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Retry a failed, timed_out, or cancelled command
|
||||
retryCommand: async (commandId: string): Promise<{ message: string; command_id: string; new_id: string }> => {
|
||||
const response = await api.post(`/commands/${commandId}/retry`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Cancel a pending or sent command
|
||||
cancelCommand: async (commandId: string): Promise<{ message: string }> => {
|
||||
const response = await api.post(`/commands/${commandId}/cancel`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get active commands for live command control
|
||||
getActiveCommands: async (): Promise<{ commands: any[]; count: number }> => {
|
||||
const response = await api.get('/commands/active');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get recent commands for retry functionality
|
||||
getRecentCommands: async (limit?: number): Promise<{ commands: any[]; count: number; limit: number }> => {
|
||||
const response = await api.get('/commands/recent', {
|
||||
params: limit ? { limit } : undefined
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const statsApi = {
|
||||
@@ -123,6 +157,41 @@ export const statsApi = {
|
||||
},
|
||||
};
|
||||
|
||||
export const logApi = {
|
||||
// Get all logs with filtering for universal log view
|
||||
getAllLogs: async (params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
agent_id?: string;
|
||||
action?: string;
|
||||
result?: string;
|
||||
since?: string;
|
||||
}): Promise<{ logs: any[]; total: number; page: number; page_size: number }> => {
|
||||
const response = await api.get('/logs', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get active operations for live status view
|
||||
getActiveOperations: async (): Promise<{ operations: any[]; count: number }> => {
|
||||
const response = await api.get('/logs/active');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get active commands for live command control
|
||||
getActiveCommands: async (): Promise<{ commands: any[]; count: number }> => {
|
||||
const response = await api.get('/commands/active');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get recent commands for retry functionality
|
||||
getRecentCommands: async (limit?: number): Promise<{ commands: any[]; count: number; limit: number }> => {
|
||||
const response = await api.get('/commands/recent', {
|
||||
params: limit ? { limit } : undefined
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const authApi = {
|
||||
// Simple login (using API key or token)
|
||||
login: async (credentials: { token: string }): Promise<{ token: string }> => {
|
||||
|
||||
@@ -110,6 +110,10 @@ export const getStatusColor = (status: string): string => {
|
||||
return 'text-danger-600 bg-danger-100';
|
||||
case 'pending':
|
||||
return 'text-warning-600 bg-warning-100';
|
||||
case 'checking_dependencies':
|
||||
return 'text-blue-500 bg-blue-100';
|
||||
case 'pending_dependencies':
|
||||
return 'text-orange-600 bg-orange-100';
|
||||
case 'approved':
|
||||
case 'scheduled':
|
||||
return 'text-blue-600 bg-blue-100';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Computer,
|
||||
@@ -15,21 +15,41 @@ import {
|
||||
GitBranch,
|
||||
Clock,
|
||||
Trash2,
|
||||
History as HistoryIcon,
|
||||
Download,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
||||
import { useActiveCommands, useCancelCommand } from '@/hooks/useCommands';
|
||||
import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import { AgentSystemUpdates } from '@/components/AgentUpdates';
|
||||
import HistoryTimeline from '@/components/HistoryTimeline';
|
||||
|
||||
const Agents: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [osFilter, setOsFilter] = useState<string>('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'history'>('overview');
|
||||
|
||||
// Debounce search query to avoid API calls on every keystroke
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
}, 300); // 300ms delay
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
// Helper function to get system metadata from agent
|
||||
const getSystemMetadata = (agent: any) => {
|
||||
@@ -106,7 +126,7 @@ const Agents: React.FC = () => {
|
||||
|
||||
// Fetch agents list
|
||||
const { data: agentsData, isPending, error } = useAgents({
|
||||
search: searchQuery || undefined,
|
||||
search: debouncedSearchQuery || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
});
|
||||
|
||||
@@ -117,6 +137,10 @@ const Agents: React.FC = () => {
|
||||
const scanMultipleMutation = useScanMultipleAgents();
|
||||
const unregisterAgentMutation = useUnregisterAgent();
|
||||
|
||||
// Active commands for live status
|
||||
const { data: activeCommandsData, refetch: refetchActiveCommands } = useActiveCommands();
|
||||
const cancelCommandMutation = useCancelCommand();
|
||||
|
||||
const agents = agentsData?.agents || [];
|
||||
const selectedAgent = selectedAgentData || agents.find(a => a.id === id);
|
||||
|
||||
@@ -189,6 +213,58 @@ const Agents: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle command cancellation
|
||||
const handleCancelCommand = async (commandId: string) => {
|
||||
try {
|
||||
await cancelCommandMutation.mutateAsync(commandId);
|
||||
toast.success('Command cancelled successfully');
|
||||
refetchActiveCommands();
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to cancel command: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Get agent-specific active commands
|
||||
const getAgentActiveCommands = () => {
|
||||
if (!selectedAgent || !activeCommandsData?.commands) return [];
|
||||
return activeCommandsData.commands.filter(cmd => cmd.agent_id === selectedAgent.id);
|
||||
};
|
||||
|
||||
// Helper function to get command display info
|
||||
const getCommandDisplayInfo = (command: any) => {
|
||||
const actionMap: { [key: string]: { icon: React.ReactNode; label: string } } = {
|
||||
'scan': { icon: <RefreshCw className="h-4 w-4" />, label: 'System scan' },
|
||||
'install_updates': { icon: <Package className="h-4 w-4" />, label: `Installing ${command.package_name || 'packages'}` },
|
||||
'dry_run_update': { icon: <Search className="h-4 w-4" />, label: `Checking dependencies for ${command.package_name || 'packages'}` },
|
||||
'confirm_dependencies': { icon: <CheckCircle className="h-4 w-4" />, label: `Installing confirmed dependencies` },
|
||||
};
|
||||
|
||||
return actionMap[command.command_type] || {
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
label: command.command_type.replace('_', ' ')
|
||||
};
|
||||
};
|
||||
|
||||
// Get command status
|
||||
const getCommandStatus = (command: any) => {
|
||||
switch (command.status) {
|
||||
case 'pending':
|
||||
return { text: 'Pending', color: 'text-amber-600 bg-amber-50 border-amber-200' };
|
||||
case 'sent':
|
||||
return { text: 'Sent to agent', color: 'text-blue-600 bg-blue-50 border-blue-200' };
|
||||
case 'running':
|
||||
return { text: 'Running', color: 'text-green-600 bg-green-50 border-green-200' };
|
||||
case 'completed':
|
||||
return { text: 'Completed', color: 'text-gray-600 bg-gray-50 border-gray-200' };
|
||||
case 'failed':
|
||||
return { text: 'Failed', color: 'text-red-600 bg-red-50 border-red-200' };
|
||||
case 'timed_out':
|
||||
return { text: 'Timed out', color: 'text-red-600 bg-red-50 border-red-200' };
|
||||
default:
|
||||
return { text: command.status, color: 'text-gray-600 bg-gray-50 border-gray-200' };
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique OS types for filter
|
||||
const osTypes = [...new Set(agents.map(agent => agent.os_type))];
|
||||
|
||||
@@ -203,19 +279,53 @@ const Agents: React.FC = () => {
|
||||
>
|
||||
← Back to Agents
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{selectedAgent.hostname}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
System details and update management for this agent
|
||||
</p>
|
||||
|
||||
{/* New Compact Header Design */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between mb-4">
|
||||
<div className="flex-1 mb-4 sm:mb-0">
|
||||
{/* Main hostname with integrated agent info */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center space-y-3 sm:space-y-0 sm:space-x-3 mb-2">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">
|
||||
{selectedAgent.hostname}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-gray-500">[Agent ID:</span>
|
||||
<span className="font-mono text-xs text-gray-700 bg-gray-100 px-2 py-1 rounded break-all">
|
||||
{selectedAgent.id}
|
||||
</span>
|
||||
<span className="text-gray-500">|</span>
|
||||
<span className="text-gray-500">Version:</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="font-medium text-gray-900">
|
||||
{selectedAgent.current_version || 'Unknown'}
|
||||
</span>
|
||||
{selectedAgent.update_available === true && (
|
||||
<span className="flex items-center text-xs text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
Update Available
|
||||
</span>
|
||||
)}
|
||||
{selectedAgent.update_available === false && selectedAgent.current_version && (
|
||||
<span className="flex items-center text-xs text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Up to Date
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-500">]</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-line with registration info only */}
|
||||
<div className="text-sm text-gray-600">
|
||||
<span>Registered {formatRelativeTime(selectedAgent.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleScanAgent(selectedAgent.id)}
|
||||
disabled={scanAgentMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
className="btn btn-primary sm:ml-4 w-full sm:w-auto"
|
||||
>
|
||||
{scanAgentMutation.isPending ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
@@ -227,88 +337,162 @@ const Agents: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={cn(
|
||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
activeTab === 'overview'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
)}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={cn(
|
||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center space-x-2',
|
||||
activeTab === 'history'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
)}
|
||||
>
|
||||
<HistoryIcon className="h-4 w-4" />
|
||||
<span>History</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Agent info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Agent Status Card */}
|
||||
{/* Main content area */}
|
||||
<div className="lg:col-span-2">
|
||||
{activeTab === 'overview' ? (
|
||||
<div className="space-y-6">
|
||||
{/* Agent Status Card - Compact Timeline Style */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<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 className="flex items-center space-x-2">
|
||||
<div className={cn(
|
||||
'w-3 h-3 rounded-full',
|
||||
isOnline(selectedAgent.last_seen) ? 'bg-green-500' : 'bg-gray-400'
|
||||
)}></div>
|
||||
<span className={cn('badge', getStatusColor(isOnline(selectedAgent.last_seen) ? 'online' : 'offline'))}>
|
||||
{isOnline(selectedAgent.last_seen) ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{/* Compact Timeline Display */}
|
||||
<div className="space-y-2 mb-3">
|
||||
{(() => {
|
||||
const agentCommands = getAgentActiveCommands();
|
||||
const activeCommands = agentCommands.filter(cmd =>
|
||||
cmd.status === 'running' || cmd.status === 'sent' || cmd.status === 'pending'
|
||||
);
|
||||
const completedCommands = agentCommands.filter(cmd =>
|
||||
cmd.status === 'completed' || cmd.status === 'failed' || cmd.status === 'timed_out'
|
||||
).slice(0, 1); // Only show last completed
|
||||
|
||||
const displayCommands = [
|
||||
...activeCommands.slice(0, 2), // Max 2 active
|
||||
...completedCommands.slice(0, 1) // Max 1 completed
|
||||
].slice(0, 3); // Total max 3 entries
|
||||
|
||||
if (displayCommands.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-3 text-sm text-gray-500">
|
||||
No active operations
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
return displayCommands.map((command, index) => {
|
||||
const displayInfo = getCommandDisplayInfo(command);
|
||||
const statusInfo = getCommandStatus(command);
|
||||
const isActive = command.status === 'running' || command.status === 'sent' || command.status === 'pending';
|
||||
|
||||
return (
|
||||
<div key={command.id} className="flex items-start space-x-2 p-2 bg-gray-50 rounded border border-gray-200">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{displayInfo.icon}
|
||||
</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 className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900 truncate">
|
||||
{isActive ? (
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border',
|
||||
statusInfo.color
|
||||
)}>
|
||||
{command.status === 'running' && <RefreshCw className="h-3 w-3 animate-spin mr-1" />}
|
||||
{command.status === 'pending' && <Clock className="h-3 w-3 mr-1" />}
|
||||
{isActive ? command.status.replace('_', ' ') : statusInfo.text}
|
||||
</span>
|
||||
<span className="ml-1">{displayInfo.label}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border',
|
||||
statusInfo.color
|
||||
)}>
|
||||
{command.status === 'completed' && <CheckCircle className="h-3 w-3 mr-1" />}
|
||||
{command.status === 'failed' && <XCircle className="h-3 w-3 mr-1" />}
|
||||
{statusInfo.text}
|
||||
</span>
|
||||
<span className="ml-1">{displayInfo.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatRelativeTime(command.created_at)}
|
||||
</span>
|
||||
{isActive && (command.status === 'pending' || command.status === 'sent') && (
|
||||
<button
|
||||
onClick={() => handleCancelCommand(command.id)}
|
||||
disabled={cancelCommandMutation.isPending}
|
||||
className="text-xs text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* Basic Status Info */}
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 pt-2 border-t border-gray-200">
|
||||
<span>Last seen: {formatRelativeTime(selectedAgent.last_seen)}</span>
|
||||
<span>Last scan: {selectedAgent.last_scan ? formatRelativeTime(selectedAgent.last_scan) : 'Never'}</span>
|
||||
</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>
|
||||
</div>
|
||||
{/* Action Button */}
|
||||
<div className="flex justify-center mt-3 pt-3 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => handleScanAgent(selectedAgent.id)}
|
||||
disabled={scanAgentMutation.isPending}
|
||||
className="btn btn-primary w-full sm:w-auto text-sm"
|
||||
>
|
||||
{scanAgentMutation.isPending ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Scan Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -444,6 +628,12 @@ const Agents: React.FC = () => {
|
||||
{/* System Updates */}
|
||||
<AgentSystemUpdates agentId={selectedAgent.id} />
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<HistoryTimeline agentId={selectedAgent.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
@@ -603,7 +793,7 @@ const Agents: React.FC = () => {
|
||||
<Computer className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No agents found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchQuery || statusFilter !== 'all' || osFilter !== 'all'
|
||||
{debouncedSearchQuery || statusFilter !== 'all' || osFilter !== 'all'
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'No agents have registered with the server yet.'}
|
||||
</p>
|
||||
@@ -624,6 +814,7 @@ const Agents: React.FC = () => {
|
||||
</th>
|
||||
<th className="table-header">Agent</th>
|
||||
<th className="table-header">Status</th>
|
||||
<th className="table-header">Version</th>
|
||||
<th className="table-header">OS</th>
|
||||
<th className="table-header">Last Check-in</th>
|
||||
<th className="table-header">Last Scan</th>
|
||||
@@ -673,6 +864,25 @@ const Agents: React.FC = () => {
|
||||
{isOnline(agent.last_seen) ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-900">
|
||||
{agent.current_version || 'Unknown'}
|
||||
</span>
|
||||
{agent.update_available === true && (
|
||||
<span className="flex items-center text-xs text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded-full">
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Update
|
||||
</span>
|
||||
)}
|
||||
{agent.update_available === false && agent.current_version && (
|
||||
<span className="flex items-center text-xs text-green-600 bg-green-50 px-1.5 py-0.5 rounded-full">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{(() => {
|
||||
|
||||
74
aggregator-web/src/pages/History.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
History,
|
||||
Calendar,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import HistoryTimeline from '@/components/HistoryTimeline';
|
||||
|
||||
const HistoryPage: React.FC = () => {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<History className="h-8 w-8 text-indigo-600" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">History & Audit Log</h1>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Complete chronological timeline of all system activities across all agents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Activities</p>
|
||||
<p className="text-2xl font-bold text-gray-900">--</p>
|
||||
</div>
|
||||
<History className="h-8 w-8 text-indigo-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-green-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Successful</p>
|
||||
<p className="text-2xl font-bold text-green-600">--</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-red-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Failed</p>
|
||||
<p className="text-2xl font-bold text-red-600">--</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-blue-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Today</p>
|
||||
<p className="text-2xl font-bold text-blue-600">--</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<HistoryTimeline />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryPage;
|
||||
508
aggregator-web/src/pages/LiveOperations.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Activity,
|
||||
Clock,
|
||||
Package,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
Terminal,
|
||||
User,
|
||||
Calendar,
|
||||
Search,
|
||||
Computer,
|
||||
Eye,
|
||||
RotateCcw,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useAgents, useUpdates } from '@/hooks/useAgents';
|
||||
import { useActiveCommands, useRetryCommand, useCancelCommand } from '@/hooks/useCommands';
|
||||
import { getStatusColor, formatRelativeTime, isOnline } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import { logApi } from '@/lib/api';
|
||||
|
||||
interface LiveOperation {
|
||||
id: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
updateId: string;
|
||||
packageName: string;
|
||||
action: 'checking_dependencies' | 'installing' | 'pending_dependencies';
|
||||
status: 'running' | 'completed' | 'failed' | 'waiting';
|
||||
startTime: Date;
|
||||
duration?: number;
|
||||
progress?: string;
|
||||
logOutput?: string;
|
||||
error?: string;
|
||||
commandId: string;
|
||||
commandStatus: string;
|
||||
}
|
||||
|
||||
const LiveOperations: React.FC = () => {
|
||||
const [expandedOperation, setExpandedOperation] = useState<string | null>(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Fetch active commands from API
|
||||
const { data: activeCommandsData, refetch: refetchCommands } = useActiveCommands();
|
||||
|
||||
// Retry and cancel mutations
|
||||
const retryMutation = useRetryCommand();
|
||||
const cancelMutation = useCancelCommand();
|
||||
|
||||
// Fetch agents for mapping
|
||||
const { data: agentsData } = useAgents();
|
||||
const agents = agentsData?.agents || [];
|
||||
|
||||
// Transform API data to LiveOperation format
|
||||
const activeOperations: LiveOperation[] = React.useMemo(() => {
|
||||
if (!activeCommandsData?.commands) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return activeCommandsData.commands.map((cmd: any) => {
|
||||
const agent = agents.find(a => a.id === cmd.agent_id);
|
||||
let action: LiveOperation['action'];
|
||||
let status: LiveOperation['status'];
|
||||
|
||||
// Map command status to operation status
|
||||
if (cmd.status === 'failed' || cmd.status === 'timed_out') {
|
||||
status = 'failed';
|
||||
} else if (cmd.status === 'pending') {
|
||||
status = 'waiting';
|
||||
} else if (cmd.status === 'completed') {
|
||||
status = 'completed';
|
||||
} else {
|
||||
status = 'running';
|
||||
}
|
||||
|
||||
// Map command type to action
|
||||
switch (cmd.command_type) {
|
||||
case 'dry_run_update':
|
||||
action = 'checking_dependencies';
|
||||
break;
|
||||
case 'install_updates':
|
||||
case 'confirm_dependencies':
|
||||
action = 'installing';
|
||||
break;
|
||||
default:
|
||||
action = 'checking_dependencies';
|
||||
}
|
||||
|
||||
return {
|
||||
id: cmd.id,
|
||||
agentId: cmd.agent_id,
|
||||
agentName: cmd.agent_hostname || 'Unknown Agent',
|
||||
updateId: cmd.id,
|
||||
packageName: cmd.package_name !== 'N/A' ? cmd.package_name : cmd.command_type,
|
||||
action,
|
||||
status,
|
||||
startTime: new Date(cmd.created_at),
|
||||
progress: getStatusText(cmd.command_type, cmd.status),
|
||||
commandId: cmd.id,
|
||||
commandStatus: cmd.status,
|
||||
};
|
||||
});
|
||||
}, [activeCommandsData, agents]);
|
||||
|
||||
// Manual refresh function
|
||||
const handleManualRefresh = () => {
|
||||
refetchCommands();
|
||||
};
|
||||
|
||||
// Handle retry command
|
||||
const handleRetryCommand = async (commandId: string) => {
|
||||
try {
|
||||
await retryMutation.mutateAsync(commandId);
|
||||
toast.success('Command retry initiated successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to retry command: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle cancel command
|
||||
const handleCancelCommand = async (commandId: string) => {
|
||||
try {
|
||||
await cancelMutation.mutateAsync(commandId);
|
||||
toast.success('Command cancelled successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to cancel command: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
function getStatusText(commandType: string, status: string): string {
|
||||
if (commandType === 'dry_run_update') {
|
||||
return status === 'pending' ? 'Pending dependency check...' : 'Checking for required dependencies...';
|
||||
}
|
||||
if (commandType === 'install_updates') {
|
||||
return status === 'pending' ? 'Pending installation...' : 'Installing package and dependencies...';
|
||||
}
|
||||
if (commandType === 'confirm_dependencies') {
|
||||
return status === 'pending' ? 'Pending dependency confirmation...' : 'Installing confirmed dependencies...';
|
||||
}
|
||||
return status === 'pending' ? 'Pending operation...' : 'Processing command...';
|
||||
}
|
||||
|
||||
function getActionIcon(action: LiveOperation['action']) {
|
||||
switch (action) {
|
||||
case 'checking_dependencies':
|
||||
return <Search className="h-4 w-4" />;
|
||||
case 'installing':
|
||||
return <Package className="h-4 w-4" />;
|
||||
case 'pending_dependencies':
|
||||
return <AlertTriangle className="h-4 w-4" />;
|
||||
default:
|
||||
return <Activity className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: LiveOperation['status']) {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <Loader2 className="h-4 w-4 animate-spin" />;
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-4 w-4" />;
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4" />;
|
||||
case 'waiting':
|
||||
return <Clock className="h-4 w-4" />;
|
||||
default:
|
||||
return <Activity className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getDuration(startTime: Date): string {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - startTime.getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
const filteredOperations = activeOperations.filter(op => {
|
||||
const matchesSearch = !searchQuery ||
|
||||
op.packageName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
op.agentName.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || op.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center space-x-2">
|
||||
<Activity className="h-6 w-6" />
|
||||
<span>Live Operations</span>
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Real-time monitoring of ongoing update operations
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className={cn(
|
||||
"flex items-center space-x-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
autoRefresh
|
||||
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", autoRefresh && "animate-spin")} />
|
||||
<span>Auto Refresh</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleManualRefresh}
|
||||
className="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>Refresh Now</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Active</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{activeOperations.length}</p>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-blue-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Running</p>
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
{activeOperations.filter(op => op.status === 'running').length}
|
||||
</p>
|
||||
</div>
|
||||
<Loader2 className="h-8 w-8 text-blue-400 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-amber-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Waiting</p>
|
||||
<p className="text-2xl font-bold text-amber-600">
|
||||
{activeOperations.filter(op => op.status === 'waiting').length}
|
||||
</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-red-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Failed</p>
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
{activeOperations.filter(op => op.status === 'failed').length}
|
||||
</p>
|
||||
</div>
|
||||
<XCircle className="h-8 w-8 text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<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"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by package name or agent..."
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center space-x-2 px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>Filters</span>
|
||||
{statusFilter !== 'all' && (
|
||||
<span className="bg-primary-100 text-primary-800 px-2 py-0.5 rounded-full text-xs">1</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
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">All Status</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="waiting">Waiting</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Operations list */}
|
||||
{filteredOperations.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Activity className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No active operations</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchQuery || statusFilter !== 'all'
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'All operations are completed. Check the Updates page to start new operations.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredOperations.map((operation) => (
|
||||
<div
|
||||
key={operation.id}
|
||||
className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Operation header */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getActionIcon(operation.action)}
|
||||
<span className="text-lg font-medium text-gray-900">
|
||||
{operation.packageName}
|
||||
</span>
|
||||
<span className={cn('badge', getStatusColor(operation.status))}>
|
||||
{getStatusIcon(operation.status)}
|
||||
<span className="ml-1">{operation.status}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 flex items-center space-x-1">
|
||||
<Computer className="h-4 w-4" />
|
||||
<span>{operation.agentName}</span>
|
||||
<span>•</span>
|
||||
<span>{getDuration(operation.startTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setExpandedOperation(expandedOperation === operation.id ? null : operation.id)}
|
||||
className="flex items-center space-x-1 px-3 py-1 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span>Details</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
expandedOperation === operation.id && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
{operation.progress}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expandedOperation === operation.id && (
|
||||
<div className="p-4 bg-gray-50 border-t border-gray-200">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Operation Details</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Action:</span>
|
||||
<span className="font-medium capitalize">{operation.action.replace('_', ' ')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Started:</span>
|
||||
<span className="font-medium">{formatRelativeTime(operation.startTime)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Duration:</span>
|
||||
<span className="font-medium">{getDuration(operation.startTime)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Agent:</span>
|
||||
<span className="font-medium">{operation.agentName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Quick Actions</h4>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => window.open(`/updates/${operation.updateId}`, '_blank')}
|
||||
className="w-full flex items-center justify-center space-x-2 px-3 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span>View Update Details</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(`/agents/${operation.agentId}`, '_blank')}
|
||||
className="w-full flex items-center justify-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
<Computer className="h-4 w-4" />
|
||||
<span>View Agent</span>
|
||||
</button>
|
||||
|
||||
{/* Command control buttons */}
|
||||
{operation.commandStatus === 'pending' || operation.commandStatus === 'sent' ? (
|
||||
<button
|
||||
onClick={() => handleCancelCommand(operation.commandId)}
|
||||
disabled={cancelMutation.isPending}
|
||||
className="w-full flex items-center justify-center space-x-2 px-3 py-2 bg-red-100 text-red-700 rounded-md hover:bg-red-200 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span>{cancelMutation.isPending ? 'Cancelling...' : 'Cancel Command'}</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Retry button for failed/timed_out commands */}
|
||||
{operation.commandStatus === 'failed' || operation.commandStatus === 'timed_out' ? (
|
||||
<button
|
||||
onClick={() => handleRetryCommand(operation.commandId)}
|
||||
disabled={retryMutation.isPending}
|
||||
className="w-full flex items-center justify-center space-x-2 px-3 py-2 bg-green-100 text-green-700 rounded-md hover:bg-green-200 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<span>{retryMutation.isPending ? 'Retrying...' : 'Retry Command'}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log output placeholder */}
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2 flex items-center space-x-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span>Live Output</span>
|
||||
</h4>
|
||||
<div className="bg-gray-900 text-green-400 p-3 rounded-md font-mono text-xs min-h-32 max-h-48 overflow-y-auto">
|
||||
{operation.status === 'running' ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Waiting for log stream...</span>
|
||||
</div>
|
||||
) : operation.logOutput ? (
|
||||
<pre>{operation.logOutput}</pre>
|
||||
) : operation.error ? (
|
||||
<div className="text-red-400">Error: {operation.error}</div>
|
||||
) : (
|
||||
<div className="text-gray-500">No log output available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveOperations;
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const Logs: React.FC = () => {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Logs</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
View system logs and update history
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center">
|
||||
<div className="text-gray-400 mb-2">📋</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Coming Soon</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Logs and history tracking will be available in a future update.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logs;
|
||||
@@ -13,12 +13,17 @@ import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Calendar,
|
||||
X,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates } from '@/hooks/useUpdates';
|
||||
import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates, useRetryCommand, useCancelCommand } from '@/hooks/useUpdates';
|
||||
import { useRecentCommands } from '@/hooks/useCommands';
|
||||
import type { UpdatePackage } from '@/types';
|
||||
import { getSeverityColor, getStatusColor, getPackageTypeIcon, formatBytes, formatRelativeTime } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import { updateApi } from '@/lib/api';
|
||||
|
||||
|
||||
const Updates: React.FC = () => {
|
||||
@@ -28,19 +33,39 @@ const Updates: React.FC = () => {
|
||||
|
||||
// Get filters from URL params
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchParams.get('search') || '');
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||
const [severityFilter, setSeverityFilter] = useState(searchParams.get('severity') || '');
|
||||
const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || '');
|
||||
const [agentFilter, setAgentFilter] = useState(searchParams.get('agent') || '');
|
||||
|
||||
// Debounce search query to avoid API calls on every keystroke
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
}, 300); // 300ms delay
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedUpdates, setSelectedUpdates] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(parseInt(searchParams.get('page') || '1'));
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
const [showLogModal, setShowLogModal] = useState(false);
|
||||
const [logs, setLogs] = useState<any[]>([]);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [showDependencyModal, setShowDependencyModal] = useState(false);
|
||||
const [pendingDependencies, setPendingDependencies] = useState<string[]>([]);
|
||||
const [dependencyUpdateId, setDependencyUpdateId] = useState<string | null>(null);
|
||||
const [dependencyLoading, setDependencyLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'updates' | 'commands'>('updates');
|
||||
|
||||
// Store filters in URL
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
if (debouncedSearchQuery) params.set('search', debouncedSearchQuery);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
if (severityFilter) params.set('severity', severityFilter);
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
@@ -52,11 +77,11 @@ const Updates: React.FC = () => {
|
||||
if (newUrl !== window.location.href) {
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
}, [searchQuery, statusFilter, severityFilter, typeFilter, agentFilter, currentPage, pageSize]);
|
||||
}, [debouncedSearchQuery, statusFilter, severityFilter, typeFilter, agentFilter, currentPage, pageSize]);
|
||||
|
||||
// Fetch updates list
|
||||
const { data: updatesData, isPending, error } = useUpdates({
|
||||
search: searchQuery || undefined,
|
||||
search: debouncedSearchQuery || undefined,
|
||||
status: statusFilter || undefined,
|
||||
severity: severityFilter || undefined,
|
||||
type: typeFilter || undefined,
|
||||
@@ -68,10 +93,15 @@ const Updates: React.FC = () => {
|
||||
// Fetch single update if ID is provided
|
||||
const { data: selectedUpdateData } = useUpdate(id || '', !!id);
|
||||
|
||||
// Fetch recent commands for retry functionality
|
||||
const { data: recentCommandsData } = useRecentCommands(50);
|
||||
|
||||
const approveMutation = useApproveUpdate();
|
||||
const rejectMutation = useRejectUpdate();
|
||||
const installMutation = useInstallUpdate();
|
||||
const bulkApproveMutation = useApproveMultipleUpdates();
|
||||
const retryMutation = useRetryCommand();
|
||||
const cancelMutation = useCancelCommand();
|
||||
|
||||
const updates = updatesData?.updates || [];
|
||||
const totalCount = updatesData?.total || 0;
|
||||
@@ -138,6 +168,79 @@ const Updates: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle retry command
|
||||
const handleRetryCommand = async (commandId: string) => {
|
||||
try {
|
||||
await retryMutation.mutateAsync(commandId);
|
||||
toast.success('Command retry initiated successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to retry command: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle cancel command
|
||||
const handleCancelCommand = async (commandId: string) => {
|
||||
try {
|
||||
await cancelMutation.mutateAsync(commandId);
|
||||
toast.success('Command cancelled successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to cancel command: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle dependency confirmation
|
||||
const handleConfirmDependencies = async (updateId: string) => {
|
||||
setDependencyLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/updates/${updateId}/confirm-dependencies`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to confirm dependencies');
|
||||
}
|
||||
|
||||
toast.success('Dependency installation confirmed');
|
||||
setShowDependencyModal(false);
|
||||
setPendingDependencies([]);
|
||||
setDependencyUpdateId(null);
|
||||
|
||||
// Refresh the update data
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
toast.error('Failed to confirm dependencies');
|
||||
console.error('Failed to confirm dependencies:', error);
|
||||
} finally {
|
||||
setDependencyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle dependency cancellation
|
||||
const handleCancelDependencies = async () => {
|
||||
setShowDependencyModal(false);
|
||||
setPendingDependencies([]);
|
||||
setDependencyUpdateId(null);
|
||||
toast('Dependency installation cancelled');
|
||||
};
|
||||
|
||||
// Handle viewing logs
|
||||
const handleViewLogs = async (updateId: string) => {
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
const result = await updateApi.getUpdateLogs(updateId, 50);
|
||||
setLogs(result.logs || []);
|
||||
setShowLogModal(true);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load installation logs');
|
||||
console.error('Failed to load logs:', error);
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique values for filters
|
||||
const statuses = [...new Set(updates.map((u: UpdatePackage) => u.status))];
|
||||
const severities = [...new Set(updates.map((u: UpdatePackage) => u.severity))];
|
||||
@@ -230,7 +333,14 @@ const Updates: React.FC = () => {
|
||||
{selectedUpdate.severity}
|
||||
</span>
|
||||
<span className={cn('badge', getStatusColor(selectedUpdate.status))}>
|
||||
{selectedUpdate.status}
|
||||
{selectedUpdate.status === 'checking_dependencies' ? (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Checking dependencies...</span>
|
||||
</div>
|
||||
) : (
|
||||
selectedUpdate.status
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
@@ -351,6 +461,54 @@ const Updates: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedUpdate.status === 'checking_dependencies' && (
|
||||
<div className="w-full btn btn-secondary opacity-75 cursor-not-allowed">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Checking Dependencies...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedUpdate.status === 'pending_dependencies' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// Extract dependencies from metadata
|
||||
const deps = selectedUpdate.metadata?.dependencies || [];
|
||||
setPendingDependencies(Array.isArray(deps) ? deps : []);
|
||||
setDependencyUpdateId(selectedUpdate.id);
|
||||
setShowDependencyModal(true);
|
||||
}}
|
||||
className="w-full btn btn-warning"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
||||
Review Dependencies
|
||||
</button>
|
||||
)}
|
||||
|
||||
{['installing', 'completed', 'failed'].includes(selectedUpdate.status) && (
|
||||
<button
|
||||
onClick={() => handleViewLogs(selectedUpdate.id)}
|
||||
disabled={logsLoading}
|
||||
className="w-full btn btn-ghost"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
{logsLoading ? 'Loading...' : 'View Log'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedUpdate.status === 'failed' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// This would need a way to find the associated command ID
|
||||
// For now, we'll show a message indicating this needs to be implemented
|
||||
toast.info('Retry functionality will be available in the command history view');
|
||||
}}
|
||||
className="w-full btn btn-warning"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Retry Update
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${selectedUpdate.agent_id}`)}
|
||||
className="w-full btn btn-ghost"
|
||||
@@ -362,6 +520,213 @@ const Updates: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependency Confirmation Modal */}
|
||||
{showDependencyModal && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:p-0">
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl border border-gray-200">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-lg">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Dependencies Required
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-md p-1"
|
||||
onClick={handleCancelDependencies}
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="h-6 w-6 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-base font-medium text-gray-900">
|
||||
Additional packages are required
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
To install <span className="font-medium text-gray-900">{selectedUpdate?.package_name}</span>, the following additional packages will also be installed:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies List */}
|
||||
{pendingDependencies.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">Required Dependencies:</h5>
|
||||
<ul className="space-y-2">
|
||||
{pendingDependencies.map((dep, index) => (
|
||||
<li key={index} className="flex items-center space-x-2 text-sm">
|
||||
<Package className="h-4 w-4 text-gray-400" />
|
||||
<span className="font-medium text-gray-700">{dep}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning Message */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500 mr-2 flex-shrink-0" />
|
||||
<div className="text-sm text-amber-800">
|
||||
<p className="font-medium">Please review the dependencies before proceeding.</p>
|
||||
<p className="mt-1">These additional packages will be installed alongside your requested package.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse rounded-b-lg border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={() => handleConfirmDependencies(dependencyUpdateId!)}
|
||||
disabled={dependencyLoading}
|
||||
>
|
||||
{dependencyLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Approving & Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Approve & Install All
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={handleCancelDependencies}
|
||||
disabled={dependencyLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Modal */}
|
||||
{showLogModal && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:p-0">
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl border border-gray-200">
|
||||
{/* Modern Header */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-lg">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Installation Logs - {selectedUpdate?.package_name}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-md p-1"
|
||||
onClick={() => setShowLogModal(false)}
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Terminal Content Area */}
|
||||
<div className="bg-gray-900 text-green-400 p-4 max-h-96 overflow-y-auto" style={{ fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace' }}>
|
||||
{logsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-green-400 mr-2" />
|
||||
<span className="text-green-400">Loading logs...</span>
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-8">
|
||||
No installation logs available for this update.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className="border-b border-gray-700 pb-3 last:border-b-0">
|
||||
<div className="flex items-center space-x-3 mb-2 text-xs">
|
||||
<span className="text-gray-500">
|
||||
{new Date(log.executedAt).toLocaleString()}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"px-2 py-1 rounded font-medium",
|
||||
log.action === 'install' ? "bg-blue-900/50 text-blue-300" :
|
||||
log.action === 'configure' ? "bg-yellow-900/50 text-yellow-300" :
|
||||
log.action === 'cleanup' ? "bg-gray-700 text-gray-300" :
|
||||
"bg-gray-700 text-gray-300"
|
||||
)}>
|
||||
{log.action?.toUpperCase() || 'UNKNOWN'}
|
||||
</span>
|
||||
{log.exit_code !== undefined && (
|
||||
<span className={cn(
|
||||
"px-2 py-1 rounded font-medium",
|
||||
log.exit_code === 0 ? "bg-green-900/50 text-green-300" : "bg-red-900/50 text-red-300"
|
||||
)}>
|
||||
Exit: {log.exit_code}
|
||||
</span>
|
||||
)}
|
||||
{log.duration_seconds && (
|
||||
<span className="text-gray-500">
|
||||
{log.duration_seconds}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{log.stdout && (
|
||||
<div className="text-sm text-gray-300 whitespace-pre-wrap mb-2 font-mono">
|
||||
{log.stdout}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.stderr && (
|
||||
<div className="text-sm text-red-400 whitespace-pre-wrap font-mono">
|
||||
{log.stderr}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modern Footer */}
|
||||
<div className="bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse rounded-b-lg border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={() => setShowLogModal(false)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={() => {
|
||||
// Copy logs to clipboard functionality could be added here
|
||||
navigator.clipboard.writeText(logs.map(log =>
|
||||
`${log.action?.toUpperCase() || 'UNKNOWN'} - ${new Date(log.executedAt).toLocaleString()}\n${log.stdout || ''}\n${log.stderr || ''}`
|
||||
).join('\n\n'));
|
||||
// Could add toast notification here
|
||||
}}
|
||||
>
|
||||
Copy Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -376,6 +741,124 @@ const Updates: React.FC = () => {
|
||||
setCurrentPage(1); // Reset to first page when changing page size
|
||||
};
|
||||
|
||||
// Commands view
|
||||
if (activeTab === 'commands') {
|
||||
const commands = recentCommandsData?.commands || [];
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Command History</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Review and retry failed or cancelled commands
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveTab('updates')}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
← Back to Updates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commands list */}
|
||||
{commands.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Package className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No commands found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
No command history available yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="table-header">Command</th>
|
||||
<th className="table-header">Package</th>
|
||||
<th className="table-header">Agent</th>
|
||||
<th className="table-header">Status</th>
|
||||
<th className="table-header">Created</th>
|
||||
<th className="table-header">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{commands.map((command: any) => (
|
||||
<tr key={command.id} className="hover:bg-gray-50">
|
||||
<td className="table-cell">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{command.command_type.replace('_', ' ')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{command.package_name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{command.agent_hostname}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={cn(
|
||||
'badge',
|
||||
command.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
command.status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||
command.status === 'cancelled' ? 'bg-gray-100 text-gray-800' :
|
||||
command.status === 'pending' || command.status === 'sent' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
)}>
|
||||
{command.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatRelativeTime(command.created_at)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-2">
|
||||
{(command.status === 'failed' || command.status === 'cancelled' || command.status === 'timed_out') && (
|
||||
<button
|
||||
onClick={() => handleRetryCommand(command.id)}
|
||||
disabled={retryMutation.isLoading}
|
||||
className="text-amber-600 hover:text-amber-800"
|
||||
title="Retry command"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(command.status === 'pending' || command.status === 'sent') && (
|
||||
<button
|
||||
onClick={() => handleCancelCommand(command.id)}
|
||||
disabled={cancelMutation.isLoading}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
title="Cancel command"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Updates list view
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
@@ -554,6 +1037,15 @@ const Updates: React.FC = () => {
|
||||
Approve Selected ({selectedUpdates.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Command History button */}
|
||||
<button
|
||||
onClick={() => setActiveTab('commands')}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Command History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -650,7 +1142,7 @@ const Updates: React.FC = () => {
|
||||
<Package className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No updates found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchQuery || statusFilter || severityFilter || typeFilter || agentFilter
|
||||
{debouncedSearchQuery || statusFilter || severityFilter || typeFilter || agentFilter
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'All agents are up to date!'}
|
||||
</p>
|
||||
@@ -728,7 +1220,14 @@ const Updates: React.FC = () => {
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={cn('badge', getStatusColor(update.status))}>
|
||||
{update.status}
|
||||
{update.status === 'checking_dependencies' ? (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Checking dependencies...</span>
|
||||
</div>
|
||||
) : (
|
||||
update.status
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
@@ -779,6 +1278,12 @@ const Updates: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{update.status === 'checking_dependencies' && (
|
||||
<div className="text-blue-500" title="Checking dependencies">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/updates/${update.id}`)}
|
||||
className="text-gray-400 hover:text-primary-600"
|
||||
@@ -835,16 +1340,7 @@ const Updates: React.FC = () => {
|
||||
|
||||
{/* 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;
|
||||
}
|
||||
const pageNum = totalPages <= 5 ? i + 1 : currentPage <= 3 ? i + 1 : currentPage >= totalPages - 2 ? totalPages - 4 + i : currentPage - 2 + i;
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||