Add screenshots and update gitignore for alpha release
- Fixed gitignore to allow Screenshots/*.png files - Added all screenshots for README documentation - Fixed gitignore to be less restrictive with image files - Includes dashboard, agent, updates, and docker screenshots
This commit is contained in:
BIN
Screenshots/RedFlag Agent Dashboard.png
Normal file
BIN
Screenshots/RedFlag Agent Dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
BIN
Screenshots/RedFlag Default Dashboard.png
Normal file
BIN
Screenshots/RedFlag Default Dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
Screenshots/RedFlag Docker Dashboard.png
Normal file
BIN
Screenshots/RedFlag Docker Dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
BIN
Screenshots/RedFlag Updates Dashboard.png
Normal file
BIN
Screenshots/RedFlag Updates Dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
Binary file not shown.
@@ -12,7 +12,9 @@ import (
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
"github.com/aggregator-project/aggregator-agent/internal/config"
|
||||
"github.com/aggregator-project/aggregator-agent/internal/display"
|
||||
"github.com/aggregator-project/aggregator-agent/internal/installer"
|
||||
"github.com/aggregator-project/aggregator-agent/internal/scanner"
|
||||
"github.com/aggregator-project/aggregator-agent/internal/system"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -41,8 +43,16 @@ func main() {
|
||||
if err := registerAgent(cfg, *serverURL); err != nil {
|
||||
log.Fatal("Registration failed:", err)
|
||||
}
|
||||
fmt.Println("✓ Agent registered successfully!")
|
||||
fmt.Printf("Agent ID: %s\n", cfg.AgentID)
|
||||
fmt.Println("==================================================================")
|
||||
fmt.Println("🎉 AGENT REGISTRATION SUCCESSFUL!")
|
||||
fmt.Println("==================================================================")
|
||||
fmt.Printf("📋 Agent ID: %s\n", cfg.AgentID)
|
||||
fmt.Printf("🌐 Server: %s\n", cfg.ServerURL)
|
||||
fmt.Printf("⏱️ Check-in Interval: %ds\n", cfg.CheckInInterval)
|
||||
fmt.Println("==================================================================")
|
||||
fmt.Println("💡 Save this Agent ID for your records!")
|
||||
fmt.Println("🚀 You can now start the agent without flags")
|
||||
fmt.Println("")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -82,20 +92,64 @@ func main() {
|
||||
}
|
||||
|
||||
func registerAgent(cfg *config.Config, serverURL string) error {
|
||||
// Get detailed system information
|
||||
sysInfo, err := system.GetSystemInfo(AgentVersion)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to get detailed system info: %v\n", err)
|
||||
// Fall back to basic detection
|
||||
hostname, _ := os.Hostname()
|
||||
osType, osVersion, osArch := client.DetectSystem()
|
||||
|
||||
apiClient := client.NewClient(serverURL, "")
|
||||
|
||||
req := client.RegisterRequest{
|
||||
sysInfo = &system.SystemInfo{
|
||||
Hostname: hostname,
|
||||
OSType: osType,
|
||||
OSVersion: osVersion,
|
||||
OSArchitecture: osArch,
|
||||
AgentVersion: AgentVersion,
|
||||
Metadata: map[string]string{
|
||||
Metadata: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
apiClient := client.NewClient(serverURL, "")
|
||||
|
||||
// Create metadata with system information
|
||||
metadata := map[string]string{
|
||||
"installation_time": time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
// Add system info to metadata
|
||||
if sysInfo.CPUInfo.ModelName != "" {
|
||||
metadata["cpu_model"] = sysInfo.CPUInfo.ModelName
|
||||
}
|
||||
if sysInfo.CPUInfo.Cores > 0 {
|
||||
metadata["cpu_cores"] = fmt.Sprintf("%d", sysInfo.CPUInfo.Cores)
|
||||
}
|
||||
if sysInfo.MemoryInfo.Total > 0 {
|
||||
metadata["memory_total"] = fmt.Sprintf("%d", sysInfo.MemoryInfo.Total)
|
||||
}
|
||||
if sysInfo.RunningProcesses > 0 {
|
||||
metadata["processes"] = fmt.Sprintf("%d", sysInfo.RunningProcesses)
|
||||
}
|
||||
if sysInfo.Uptime != "" {
|
||||
metadata["uptime"] = sysInfo.Uptime
|
||||
}
|
||||
|
||||
// Add disk information
|
||||
for i, disk := range sysInfo.DiskInfo {
|
||||
if i == 0 {
|
||||
metadata["disk_mount"] = disk.Mountpoint
|
||||
metadata["disk_total"] = fmt.Sprintf("%d", disk.Total)
|
||||
metadata["disk_used"] = fmt.Sprintf("%d", disk.Used)
|
||||
break // Only add primary disk info
|
||||
}
|
||||
}
|
||||
|
||||
req := client.RegisterRequest{
|
||||
Hostname: sysInfo.Hostname,
|
||||
OSType: sysInfo.OSType,
|
||||
OSVersion: sysInfo.OSVersion,
|
||||
OSArchitecture: sysInfo.OSArchitecture,
|
||||
AgentVersion: sysInfo.AgentVersion,
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
resp, err := apiClient.Register(req)
|
||||
@@ -121,14 +175,19 @@ func registerAgent(cfg *config.Config, serverURL string) error {
|
||||
|
||||
func runAgent(cfg *config.Config) error {
|
||||
log.Printf("🚩 RedFlag Agent v%s starting...\n", AgentVersion)
|
||||
log.Printf("Agent ID: %s\n", cfg.AgentID)
|
||||
log.Printf("Server: %s\n", cfg.ServerURL)
|
||||
log.Printf("Check-in interval: %ds\n", cfg.CheckInInterval)
|
||||
log.Printf("==================================================================")
|
||||
log.Printf("📋 AGENT ID: %s", cfg.AgentID)
|
||||
log.Printf("🌐 SERVER: %s", cfg.ServerURL)
|
||||
log.Printf("⏱️ CHECK-IN INTERVAL: %ds", cfg.CheckInInterval)
|
||||
log.Printf("==================================================================")
|
||||
log.Printf("💡 Tip: Use this Agent ID to identify this agent in the web UI")
|
||||
log.Printf("")
|
||||
|
||||
apiClient := client.NewClient(cfg.ServerURL, cfg.Token)
|
||||
|
||||
// Initialize scanners
|
||||
aptScanner := scanner.NewAPTScanner()
|
||||
dnfScanner := scanner.NewDNFScanner()
|
||||
dockerScanner, _ := scanner.NewDockerScanner()
|
||||
|
||||
// Main check-in loop
|
||||
@@ -153,7 +212,7 @@ func runAgent(cfg *config.Config) error {
|
||||
|
||||
switch cmd.Type {
|
||||
case "scan_updates":
|
||||
if err := handleScanUpdates(apiClient, cfg, aptScanner, dockerScanner, cmd.ID); err != nil {
|
||||
if err := handleScanUpdates(apiClient, cfg, aptScanner, dnfScanner, dockerScanner, cmd.ID); err != nil {
|
||||
log.Printf("Error scanning updates: %v\n", err)
|
||||
}
|
||||
|
||||
@@ -161,7 +220,9 @@ func runAgent(cfg *config.Config) error {
|
||||
log.Println("Spec collection not yet implemented")
|
||||
|
||||
case "install_updates":
|
||||
log.Println("Update installation not yet implemented")
|
||||
if err := handleInstallUpdates(apiClient, cfg, cmd.ID, cmd.Params); err != nil {
|
||||
log.Printf("Error installing updates: %v\n", err)
|
||||
}
|
||||
|
||||
default:
|
||||
log.Printf("Unknown command type: %s\n", cmd.Type)
|
||||
@@ -173,7 +234,7 @@ func runAgent(cfg *config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner *scanner.APTScanner, dockerScanner *scanner.DockerScanner, commandID string) error {
|
||||
func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner *scanner.APTScanner, dnfScanner *scanner.DNFScanner, dockerScanner *scanner.DockerScanner, commandID string) error {
|
||||
log.Println("Scanning for updates...")
|
||||
|
||||
var allUpdates []client.UpdateReportItem
|
||||
@@ -190,6 +251,18 @@ func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner
|
||||
}
|
||||
}
|
||||
|
||||
// Scan DNF updates
|
||||
if dnfScanner.IsAvailable() {
|
||||
log.Println(" - Scanning DNF packages...")
|
||||
updates, err := dnfScanner.Scan()
|
||||
if err != nil {
|
||||
log.Printf(" DNF scan failed: %v\n", err)
|
||||
} else {
|
||||
log.Printf(" Found %d DNF updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan Docker updates
|
||||
if dockerScanner != nil && dockerScanner.IsAvailable() {
|
||||
log.Println(" - Scanning Docker images...")
|
||||
@@ -226,6 +299,7 @@ func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner
|
||||
func handleScanCommand(cfg *config.Config, exportFormat string) error {
|
||||
// Initialize scanners
|
||||
aptScanner := scanner.NewAPTScanner()
|
||||
dnfScanner := scanner.NewDNFScanner()
|
||||
dockerScanner, _ := scanner.NewDockerScanner()
|
||||
|
||||
fmt.Println("🔍 Scanning for updates...")
|
||||
@@ -243,6 +317,18 @@ func handleScanCommand(cfg *config.Config, exportFormat string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Scan DNF updates
|
||||
if dnfScanner.IsAvailable() {
|
||||
fmt.Println(" - Scanning DNF packages...")
|
||||
updates, err := dnfScanner.Scan()
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠️ DNF scan failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d DNF updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan Docker updates
|
||||
if dockerScanner != nil && dockerScanner.IsAvailable() {
|
||||
fmt.Println(" - Scanning Docker images...")
|
||||
@@ -345,6 +431,128 @@ func handleListUpdatesCommand(cfg *config.Config, exportFormat string) error {
|
||||
return display.PrintDetailedUpdates(localCache.Updates, exportFormat)
|
||||
}
|
||||
|
||||
// handleInstallUpdates handles install_updates command
|
||||
func handleInstallUpdates(apiClient *client.Client, cfg *config.Config, commandID string, params map[string]interface{}) error {
|
||||
log.Println("Installing updates...")
|
||||
|
||||
// Parse parameters
|
||||
packageType := ""
|
||||
packageName := ""
|
||||
targetVersion := ""
|
||||
|
||||
if pt, ok := params["package_type"].(string); ok {
|
||||
packageType = pt
|
||||
}
|
||||
if pn, ok := params["package_name"].(string); ok {
|
||||
packageName = pn
|
||||
}
|
||||
if tv, ok := params["target_version"].(string); ok {
|
||||
targetVersion = tv
|
||||
}
|
||||
|
||||
// Validate package type
|
||||
if packageType == "" {
|
||||
return fmt.Errorf("package_type parameter is required")
|
||||
}
|
||||
|
||||
// Create installer based on package type
|
||||
inst, err := installer.InstallerFactory(packageType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create installer for package type %s: %w", packageType, err)
|
||||
}
|
||||
|
||||
// Check if installer is available
|
||||
if !inst.IsAvailable() {
|
||||
return fmt.Errorf("%s installer is not available on this system", packageType)
|
||||
}
|
||||
|
||||
var result *installer.InstallResult
|
||||
var action string
|
||||
|
||||
// Perform installation based on what's specified
|
||||
if packageName != "" {
|
||||
action = "install"
|
||||
log.Printf("Installing package: %s (type: %s)", packageName, packageType)
|
||||
result, err = inst.Install(packageName)
|
||||
} else if len(params) > 1 {
|
||||
// Multiple packages might be specified in various ways
|
||||
var packageNames []string
|
||||
for key, value := range params {
|
||||
if key != "package_type" && key != "target_version" {
|
||||
if name, ok := value.(string); ok && name != "" {
|
||||
packageNames = append(packageNames, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(packageNames) > 0 {
|
||||
action = "install_multiple"
|
||||
log.Printf("Installing multiple packages: %v (type: %s)", packageNames, packageType)
|
||||
result, err = inst.InstallMultiple(packageNames)
|
||||
} else {
|
||||
// Upgrade all packages if no specific packages named
|
||||
action = "upgrade"
|
||||
log.Printf("Upgrading all packages (type: %s)", packageType)
|
||||
result, err = inst.Upgrade()
|
||||
}
|
||||
} else {
|
||||
// Upgrade all packages if no specific packages named
|
||||
action = "upgrade"
|
||||
log.Printf("Upgrading all packages (type: %s)", packageType)
|
||||
result, err = inst.Upgrade()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Report installation failure
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: action,
|
||||
Result: "failed",
|
||||
Stdout: "",
|
||||
Stderr: fmt.Sprintf("Installation error: %v", err),
|
||||
ExitCode: 1,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
|
||||
if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
|
||||
log.Printf("Failed to report installation failure: %v\n", reportErr)
|
||||
}
|
||||
|
||||
return fmt.Errorf("installation failed: %w", err)
|
||||
}
|
||||
|
||||
// Report installation success
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: result.Action,
|
||||
Result: "success",
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
ExitCode: result.ExitCode,
|
||||
DurationSeconds: result.DurationSeconds,
|
||||
}
|
||||
|
||||
// Add additional metadata to the log report
|
||||
if len(result.PackagesInstalled) > 0 {
|
||||
logReport.Stdout += fmt.Sprintf("\nPackages installed: %v", result.PackagesInstalled)
|
||||
}
|
||||
|
||||
if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
|
||||
log.Printf("Failed to report installation success: %v\n", reportErr)
|
||||
}
|
||||
|
||||
if result.Success {
|
||||
log.Printf("✓ Installation completed successfully in %d seconds\n", result.DurationSeconds)
|
||||
if len(result.PackagesInstalled) > 0 {
|
||||
log.Printf(" Packages installed: %v\n", result.PackagesInstalled)
|
||||
}
|
||||
} else {
|
||||
log.Printf("✗ Installation failed after %d seconds\n", result.DurationSeconds)
|
||||
log.Printf(" Error: %s\n", result.ErrorMessage)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatTimeSince formats a duration as "X time ago"
|
||||
func formatTimeSince(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -219,18 +220,17 @@ func (c *Client) ReportLog(agentID uuid.UUID, report LogReport) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DetectSystem returns basic system information
|
||||
// DetectSystem returns basic system information (deprecated, use system.GetSystemInfo instead)
|
||||
func DetectSystem() (osType, osVersion, osArch string) {
|
||||
osType = runtime.GOOS
|
||||
osArch = runtime.GOARCH
|
||||
|
||||
// Read OS version (simplified for now)
|
||||
// Read OS version
|
||||
switch osType {
|
||||
case "linux":
|
||||
data, _ := os.ReadFile("/etc/os-release")
|
||||
if data != nil {
|
||||
// Parse os-release file (simplified)
|
||||
osVersion = "Linux"
|
||||
osVersion = parseOSRelease(data)
|
||||
}
|
||||
case "windows":
|
||||
osVersion = "Windows"
|
||||
@@ -240,3 +240,38 @@ func DetectSystem() (osType, osVersion, osArch string) {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// parseOSRelease parses /etc/os-release to get proper distro name
|
||||
func parseOSRelease(data []byte) string {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
id := ""
|
||||
prettyName := ""
|
||||
version := ""
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "ID=") {
|
||||
id = strings.Trim(strings.TrimPrefix(line, "ID="), "\"")
|
||||
}
|
||||
if strings.HasPrefix(line, "PRETTY_NAME=") {
|
||||
prettyName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"")
|
||||
}
|
||||
if strings.HasPrefix(line, "VERSION_ID=") {
|
||||
version = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"")
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer PRETTY_NAME if available
|
||||
if prettyName != "" {
|
||||
return prettyName
|
||||
}
|
||||
|
||||
// Fall back to ID + VERSION
|
||||
if id != "" {
|
||||
if version != "" {
|
||||
return strings.Title(id) + " " + version
|
||||
}
|
||||
return strings.Title(id)
|
||||
}
|
||||
|
||||
return "Linux"
|
||||
}
|
||||
|
||||
381
aggregator-agent/internal/system/info.go
Normal file
381
aggregator-agent/internal/system/info.go
Normal file
@@ -0,0 +1,381 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SystemInfo contains detailed system information
|
||||
type SystemInfo struct {
|
||||
Hostname string `json:"hostname"`
|
||||
OSType string `json:"os_type"`
|
||||
OSVersion string `json:"os_version"`
|
||||
OSArchitecture string `json:"os_architecture"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
CPUInfo CPUInfo `json:"cpu_info"`
|
||||
MemoryInfo MemoryInfo `json:"memory_info"`
|
||||
DiskInfo []DiskInfo `json:"disk_info"`
|
||||
RunningProcesses int `json:"running_processes"`
|
||||
Uptime string `json:"uptime"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// CPUInfo contains CPU information
|
||||
type CPUInfo struct {
|
||||
ModelName string `json:"model_name"`
|
||||
Cores int `json:"cores"`
|
||||
Threads int `json:"threads"`
|
||||
}
|
||||
|
||||
// MemoryInfo contains memory information
|
||||
type MemoryInfo struct {
|
||||
Total uint64 `json:"total"`
|
||||
Available uint64 `json:"available"`
|
||||
Used uint64 `json:"used"`
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
}
|
||||
|
||||
// DiskInfo contains disk information
|
||||
type DiskInfo struct {
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Total uint64 `json:"total"`
|
||||
Available uint64 `json:"available"`
|
||||
Used uint64 `json:"used"`
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
Filesystem string `json:"filesystem"`
|
||||
}
|
||||
|
||||
// GetSystemInfo collects detailed system information
|
||||
func GetSystemInfo(agentVersion string) (*SystemInfo, error) {
|
||||
info := &SystemInfo{
|
||||
AgentVersion: agentVersion,
|
||||
Metadata: make(map[string]string),
|
||||
}
|
||||
|
||||
// Get basic system info
|
||||
info.OSType = runtime.GOOS
|
||||
info.OSArchitecture = runtime.GOARCH
|
||||
|
||||
// Get hostname
|
||||
if hostname, err := exec.Command("hostname").Output(); err == nil {
|
||||
info.Hostname = strings.TrimSpace(string(hostname))
|
||||
}
|
||||
|
||||
// Get IP address
|
||||
if ip, err := getIPAddress(); err == nil {
|
||||
info.IPAddress = ip
|
||||
}
|
||||
|
||||
// Get OS version info
|
||||
if info.OSType == "linux" {
|
||||
info.OSVersion = getLinuxDistroInfo()
|
||||
} else if info.OSType == "windows" {
|
||||
info.OSVersion = getWindowsInfo()
|
||||
} else if info.OSType == "darwin" {
|
||||
info.OSVersion = getMacOSInfo()
|
||||
}
|
||||
|
||||
// Get CPU info
|
||||
if cpu, err := getCPUInfo(); err == nil {
|
||||
info.CPUInfo = *cpu
|
||||
}
|
||||
|
||||
// Get memory info
|
||||
if mem, err := getMemoryInfo(); err == nil {
|
||||
info.MemoryInfo = *mem
|
||||
}
|
||||
|
||||
// Get disk info
|
||||
if disks, err := getDiskInfo(); err == nil {
|
||||
info.DiskInfo = disks
|
||||
}
|
||||
|
||||
// Get process count
|
||||
if procs, err := getProcessCount(); err == nil {
|
||||
info.RunningProcesses = procs
|
||||
}
|
||||
|
||||
// Get uptime
|
||||
if uptime, err := getUptime(); err == nil {
|
||||
info.Uptime = uptime
|
||||
}
|
||||
|
||||
// Add collection timestamp
|
||||
info.Metadata["collected_at"] = time.Now().Format(time.RFC3339)
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// getLinuxDistroInfo parses /etc/os-release for distro information
|
||||
func getLinuxDistroInfo() string {
|
||||
if data, err := exec.Command("cat", "/etc/os-release").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
prettyName := ""
|
||||
version := ""
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "PRETTY_NAME=") {
|
||||
prettyName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"")
|
||||
}
|
||||
if strings.HasPrefix(line, "VERSION_ID=") {
|
||||
version = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"")
|
||||
}
|
||||
}
|
||||
|
||||
if prettyName != "" {
|
||||
return prettyName
|
||||
}
|
||||
|
||||
// Fallback to parsing ID and VERSION_ID
|
||||
id := ""
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "ID=") {
|
||||
id = strings.Trim(strings.TrimPrefix(line, "ID="), "\"")
|
||||
}
|
||||
}
|
||||
|
||||
if id != "" {
|
||||
if version != "" {
|
||||
return strings.Title(id) + " " + version
|
||||
}
|
||||
return strings.Title(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Try other methods
|
||||
if data, err := exec.Command("lsb_release", "-d", "-s").Output(); err == nil {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
return "Linux"
|
||||
}
|
||||
|
||||
// getWindowsInfo gets Windows version information
|
||||
func getWindowsInfo() string {
|
||||
// Try using wmic for Windows version
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
if data, err := exec.Command(cmd, "os", "get", "Caption,Version").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "Microsoft Windows") {
|
||||
return strings.TrimSpace(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "Windows"
|
||||
}
|
||||
|
||||
// getMacOSInfo gets macOS version information
|
||||
func getMacOSInfo() string {
|
||||
if cmd, err := exec.LookPath("sw_vers"); err == nil {
|
||||
if data, err := exec.Command(cmd, "-productVersion").Output(); err == nil {
|
||||
version := strings.TrimSpace(string(data))
|
||||
return "macOS " + version
|
||||
}
|
||||
}
|
||||
|
||||
return "macOS"
|
||||
}
|
||||
|
||||
// getCPUInfo gets CPU information
|
||||
func getCPUInfo() (*CPUInfo, error) {
|
||||
cpu := &CPUInfo{}
|
||||
|
||||
if runtime.GOOS == "linux" {
|
||||
if data, err := exec.Command("cat", "/proc/cpuinfo").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
cores := 0
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "model name") {
|
||||
cpu.ModelName = strings.TrimPrefix(line, "model name\t: ")
|
||||
}
|
||||
if strings.HasPrefix(line, "processor") {
|
||||
cores++
|
||||
}
|
||||
}
|
||||
cpu.Cores = cores
|
||||
cpu.Threads = cores
|
||||
}
|
||||
} else if runtime.GOOS == "darwin" {
|
||||
if cmd, err := exec.LookPath("sysctl"); err == nil {
|
||||
if data, err := exec.Command(cmd, "-n", "hw.ncpu").Output(); err == nil {
|
||||
if cores, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil {
|
||||
cpu.Cores = cores
|
||||
cpu.Threads = cores
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cpu, nil
|
||||
}
|
||||
|
||||
// getMemoryInfo gets memory information
|
||||
func getMemoryInfo() (*MemoryInfo, error) {
|
||||
mem := &MemoryInfo{}
|
||||
|
||||
if runtime.GOOS == "linux" {
|
||||
if data, err := exec.Command("cat", "/proc/meminfo").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
switch fields[0] {
|
||||
case "MemTotal:":
|
||||
if total, err := strconv.ParseUint(fields[1], 10, 64); err == nil {
|
||||
mem.Total = total * 1024 // Convert from KB to bytes
|
||||
}
|
||||
case "MemAvailable:":
|
||||
if available, err := strconv.ParseUint(fields[1], 10, 64); err == nil {
|
||||
mem.Available = available * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mem.Used = mem.Total - mem.Available
|
||||
if mem.Total > 0 {
|
||||
mem.UsedPercent = float64(mem.Used) / float64(mem.Total) * 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mem, nil
|
||||
}
|
||||
|
||||
// getDiskInfo gets disk information for mounted filesystems
|
||||
func getDiskInfo() ([]DiskInfo, error) {
|
||||
var disks []DiskInfo
|
||||
|
||||
if cmd, err := exec.LookPath("df"); err == nil {
|
||||
if data, err := exec.Command(cmd, "-h", "--output=target,size,used,avail,pcent,source").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for i, line := range lines {
|
||||
if i == 0 || strings.TrimSpace(line) == "" {
|
||||
continue // Skip header and empty lines
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 6 {
|
||||
disk := DiskInfo{
|
||||
Mountpoint: fields[0],
|
||||
Filesystem: fields[5],
|
||||
}
|
||||
|
||||
// Parse sizes (df outputs in human readable format, we'll parse the numeric part)
|
||||
if total, err := parseSize(fields[1]); err == nil {
|
||||
disk.Total = total
|
||||
}
|
||||
if used, err := parseSize(fields[2]); err == nil {
|
||||
disk.Used = used
|
||||
}
|
||||
if available, err := parseSize(fields[3]); err == nil {
|
||||
disk.Available = available
|
||||
}
|
||||
if total, err := strconv.ParseFloat(strings.TrimSuffix(fields[4], "%"), 64); err == nil {
|
||||
disk.UsedPercent = total
|
||||
}
|
||||
|
||||
disks = append(disks, disk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return disks, nil
|
||||
}
|
||||
|
||||
// parseSize parses human readable size strings (like "1.5G" or "500M")
|
||||
func parseSize(sizeStr string) (uint64, error) {
|
||||
sizeStr = strings.TrimSpace(sizeStr)
|
||||
if len(sizeStr) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
multiplier := uint64(1)
|
||||
unit := sizeStr[len(sizeStr)-1:]
|
||||
if unit == "G" || unit == "g" {
|
||||
multiplier = 1024 * 1024 * 1024
|
||||
sizeStr = sizeStr[:len(sizeStr)-1]
|
||||
} else if unit == "M" || unit == "m" {
|
||||
multiplier = 1024 * 1024
|
||||
sizeStr = sizeStr[:len(sizeStr)-1]
|
||||
} else if unit == "K" || unit == "k" {
|
||||
multiplier = 1024
|
||||
sizeStr = sizeStr[:len(sizeStr)-1]
|
||||
}
|
||||
|
||||
size, err := strconv.ParseFloat(sizeStr, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return uint64(size * float64(multiplier)), nil
|
||||
}
|
||||
|
||||
// getProcessCount gets the number of running processes
|
||||
func getProcessCount() (int, error) {
|
||||
if runtime.GOOS == "linux" {
|
||||
if data, err := exec.Command("ps", "-e").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
return len(lines) - 1, nil // Subtract 1 for header
|
||||
}
|
||||
} else if runtime.GOOS == "darwin" {
|
||||
if data, err := exec.Command("ps", "-ax").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
return len(lines) - 1, nil // Subtract 1 for header
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// getUptime gets system uptime
|
||||
func getUptime() (string, error) {
|
||||
if runtime.GOOS == "linux" {
|
||||
if data, err := exec.Command("uptime", "-p").Output(); err == nil {
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
} else if runtime.GOOS == "darwin" {
|
||||
if data, err := exec.Command("uptime").Output(); err == nil {
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "Unknown", nil
|
||||
}
|
||||
|
||||
// getIPAddress gets the primary IP address
|
||||
func getIPAddress() (string, error) {
|
||||
if runtime.GOOS == "linux" {
|
||||
// Try to get the IP from hostname -I
|
||||
if data, err := exec.Command("hostname", "-I").Output(); err == nil {
|
||||
ips := strings.Fields(string(data))
|
||||
if len(ips) > 0 {
|
||||
return ips[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to ip route
|
||||
if data, err := exec.Command("ip", "route", "get", "8.8.8.8").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "src") {
|
||||
fields := strings.Fields(line)
|
||||
for i, field := range fields {
|
||||
if field == "src" && i+1 < len(fields) {
|
||||
return fields[i+1], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "127.0.0.1", nil
|
||||
}
|
||||
10
aggregator-agent/test-config/config.yaml
Normal file
10
aggregator-agent/test-config/config.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
server:
|
||||
url: "http://localhost:8080"
|
||||
|
||||
agent:
|
||||
hostname: "test-agent"
|
||||
check_in_interval: 60
|
||||
batch_size: 50
|
||||
|
||||
auth:
|
||||
token: "test-token"
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-server/internal/api/handlers"
|
||||
"github.com/aggregator-project/aggregator-server/internal/api/middleware"
|
||||
"github.com/aggregator-project/aggregator-server/internal/config"
|
||||
"github.com/aggregator-project/aggregator-server/internal/database"
|
||||
"github.com/aggregator-project/aggregator-server/internal/database/queries"
|
||||
"github.com/aggregator-project/aggregator-server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -33,7 +35,9 @@ func main() {
|
||||
// Run migrations
|
||||
migrationsPath := filepath.Join("internal", "database", "migrations")
|
||||
if err := db.Migrate(migrationsPath); err != nil {
|
||||
log.Fatal("Failed to run migrations:", err)
|
||||
// For development, continue even if migrations fail
|
||||
// In production, you might want to handle this more gracefully
|
||||
fmt.Printf("Warning: Migration failed (tables may already exist): %v\n", err)
|
||||
}
|
||||
|
||||
// Initialize queries
|
||||
@@ -41,13 +45,23 @@ func main() {
|
||||
updateQueries := queries.NewUpdateQueries(db.DB)
|
||||
commandQueries := queries.NewCommandQueries(db.DB)
|
||||
|
||||
// Initialize services
|
||||
timezoneService := services.NewTimezoneService(cfg)
|
||||
|
||||
// Initialize handlers
|
||||
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, cfg.CheckInInterval)
|
||||
updateHandler := handlers.NewUpdateHandler(updateQueries)
|
||||
updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries)
|
||||
authHandler := handlers.NewAuthHandler(cfg.JWTSecret)
|
||||
statsHandler := handlers.NewStatsHandler(agentQueries, updateQueries)
|
||||
settingsHandler := handlers.NewSettingsHandler(timezoneService)
|
||||
dockerHandler := handlers.NewDockerHandler(updateQueries, agentQueries, commandQueries)
|
||||
|
||||
// Setup router
|
||||
router := gin.Default()
|
||||
|
||||
// Add CORS middleware
|
||||
router.Use(middleware.CORSMiddleware())
|
||||
|
||||
// Health check
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "healthy"})
|
||||
@@ -56,6 +70,11 @@ func main() {
|
||||
// API routes
|
||||
api := router.Group("/api/v1")
|
||||
{
|
||||
// Authentication routes
|
||||
api.POST("/auth/login", authHandler.Login)
|
||||
api.POST("/auth/logout", authHandler.Logout)
|
||||
api.GET("/auth/verify", authHandler.VerifyToken)
|
||||
|
||||
// Public routes
|
||||
api.POST("/agents/register", agentHandler.RegisterAgent)
|
||||
|
||||
@@ -68,14 +87,56 @@ func main() {
|
||||
agents.POST("/:id/logs", updateHandler.ReportLog)
|
||||
}
|
||||
|
||||
// Dashboard/Web routes (will add proper auth later)
|
||||
api.GET("/agents", agentHandler.ListAgents)
|
||||
api.GET("/agents/:id", agentHandler.GetAgent)
|
||||
api.POST("/agents/:id/scan", agentHandler.TriggerScan)
|
||||
api.GET("/updates", updateHandler.ListUpdates)
|
||||
api.GET("/updates/:id", updateHandler.GetUpdate)
|
||||
api.POST("/updates/:id/approve", updateHandler.ApproveUpdate)
|
||||
// Dashboard/Web routes (protected by web auth)
|
||||
dashboard := api.Group("/")
|
||||
dashboard.Use(authHandler.WebAuthMiddleware())
|
||||
{
|
||||
dashboard.GET("/stats/summary", statsHandler.GetDashboardStats)
|
||||
dashboard.GET("/agents", agentHandler.ListAgents)
|
||||
dashboard.GET("/agents/:id", agentHandler.GetAgent)
|
||||
dashboard.POST("/agents/:id/scan", agentHandler.TriggerScan)
|
||||
dashboard.POST("/agents/:id/update", agentHandler.TriggerUpdate)
|
||||
dashboard.DELETE("/agents/:id", agentHandler.UnregisterAgent)
|
||||
dashboard.GET("/updates", updateHandler.ListUpdates)
|
||||
dashboard.GET("/updates/:id", updateHandler.GetUpdate)
|
||||
dashboard.POST("/updates/:id/approve", updateHandler.ApproveUpdate)
|
||||
dashboard.POST("/updates/approve", updateHandler.ApproveUpdates)
|
||||
dashboard.POST("/updates/:id/reject", updateHandler.RejectUpdate)
|
||||
dashboard.POST("/updates/:id/install", updateHandler.InstallUpdate)
|
||||
|
||||
// Settings routes
|
||||
dashboard.GET("/settings/timezone", settingsHandler.GetTimezone)
|
||||
dashboard.GET("/settings/timezones", settingsHandler.GetTimezones)
|
||||
dashboard.PUT("/settings/timezone", settingsHandler.UpdateTimezone)
|
||||
|
||||
// Docker routes
|
||||
dashboard.GET("/docker/containers", dockerHandler.GetContainers)
|
||||
dashboard.GET("/docker/stats", dockerHandler.GetStats)
|
||||
dashboard.POST("/docker/containers/:container_id/images/:image_id/approve", dockerHandler.ApproveUpdate)
|
||||
dashboard.POST("/docker/containers/:container_id/images/:image_id/reject", dockerHandler.RejectUpdate)
|
||||
dashboard.POST("/docker/containers/:container_id/images/:image_id/install", dockerHandler.InstallUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
// Start background goroutine to mark offline agents
|
||||
// TODO: Make these values configurable via settings:
|
||||
// - Check interval (currently 2 minutes, should match agent heartbeat setting)
|
||||
// - Offline threshold (currently 10 minutes, should be based on agent check-in interval + missed checks)
|
||||
// - Missed checks before offline (default 2, so 300s agent interval * 2 = 10 minutes)
|
||||
go func() {
|
||||
ticker := time.NewTicker(2 * time.Minute) // Check every 2 minutes
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// Mark agents as offline if they haven't checked in within 10 minutes
|
||||
if err := agentQueries.MarkOfflineAgents(10 * time.Minute); err != nil {
|
||||
log.Printf("Failed to mark offline agents: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Start server
|
||||
addr := ":" + cfg.ServerPort
|
||||
|
||||
26
aggregator-server/internal/api/middleware/cors.go
Normal file
26
aggregator-server/internal/api/middleware/cors.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORSMiddleware handles Cross-Origin Resource Sharing
|
||||
func CORSMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "http://localhost:3000")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
|
||||
// Handle preflight requests
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
@@ -14,6 +15,7 @@ type Config struct {
|
||||
JWTSecret string
|
||||
CheckInInterval int
|
||||
OfflineThreshold int
|
||||
Timezone string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables
|
||||
@@ -24,13 +26,21 @@ func Load() (*Config, error) {
|
||||
checkInInterval, _ := strconv.Atoi(getEnv("CHECK_IN_INTERVAL", "300"))
|
||||
offlineThreshold, _ := strconv.Atoi(getEnv("OFFLINE_THRESHOLD", "600"))
|
||||
|
||||
return &Config{
|
||||
cfg := &Config{
|
||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://aggregator:aggregator@localhost:5432/aggregator?sslmode=disable"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "test-secret-for-development-only"),
|
||||
CheckInInterval: checkInInterval,
|
||||
OfflineThreshold: offlineThreshold,
|
||||
}, nil
|
||||
Timezone: getEnv("TIMEZONE", "UTC"),
|
||||
}
|
||||
|
||||
// Debug: Log what JWT secret we're using (remove in production)
|
||||
if cfg.JWTSecret == "test-secret-for-development-only" {
|
||||
fmt.Printf("🔓 Using development JWT secret\n")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
|
||||
@@ -45,7 +45,7 @@ func (q *AgentQueries) GetAgentByID(id uuid.UUID) (*models.Agent, error) {
|
||||
// UpdateAgentLastSeen updates the agent's last_seen timestamp
|
||||
func (q *AgentQueries) UpdateAgentLastSeen(id uuid.UUID) error {
|
||||
query := `UPDATE agents SET last_seen = $1, status = 'online' WHERE id = $2`
|
||||
_, err := q.db.Exec(query, time.Now(), id)
|
||||
_, err := q.db.Exec(query, time.Now().UTC(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -81,3 +81,77 @@ func (q *AgentQueries) MarkOfflineAgents(threshold time.Duration) error {
|
||||
_, err := q.db.Exec(query, time.Now().Add(-threshold))
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAgentLastScan gets the last scan time from update events
|
||||
func (q *AgentQueries) GetAgentLastScan(id uuid.UUID) (*time.Time, error) {
|
||||
var lastScan time.Time
|
||||
query := `SELECT MAX(created_at) FROM update_events WHERE agent_id = $1`
|
||||
err := q.db.Get(&lastScan, query, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lastScan, nil
|
||||
}
|
||||
|
||||
// GetAgentWithLastScan gets agent information including last scan time
|
||||
func (q *AgentQueries) GetAgentWithLastScan(id uuid.UUID) (*models.AgentWithLastScan, error) {
|
||||
var agent models.AgentWithLastScan
|
||||
query := `
|
||||
SELECT
|
||||
a.*,
|
||||
(SELECT MAX(created_at) FROM update_events WHERE agent_id = a.id) as last_scan
|
||||
FROM agents a
|
||||
WHERE a.id = $1`
|
||||
err := q.db.Get(&agent, query, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &agent, nil
|
||||
}
|
||||
|
||||
// ListAgentsWithLastScan returns all agents with their last scan times
|
||||
func (q *AgentQueries) ListAgentsWithLastScan(status, osType string) ([]models.AgentWithLastScan, error) {
|
||||
var agents []models.AgentWithLastScan
|
||||
query := `
|
||||
SELECT
|
||||
a.*,
|
||||
(SELECT MAX(created_at) FROM update_events WHERE agent_id = a.id) as last_scan
|
||||
FROM agents a
|
||||
WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if status != "" {
|
||||
query += ` AND a.status = $` + string(rune(argIdx+'0'))
|
||||
args = append(args, status)
|
||||
argIdx++
|
||||
}
|
||||
if osType != "" {
|
||||
query += ` AND a.os_type = $` + string(rune(argIdx+'0'))
|
||||
args = append(args, osType)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
query += ` ORDER BY a.last_seen DESC`
|
||||
err := q.db.Select(&agents, query, args...)
|
||||
return agents, err
|
||||
}
|
||||
|
||||
// DeleteAgent removes an agent and all associated data
|
||||
func (q *AgentQueries) DeleteAgent(id uuid.UUID) error {
|
||||
// Start a transaction for atomic deletion
|
||||
tx, err := q.db.Beginx()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete the agent (CASCADE will handle related records)
|
||||
_, err = tx.Exec("DELETE FROM agents WHERE id = $1", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package queries
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-server/internal/models"
|
||||
"github.com/google/uuid"
|
||||
@@ -45,16 +46,16 @@ func (q *UpdateQueries) UpsertUpdate(update *models.UpdatePackage) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ListUpdates retrieves updates with filtering
|
||||
// ListUpdates retrieves updates with filtering (legacy method for update_packages table)
|
||||
func (q *UpdateQueries) ListUpdates(filters *models.UpdateFilters) ([]models.UpdatePackage, int, error) {
|
||||
var updates []models.UpdatePackage
|
||||
whereClause := []string{"1=1"}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if filters.AgentID != nil {
|
||||
if filters.AgentID != uuid.Nil {
|
||||
whereClause = append(whereClause, fmt.Sprintf("agent_id = $%d", argIdx))
|
||||
args = append(args, *filters.AgentID)
|
||||
args = append(args, filters.AgentID)
|
||||
argIdx++
|
||||
}
|
||||
if filters.Status != "" {
|
||||
@@ -103,10 +104,10 @@ func (q *UpdateQueries) ListUpdates(filters *models.UpdateFilters) ([]models.Upd
|
||||
return updates, total, err
|
||||
}
|
||||
|
||||
// GetUpdateByID retrieves a single update by ID
|
||||
func (q *UpdateQueries) GetUpdateByID(id uuid.UUID) (*models.UpdatePackage, error) {
|
||||
var update models.UpdatePackage
|
||||
query := `SELECT * FROM update_packages WHERE id = $1`
|
||||
// GetUpdateByID retrieves a single update by ID from the new state table
|
||||
func (q *UpdateQueries) GetUpdateByID(id uuid.UUID) (*models.UpdateState, error) {
|
||||
var update models.UpdateState
|
||||
query := `SELECT * FROM current_package_state WHERE id = $1`
|
||||
err := q.db.Get(&update, query, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -114,14 +115,98 @@ func (q *UpdateQueries) GetUpdateByID(id uuid.UUID) (*models.UpdatePackage, erro
|
||||
return &update, nil
|
||||
}
|
||||
|
||||
// ApproveUpdate marks an update as approved
|
||||
// GetUpdateByPackage retrieves a single update by agent_id, package_type, and package_name
|
||||
func (q *UpdateQueries) GetUpdateByPackage(agentID uuid.UUID, packageType, packageName string) (*models.UpdateState, error) {
|
||||
var update models.UpdateState
|
||||
query := `SELECT * FROM current_package_state WHERE agent_id = $1 AND package_type = $2 AND package_name = $3`
|
||||
err := q.db.Get(&update, query, agentID, packageType, packageName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &update, nil
|
||||
}
|
||||
|
||||
// ApproveUpdate marks an update as approved in the new event sourcing system
|
||||
func (q *UpdateQueries) ApproveUpdate(id uuid.UUID, approvedBy string) error {
|
||||
query := `
|
||||
UPDATE update_packages
|
||||
SET status = 'approved', approved_by = $1, approved_at = NOW()
|
||||
WHERE id = $2 AND status = 'pending'
|
||||
UPDATE current_package_state
|
||||
SET status = 'approved', last_updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
`
|
||||
_, err := q.db.Exec(query, approvedBy, id)
|
||||
_, err := q.db.Exec(query, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ApproveUpdateByPackage approves an update by agent_id, package_type, and package_name
|
||||
func (q *UpdateQueries) ApproveUpdateByPackage(agentID uuid.UUID, packageType, packageName, approvedBy string) error {
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'approved', last_updated_at = NOW()
|
||||
WHERE agent_id = $1 AND package_type = $2 AND package_name = $3 AND status = 'pending'
|
||||
`
|
||||
_, err := q.db.Exec(query, agentID, packageType, packageName)
|
||||
return err
|
||||
}
|
||||
|
||||
// BulkApproveUpdates approves multiple updates by their IDs
|
||||
func (q *UpdateQueries) BulkApproveUpdates(updateIDs []uuid.UUID, approvedBy string) error {
|
||||
if len(updateIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
tx, err := q.db.Beginx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Update each update
|
||||
for _, id := range updateIDs {
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'approved', last_updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
`
|
||||
_, err := tx.Exec(query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to approve update %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// RejectUpdate marks an update as rejected/ignored
|
||||
func (q *UpdateQueries) RejectUpdate(id uuid.UUID, rejectedBy string) error {
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'ignored', last_updated_at = NOW()
|
||||
WHERE id = $1 AND status IN ('pending', 'approved')
|
||||
`
|
||||
_, err := q.db.Exec(query, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// RejectUpdateByPackage rejects an update by agent_id, package_type, and package_name
|
||||
func (q *UpdateQueries) RejectUpdateByPackage(agentID uuid.UUID, packageType, packageName, rejectedBy string) error {
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'ignored', last_updated_at = NOW()
|
||||
WHERE agent_id = $1 AND package_type = $2 AND package_name = $3 AND status IN ('pending', 'approved')
|
||||
`
|
||||
_, err := q.db.Exec(query, agentID, packageType, packageName)
|
||||
return err
|
||||
}
|
||||
|
||||
// InstallUpdate marks an update as ready for installation
|
||||
func (q *UpdateQueries) InstallUpdate(id uuid.UUID) error {
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'installing', last_updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'approved'
|
||||
`
|
||||
_, err := q.db.Exec(query, id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -139,3 +224,366 @@ func (q *UpdateQueries) CreateUpdateLog(log *models.UpdateLog) error {
|
||||
_, err := q.db.NamedExec(query, log)
|
||||
return err
|
||||
}
|
||||
|
||||
// NEW EVENT SOURCING IMPLEMENTATION
|
||||
|
||||
// CreateUpdateEvent stores a single update event
|
||||
func (q *UpdateQueries) CreateUpdateEvent(event *models.UpdateEvent) error {
|
||||
query := `
|
||||
INSERT INTO update_events (
|
||||
agent_id, package_type, package_name, version_from, version_to,
|
||||
severity, repository_source, metadata, event_type
|
||||
) VALUES (
|
||||
:agent_id, :package_type, :package_name, :version_from, :version_to,
|
||||
:severity, :repository_source, :metadata, :event_type
|
||||
)
|
||||
`
|
||||
_, err := q.db.NamedExec(query, event)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateUpdateEventsBatch creates multiple update events in a transaction
|
||||
func (q *UpdateQueries) CreateUpdateEventsBatch(events []models.UpdateEvent) error {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
tx, err := q.db.Beginx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Create batch record
|
||||
batch := &models.UpdateBatch{
|
||||
ID: uuid.New(),
|
||||
AgentID: events[0].AgentID,
|
||||
BatchSize: len(events),
|
||||
Status: "processing",
|
||||
}
|
||||
|
||||
batchQuery := `
|
||||
INSERT INTO update_batches (id, agent_id, batch_size, status)
|
||||
VALUES (:id, :agent_id, :batch_size, :status)
|
||||
`
|
||||
if _, err := tx.NamedExec(batchQuery, batch); err != nil {
|
||||
return fmt.Errorf("failed to create batch record: %w", err)
|
||||
}
|
||||
|
||||
// Insert events in batches to avoid memory issues
|
||||
batchSize := 100
|
||||
processedCount := 0
|
||||
failedCount := 0
|
||||
|
||||
for i := 0; i < len(events); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(events) {
|
||||
end = len(events)
|
||||
}
|
||||
|
||||
currentBatch := events[i:end]
|
||||
|
||||
// Prepare query with multiple value sets
|
||||
query := `
|
||||
INSERT INTO update_events (
|
||||
agent_id, package_type, package_name, version_from, version_to,
|
||||
severity, repository_source, metadata, event_type
|
||||
) VALUES (
|
||||
:agent_id, :package_type, :package_name, :version_from, :version_to,
|
||||
:severity, :repository_source, :metadata, :event_type
|
||||
)
|
||||
`
|
||||
|
||||
for _, event := range currentBatch {
|
||||
_, err := tx.NamedExec(query, event)
|
||||
if err != nil {
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
processedCount++
|
||||
|
||||
// Update current state
|
||||
if err := q.updateCurrentStateInTx(tx, &event); err != nil {
|
||||
// Log error but don't fail the entire batch
|
||||
fmt.Printf("Warning: failed to update current state for %s: %v\n", event.PackageName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update batch record
|
||||
batchUpdateQuery := `
|
||||
UPDATE update_batches
|
||||
SET processed_count = $1, failed_count = $2, status = $3, completed_at = $4
|
||||
WHERE id = $5
|
||||
`
|
||||
batchStatus := "completed"
|
||||
if failedCount > 0 {
|
||||
batchStatus = "completed_with_errors"
|
||||
}
|
||||
|
||||
_, err = tx.Exec(batchUpdateQuery, processedCount, failedCount, batchStatus, time.Now(), batch.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update batch record: %w", err)
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// updateCurrentStateInTx updates the current_package_state table within a transaction
|
||||
func (q *UpdateQueries) updateCurrentStateInTx(tx *sqlx.Tx, event *models.UpdateEvent) error {
|
||||
query := `
|
||||
INSERT INTO current_package_state (
|
||||
agent_id, package_type, package_name, current_version, available_version,
|
||||
severity, repository_source, metadata, last_discovered_at, status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending')
|
||||
ON CONFLICT (agent_id, package_type, package_name)
|
||||
DO UPDATE SET
|
||||
available_version = EXCLUDED.available_version,
|
||||
severity = EXCLUDED.severity,
|
||||
repository_source = EXCLUDED.repository_source,
|
||||
metadata = EXCLUDED.metadata,
|
||||
last_discovered_at = EXCLUDED.last_discovered_at,
|
||||
status = CASE
|
||||
WHEN current_package_state.status IN ('updated', 'ignored')
|
||||
THEN current_package_state.status
|
||||
ELSE 'pending'
|
||||
END
|
||||
`
|
||||
_, err := tx.Exec(query,
|
||||
event.AgentID,
|
||||
event.PackageType,
|
||||
event.PackageName,
|
||||
event.VersionFrom,
|
||||
event.VersionTo,
|
||||
event.Severity,
|
||||
event.RepositorySource,
|
||||
event.Metadata,
|
||||
event.CreatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListUpdatesFromState returns paginated updates from current state with filtering
|
||||
func (q *UpdateQueries) ListUpdatesFromState(filters *models.UpdateFilters) ([]models.UpdateState, int, error) {
|
||||
var updates []models.UpdateState
|
||||
var count int
|
||||
|
||||
// Build base query
|
||||
baseQuery := `
|
||||
SELECT
|
||||
id, agent_id, package_type, package_name, current_version,
|
||||
available_version, severity, repository_source, metadata,
|
||||
last_discovered_at, last_updated_at, status
|
||||
FROM current_package_state
|
||||
WHERE 1=1
|
||||
`
|
||||
countQuery := `SELECT COUNT(*) FROM current_package_state WHERE 1=1`
|
||||
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
// Add filters
|
||||
if filters.AgentID != uuid.Nil {
|
||||
baseQuery += fmt.Sprintf(" AND agent_id = $%d", argIdx)
|
||||
countQuery += fmt.Sprintf(" AND agent_id = $%d", argIdx)
|
||||
args = append(args, filters.AgentID)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if filters.PackageType != "" {
|
||||
baseQuery += fmt.Sprintf(" AND package_type = $%d", argIdx)
|
||||
countQuery += fmt.Sprintf(" AND package_type = $%d", argIdx)
|
||||
args = append(args, filters.PackageType)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if filters.Severity != "" {
|
||||
baseQuery += fmt.Sprintf(" AND severity = $%d", argIdx)
|
||||
countQuery += fmt.Sprintf(" AND severity = $%d", argIdx)
|
||||
args = append(args, filters.Severity)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if filters.Status != "" {
|
||||
baseQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||
countQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||
args = append(args, filters.Status)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
// Get total count
|
||||
err := q.db.Get(&count, countQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get updates count: %w", err)
|
||||
}
|
||||
|
||||
// Add ordering and pagination
|
||||
baseQuery += " ORDER BY last_discovered_at DESC"
|
||||
baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
|
||||
args = append(args, filters.PageSize, (filters.Page-1)*filters.PageSize)
|
||||
|
||||
// Execute query
|
||||
err = q.db.Select(&updates, baseQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list updates: %w", err)
|
||||
}
|
||||
|
||||
return updates, count, nil
|
||||
}
|
||||
|
||||
// GetPackageHistory returns version history for a specific package
|
||||
func (q *UpdateQueries) GetPackageHistory(agentID uuid.UUID, packageType, packageName string, limit int) ([]models.UpdateHistory, error) {
|
||||
var history []models.UpdateHistory
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
id, agent_id, package_type, package_name, version_from, version_to,
|
||||
severity, repository_source, metadata, update_initiated_at,
|
||||
update_completed_at, update_status, failure_reason
|
||||
FROM update_version_history
|
||||
WHERE agent_id = $1 AND package_type = $2 AND package_name = $3
|
||||
ORDER BY update_completed_at DESC
|
||||
LIMIT $4
|
||||
`
|
||||
|
||||
err := q.db.Select(&history, query, agentID, packageType, packageName, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get package history: %w", err)
|
||||
}
|
||||
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// UpdatePackageStatus updates the status of a package and records history
|
||||
func (q *UpdateQueries) UpdatePackageStatus(agentID uuid.UUID, packageType, packageName, status string, metadata map[string]interface{}) error {
|
||||
tx, err := q.db.Beginx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Get current state
|
||||
var currentState models.UpdateState
|
||||
query := `SELECT * FROM current_package_state WHERE agent_id = $1 AND package_type = $2 AND package_name = $3`
|
||||
err = tx.Get(¤tState, query, agentID, packageType, packageName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current state: %w", err)
|
||||
}
|
||||
|
||||
// Update status
|
||||
updateQuery := `
|
||||
UPDATE current_package_state
|
||||
SET status = $1, last_updated_at = $2
|
||||
WHERE agent_id = $3 AND package_type = $4 AND package_name = $5
|
||||
`
|
||||
_, err = tx.Exec(updateQuery, status, time.Now(), agentID, packageType, packageName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update package status: %w", err)
|
||||
}
|
||||
|
||||
// Record in history if this is an update completion
|
||||
if status == "updated" || status == "failed" {
|
||||
historyQuery := `
|
||||
INSERT INTO update_version_history (
|
||||
agent_id, package_type, package_name, version_from, version_to,
|
||||
severity, repository_source, metadata, update_completed_at, update_status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
`
|
||||
_, err = tx.Exec(historyQuery,
|
||||
agentID, packageType, packageName, currentState.CurrentVersion,
|
||||
currentState.AvailableVersion, currentState.Severity,
|
||||
currentState.RepositorySource, metadata, time.Now(), status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record version history: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// CleanupOldEvents removes old events to prevent table bloat
|
||||
func (q *UpdateQueries) CleanupOldEvents(olderThan time.Duration) error {
|
||||
query := `DELETE FROM update_events WHERE created_at < $1`
|
||||
result, err := q.db.Exec(query, time.Now().Add(-olderThan))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cleanup old events: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
fmt.Printf("Cleaned up %d old update events\n", rowsAffected)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBatchStatus returns the status of recent batches
|
||||
func (q *UpdateQueries) GetBatchStatus(agentID uuid.UUID, limit int) ([]models.UpdateBatch, error) {
|
||||
var batches []models.UpdateBatch
|
||||
|
||||
query := `
|
||||
SELECT id, agent_id, batch_size, processed_count, failed_count,
|
||||
status, error_details, created_at, completed_at
|
||||
FROM update_batches
|
||||
WHERE agent_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
err := q.db.Select(&batches, query, agentID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get batch status: %w", err)
|
||||
}
|
||||
|
||||
return batches, nil
|
||||
}
|
||||
|
||||
// GetUpdateStatsFromState returns statistics about updates from current state
|
||||
func (q *UpdateQueries) GetUpdateStatsFromState(agentID uuid.UUID) (*models.UpdateStats, error) {
|
||||
stats := &models.UpdateStats{}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*) as total_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'updated') as updated_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') as failed_updates,
|
||||
COUNT(*) FILTER (WHERE severity = 'critical') as critical_updates,
|
||||
COUNT(*) FILTER (WHERE severity = 'important') as important_updates,
|
||||
COUNT(*) FILTER (WHERE severity = 'moderate') as moderate_updates,
|
||||
COUNT(*) FILTER (WHERE severity = 'low') as low_updates
|
||||
FROM current_package_state
|
||||
WHERE agent_id = $1
|
||||
`
|
||||
|
||||
err := q.db.Get(stats, query, agentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get update stats: %w", err)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetAllUpdateStats returns overall statistics about updates across all agents
|
||||
func (q *UpdateQueries) GetAllUpdateStats() (*models.UpdateStats, error) {
|
||||
stats := &models.UpdateStats{}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*) as total_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'approved') as approved_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'updated') as updated_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') as failed_updates,
|
||||
COUNT(*) FILTER (WHERE severity = 'critical') as critical_updates,
|
||||
COUNT(*) FILTER (WHERE severity = 'important') as high_updates,
|
||||
COUNT(*) FILTER (WHERE severity = 'moderate') as moderate_updates,
|
||||
COUNT(*) FILTER (WHERE severity = 'low') as low_updates
|
||||
FROM current_package_state
|
||||
`
|
||||
|
||||
err := q.db.Get(stats, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get all update stats: %w", err)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,22 @@ type Agent struct {
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// AgentWithLastScan extends Agent with last scan information
|
||||
type AgentWithLastScan struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
Hostname string `json:"hostname" db:"hostname"`
|
||||
OSType string `json:"os_type" db:"os_type"`
|
||||
OSVersion string `json:"os_version" db:"os_version"`
|
||||
OSArchitecture string `json:"os_architecture" db:"os_architecture"`
|
||||
AgentVersion string `json:"agent_version" db:"agent_version"`
|
||||
LastSeen time.Time `json:"last_seen" db:"last_seen"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Metadata JSONB `json:"metadata" db:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
LastScan *time.Time `json:"last_scan" db:"last_scan"`
|
||||
}
|
||||
|
||||
// AgentSpecs represents system specifications for an agent
|
||||
type AgentSpecs struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
@@ -56,6 +72,28 @@ type AgentRegistrationResponse struct {
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
// UTCTime is a time.Time that marshals to ISO format with UTC timezone
|
||||
type UTCTime time.Time
|
||||
|
||||
// MarshalJSON implements json.Marshaler for UTCTime
|
||||
func (t UTCTime) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(time.Time(t).UTC().Format("2006-01-02T15:04:05.000Z"))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler for UTCTime
|
||||
func (t *UTCTime) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := time.Parse("2006-01-02T15:04:05.000Z", s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*t = UTCTime(parsed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// JSONB type for PostgreSQL JSONB columns
|
||||
type JSONB map[string]interface{}
|
||||
|
||||
|
||||
@@ -79,10 +79,87 @@ type UpdateLogRequest struct {
|
||||
|
||||
// UpdateFilters for querying updates
|
||||
type UpdateFilters struct {
|
||||
AgentID *uuid.UUID
|
||||
AgentID uuid.UUID
|
||||
Status string
|
||||
Severity string
|
||||
PackageType string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// EVENT SOURCING MODELS
|
||||
|
||||
// UpdateEvent represents a single update event in the event sourcing system
|
||||
type UpdateEvent struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
||||
PackageType string `json:"package_type" db:"package_type"`
|
||||
PackageName string `json:"package_name" db:"package_name"`
|
||||
VersionFrom string `json:"version_from" db:"version_from"`
|
||||
VersionTo string `json:"version_to" db:"version_to"`
|
||||
Severity string `json:"severity" db:"severity"`
|
||||
RepositorySource string `json:"repository_source" db:"repository_source"`
|
||||
Metadata JSONB `json:"metadata" db:"metadata"`
|
||||
EventType string `json:"event_type" db:"event_type"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
|
||||
// UpdateState represents the current state of a package (denormalized for queries)
|
||||
type UpdateState struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
||||
PackageType string `json:"package_type" db:"package_type"`
|
||||
PackageName string `json:"package_name" db:"package_name"`
|
||||
CurrentVersion string `json:"current_version" db:"current_version"`
|
||||
AvailableVersion string `json:"available_version" db:"available_version"`
|
||||
Severity string `json:"severity" db:"severity"`
|
||||
RepositorySource string `json:"repository_source" db:"repository_source"`
|
||||
Metadata JSONB `json:"metadata" db:"metadata"`
|
||||
LastDiscoveredAt time.Time `json:"last_discovered_at" db:"last_discovered_at"`
|
||||
LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"`
|
||||
Status string `json:"status" db:"status"`
|
||||
}
|
||||
|
||||
// UpdateHistory represents the version history of a package
|
||||
type UpdateHistory struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
||||
PackageType string `json:"package_type" db:"package_type"`
|
||||
PackageName string `json:"package_name" db:"package_name"`
|
||||
VersionFrom string `json:"version_from" db:"version_from"`
|
||||
VersionTo string `json:"version_to" db:"version_to"`
|
||||
Severity string `json:"severity" db:"severity"`
|
||||
RepositorySource string `json:"repository_source" db:"repository_source"`
|
||||
Metadata JSONB `json:"metadata" db:"metadata"`
|
||||
UpdateInitiatedAt *time.Time `json:"update_initiated_at" db:"update_initiated_at"`
|
||||
UpdateCompletedAt time.Time `json:"update_completed_at" db:"update_completed_at"`
|
||||
UpdateStatus string `json:"update_status" db:"update_status"`
|
||||
FailureReason string `json:"failure_reason" db:"failure_reason"`
|
||||
}
|
||||
|
||||
// UpdateBatch represents a batch of update events
|
||||
type UpdateBatch struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
||||
BatchSize int `json:"batch_size" db:"batch_size"`
|
||||
ProcessedCount int `json:"processed_count" db:"processed_count"`
|
||||
FailedCount int `json:"failed_count" db:"failed_count"`
|
||||
Status string `json:"status" db:"status"`
|
||||
ErrorDetails JSONB `json:"error_details" db:"error_details"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
CompletedAt *time.Time `json:"completed_at" db:"completed_at"`
|
||||
}
|
||||
|
||||
// UpdateStats represents statistics about updates
|
||||
type UpdateStats struct {
|
||||
TotalUpdates int `json:"total_updates" db:"total_updates"`
|
||||
PendingUpdates int `json:"pending_updates" db:"pending_updates"`
|
||||
ApprovedUpdates int `json:"approved_updates" db:"approved_updates"`
|
||||
UpdatedUpdates int `json:"updated_updates" db:"updated_updates"`
|
||||
FailedUpdates int `json:"failed_updates" db:"failed_updates"`
|
||||
CriticalUpdates int `json:"critical_updates" db:"critical_updates"`
|
||||
HighUpdates int `json:"high_updates" db:"high_updates"`
|
||||
ImportantUpdates int `json:"important_updates" db:"important_updates"`
|
||||
ModerateUpdates int `json:"moderate_updates" db:"moderate_updates"`
|
||||
LowUpdates int `json:"low_updates" db:"low_updates"`
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useSettingsStore } from '@/lib/store';
|
||||
import { useAuthStore, useUIStore } from '@/lib/store';
|
||||
import Layout from '@/components/Layout';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import Agents from '@/pages/Agents';
|
||||
import Updates from '@/pages/Updates';
|
||||
import Docker from '@/pages/Docker';
|
||||
import Logs from '@/pages/Logs';
|
||||
import Settings from '@/pages/Settings';
|
||||
import Login from '@/pages/Login';
|
||||
import NotificationCenter from '@/components/NotificationCenter';
|
||||
|
||||
// Protected route component
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
@@ -25,7 +24,7 @@ const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { isAuthenticated, token } = useAuthStore();
|
||||
const { theme } = useSettingsStore();
|
||||
const { theme } = useUIStore();
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
@@ -72,8 +71,6 @@ const App: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Notification center */}
|
||||
{isAuthenticated && <NotificationCenter />}
|
||||
|
||||
{/* App routes */}
|
||||
<Routes>
|
||||
@@ -96,6 +93,7 @@ const App: React.FC = () => {
|
||||
<Route path="/agents/:id" element={<Agents />} />
|
||||
<Route path="/updates" element={<Updates />} />
|
||||
<Route path="/updates/:id" element={<Updates />} />
|
||||
<Route path="/docker" element={<Docker />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
238
aggregator-web/src/components/AgentUpdates.tsx
Normal file
238
aggregator-web/src/components/AgentUpdates.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Search, Filter, Package, Clock, AlertTriangle } from 'lucide-react';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import { updateApi } from '@/lib/api';
|
||||
import type { UpdatePackage } from '@/types';
|
||||
|
||||
interface AgentUpdatesProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface AgentUpdateResponse {
|
||||
updates: UpdatePackage[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function AgentSystemUpdates({ agentId }: AgentUpdatesProps) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { data: updateData, isLoading, error } = useQuery<AgentUpdateResponse>({
|
||||
queryKey: ['agent-updates', agentId, currentPage, pageSize, searchTerm],
|
||||
queryFn: async () => {
|
||||
const params = {
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
agent: agentId,
|
||||
type: 'system', // Only show system updates in AgentUpdates
|
||||
...(searchTerm && { search: searchTerm }),
|
||||
};
|
||||
|
||||
const response = await updateApi.getUpdates(params);
|
||||
return response;
|
||||
},
|
||||
});
|
||||
|
||||
const updates = updateData?.updates || [];
|
||||
const totalCount = updateData?.total || 0;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity.toLowerCase()) {
|
||||
case 'critical': return 'text-red-600 bg-red-50';
|
||||
case 'important':
|
||||
case 'high': return 'text-orange-600 bg-orange-50';
|
||||
case 'moderate':
|
||||
case 'medium': return 'text-yellow-600 bg-yellow-50';
|
||||
case 'low':
|
||||
case 'none': return 'text-blue-600 bg-blue-50';
|
||||
default: return 'text-gray-600 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
const getPackageTypeIcon = (packageType: string) => {
|
||||
switch (packageType.toLowerCase()) {
|
||||
case 'system': return '📦';
|
||||
default: return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-4 bg-gray-200 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="text-red-600 text-sm">Error loading updates: {(error as Error).message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">System Updates</h2>
|
||||
<div className="text-sm text-gray-500">
|
||||
{totalCount} update{totalCount !== 1 ? 's' : ''} available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="p-4 border-b border-gray-200 bg-gray-50">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search packages..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page Size */}
|
||||
<div className="sm:w-32">
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Updates List */}
|
||||
<div className="divide-y divide-gray-200">
|
||||
{updates.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<Package className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No updates found</p>
|
||||
<p className="text-sm mt-2">This agent is up to date!</p>
|
||||
</div>
|
||||
) : (
|
||||
updates.map((update) => (
|
||||
<div key={update.id} className="p-4 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">{getPackageTypeIcon(update.package_type)}</span>
|
||||
<h3 className="text-sm font-medium text-gray-900 truncate">
|
||||
{update.package_name}
|
||||
</h3>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${getSeverityColor(update.severity)}`}>
|
||||
{update.severity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 mb-2">
|
||||
<span>Type: {update.package_type}</span>
|
||||
{update.repository_source && (
|
||||
<span>Source: {update.repository_source}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatRelativeTime(update.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-gray-600">From:</span>
|
||||
<span className="font-mono bg-gray-100 px-1 py-0.5 rounded">
|
||||
{update.current_version || 'N/A'}
|
||||
</span>
|
||||
<span className="text-gray-600">→</span>
|
||||
<span className="font-mono bg-green-50 text-green-700 px-1 py-0.5 rounded">
|
||||
{update.available_version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
className="text-green-600 hover:text-green-800 text-sm font-medium"
|
||||
onClick={() => {
|
||||
// TODO: Implement install single update functionality
|
||||
console.log('Install update:', update.id);
|
||||
}}
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
onClick={() => {
|
||||
// TODO: Implement view logs functionality
|
||||
console.log('View logs for update:', update.id);
|
||||
}}
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalCount)} of {totalCount} results
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-3 py-1 text-sm text-gray-700">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
Bell,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Container,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
import { useUIStore, useAuthStore, useRealtimeStore } from '@/lib/store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -25,8 +26,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
const { sidebarOpen, setSidebarOpen, setActiveTab } = useUIStore();
|
||||
const { logout } = useAuthStore();
|
||||
const { notifications } = useRealtimeStore();
|
||||
const { notifications, markNotificationRead, clearNotifications } = useRealtimeStore();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isNotificationDropdownOpen, setIsNotificationDropdownOpen] = useState(false);
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
|
||||
@@ -49,6 +51,12 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
icon: Package,
|
||||
current: location.pathname.startsWith('/updates'),
|
||||
},
|
||||
{
|
||||
name: 'Docker',
|
||||
href: '/docker',
|
||||
icon: Container,
|
||||
current: location.pathname.startsWith('/docker'),
|
||||
},
|
||||
{
|
||||
name: 'Logs',
|
||||
href: '/logs',
|
||||
@@ -78,6 +86,33 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Notification helper functions
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return '✅';
|
||||
case 'error':
|
||||
return '❌';
|
||||
case 'warning':
|
||||
return '⚠️';
|
||||
default:
|
||||
return 'ℹ️';
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'border-green-200 bg-green-50';
|
||||
case 'error':
|
||||
return 'border-red-200 bg-red-50';
|
||||
case 'warning':
|
||||
return 'border-yellow-200 bg-yellow-50';
|
||||
default:
|
||||
return 'border-blue-200 bg-blue-50';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
{/* Sidebar */}
|
||||
@@ -148,7 +183,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
{/* Top header */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="flex items-center justify-between h-16 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="lg:hidden text-gray-500 hover:text-gray-700"
|
||||
@@ -157,7 +192,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
</button>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="hidden md:block">
|
||||
<form onSubmit={handleSearch} className="hidden md:block flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
@@ -165,29 +200,113 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search updates..."
|
||||
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Header actions - right to left order */}
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100"
|
||||
title="Refresh"
|
||||
className="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
title="Refresh page"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Notifications */}
|
||||
<button className="relative text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100">
|
||||
<Bell className="w-5 h-5" />
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsNotificationDropdownOpen(!isNotificationDropdownOpen)}
|
||||
className="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100 transition-colors relative"
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-600 text-white text-xs rounded-full flex items-center justify-center font-medium">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Notifications dropdown */}
|
||||
{isNotificationDropdownOpen && (
|
||||
<div className="absolute top-12 right-0 w-96 bg-white rounded-lg shadow-lg border border-gray-200 max-h-96 overflow-hidden z-50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900">Notifications</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={clearNotifications}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsNotificationDropdownOpen(false)}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications list */}
|
||||
<div className="overflow-y-auto max-h-80">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<Bell className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||||
<p>No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
'p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors',
|
||||
!notification.read && 'bg-blue-50 border-l-4 border-l-blue-500',
|
||||
getNotificationColor(notification.type)
|
||||
)}
|
||||
onClick={() => {
|
||||
markNotificationRead(notification.id);
|
||||
setIsNotificationDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 mt-0.5 text-lg">
|
||||
{getNotificationIcon(notification.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{notification.title}
|
||||
</p>
|
||||
{!notification.read && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
{formatRelativeTime(notification.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Bell, X, Check, Info, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { Bell, X, Info, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { useRealtimeStore } from '@/lib/store';
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
@@ -36,7 +36,7 @@ const NotificationCenter: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
<div className="fixed top-4 right-4 z-40">
|
||||
{/* Notification bell */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
|
||||
@@ -1,99 +1,39 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { agentApi } from '@/lib/api';
|
||||
import { Agent, ListQueryParams } from '@/types';
|
||||
import { useAgentStore, useRealtimeStore } from '@/lib/store';
|
||||
import { handleApiError } from '@/lib/api';
|
||||
|
||||
export const useAgents = (params?: ListQueryParams) => {
|
||||
const { setAgents, setLoading, setError, updateAgentStatus } = useAgentStore();
|
||||
import type { Agent, ListQueryParams, AgentListResponse, ScanRequest } from '@/types';
|
||||
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
export const useAgents = (params?: ListQueryParams): UseQueryResult<AgentListResponse, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['agents', params],
|
||||
queryFn: () => agentApi.getAgents(params),
|
||||
onSuccess: (data) => {
|
||||
setAgents(data.agents);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(handleApiError(error).message);
|
||||
setLoading(false);
|
||||
},
|
||||
onSettled: () => {
|
||||
setLoading(false);
|
||||
},
|
||||
staleTime: 30 * 1000, // Consider data stale after 30 seconds
|
||||
refetchInterval: 60 * 1000, // Auto-refetch every minute
|
||||
});
|
||||
};
|
||||
|
||||
export const useAgent = (id: string, enabled: boolean = true) => {
|
||||
const { setSelectedAgent, setLoading, setError } = useAgentStore();
|
||||
|
||||
export const useAgent = (id: string, enabled: boolean = true): UseQueryResult<Agent, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['agent', id],
|
||||
queryFn: () => agentApi.getAgent(id),
|
||||
enabled: enabled && !!id,
|
||||
onSuccess: (data) => {
|
||||
setSelectedAgent(data);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(handleApiError(error).message);
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useScanAgent = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { addNotification } = useRealtimeStore();
|
||||
|
||||
export const useScanAgent = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
return useMutation({
|
||||
mutationFn: agentApi.scanAgent,
|
||||
onSuccess: () => {
|
||||
// Invalidate agents query to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
|
||||
// Show success notification
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Scan Triggered',
|
||||
message: 'Agent scan has been triggered successfully.',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Scan Failed',
|
||||
message: handleApiError(error).message,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useScanMultipleAgents = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { addNotification } = useRealtimeStore();
|
||||
|
||||
export const useScanMultipleAgents = (): UseMutationResult<void, Error, ScanRequest, unknown> => {
|
||||
return useMutation({
|
||||
mutationFn: agentApi.triggerScan,
|
||||
onSuccess: () => {
|
||||
// Invalidate agents query to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
|
||||
// Show success notification
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Bulk Scan Triggered',
|
||||
message: 'Scan has been triggered for selected agents.',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Bulk Scan Failed',
|
||||
message: handleApiError(error).message,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUnregisterAgent = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
return useMutation({
|
||||
mutationFn: agentApi.unregisterAgent,
|
||||
});
|
||||
};
|
||||
170
aggregator-web/src/hooks/useDocker.ts
Normal file
170
aggregator-web/src/hooks/useDocker.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { dockerApi } from '@/lib/api';
|
||||
import type { DockerContainer, DockerImage } from '@/types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Hook for fetching all Docker containers/images across all agents
|
||||
export const useDockerContainers = (params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
agent?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: ['docker-containers', params],
|
||||
queryFn: async () => {
|
||||
const response = await dockerApi.getContainers(params || {});
|
||||
return response;
|
||||
},
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for fetching Docker containers for a specific agent
|
||||
export const useAgentDockerContainers = (agentId: string, params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: ['agent-docker-containers', agentId, params],
|
||||
queryFn: async () => {
|
||||
const response = await dockerApi.getAgentContainers(agentId, params || {});
|
||||
return response;
|
||||
},
|
||||
staleTime: 30000,
|
||||
enabled: !!agentId,
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for Docker statistics
|
||||
export const useDockerStats = () => {
|
||||
return useQuery({
|
||||
queryKey: ['docker-stats'],
|
||||
queryFn: async () => {
|
||||
const response = await dockerApi.getStats();
|
||||
return response;
|
||||
},
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for approving Docker updates
|
||||
export const useApproveDockerUpdate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ containerId, imageId }: {
|
||||
containerId: string;
|
||||
imageId: string;
|
||||
}) => {
|
||||
const response = await dockerApi.approveUpdate(containerId, imageId);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Docker update approved successfully');
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to approve Docker update');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for rejecting Docker updates
|
||||
export const useRejectDockerUpdate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ containerId, imageId }: {
|
||||
containerId: string;
|
||||
imageId: string;
|
||||
}) => {
|
||||
const response = await dockerApi.rejectUpdate(containerId, imageId);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Docker update rejected');
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to reject Docker update');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for installing Docker updates
|
||||
export const useInstallDockerUpdate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ containerId, imageId }: {
|
||||
containerId: string;
|
||||
imageId: string;
|
||||
}) => {
|
||||
const response = await dockerApi.installUpdate(containerId, imageId);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Docker update installation started');
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to install Docker update');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for bulk Docker operations
|
||||
export const useBulkDockerActions = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const approveMultiple = useMutation({
|
||||
mutationFn: async ({ updates }: {
|
||||
updates: Array<{ containerId: string; imageId: string }>;
|
||||
}) => {
|
||||
const response = await dockerApi.bulkApproveUpdates(updates);
|
||||
return response;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${data.approved} Docker updates approved`);
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to approve Docker updates');
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMultiple = useMutation({
|
||||
mutationFn: async ({ updates }: {
|
||||
updates: Array<{ containerId: string; imageId: string }>;
|
||||
}) => {
|
||||
const response = await dockerApi.bulkRejectUpdates(updates);
|
||||
return response;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${data.rejected} Docker updates rejected`);
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to reject Docker updates');
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
approveMultiple,
|
||||
rejectMultiple,
|
||||
};
|
||||
};
|
||||
46
aggregator-web/src/hooks/useSettings.ts
Normal file
46
aggregator-web/src/hooks/useSettings.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
|
||||
export interface TimezoneOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface TimezoneSettings {
|
||||
timezone: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export function useTimezones() {
|
||||
return useQuery({
|
||||
queryKey: ['timezones'],
|
||||
queryFn: async (): Promise<TimezoneOption[]> => {
|
||||
const { data } = await api.get('/settings/timezones')
|
||||
return data.timezones
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTimezone() {
|
||||
return useQuery({
|
||||
queryKey: ['timezone'],
|
||||
queryFn: async (): Promise<TimezoneSettings> => {
|
||||
const { data } = await api.get('/settings/timezone')
|
||||
return data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateTimezone() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (timezone: string): Promise<TimezoneSettings> => {
|
||||
const { data } = await api.put('/settings/timezone', { timezone })
|
||||
return data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timezone'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { statsApi } from '@/lib/api';
|
||||
import { DashboardStats } from '@/types';
|
||||
import { handleApiError } from '@/lib/api';
|
||||
import type { DashboardStats } from '@/types';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
export const useDashboardStats = () => {
|
||||
export const useDashboardStats = (): UseQueryResult<DashboardStats, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['dashboard-stats'],
|
||||
queryFn: statsApi.getDashboardStats,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
staleTime: 15000, // Consider data stale after 15 seconds
|
||||
onError: (error) => {
|
||||
console.error('Failed to fetch dashboard stats:', handleApiError(error));
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,173 +1,44 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { updateApi } from '@/lib/api';
|
||||
import { UpdatePackage, ListQueryParams, UpdateApprovalRequest } from '@/types';
|
||||
import { useUpdateStore, useRealtimeStore } from '@/lib/store';
|
||||
import { handleApiError } from '@/lib/api';
|
||||
|
||||
export const useUpdates = (params?: ListQueryParams) => {
|
||||
const { setUpdates, setLoading, setError } = useUpdateStore();
|
||||
import type { UpdatePackage, ListQueryParams, UpdateApprovalRequest, UpdateListResponse } from '@/types';
|
||||
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
export const useUpdates = (params?: ListQueryParams): UseQueryResult<UpdateListResponse, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['updates', params],
|
||||
queryFn: () => updateApi.getUpdates(params),
|
||||
onSuccess: (data) => {
|
||||
setUpdates(data.updates);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(handleApiError(error).message);
|
||||
setLoading(false);
|
||||
},
|
||||
onSettled: () => {
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdate = (id: string, enabled: boolean = true) => {
|
||||
const { setSelectedUpdate, setLoading, setError } = useUpdateStore();
|
||||
|
||||
export const useUpdate = (id: string, enabled: boolean = true): UseQueryResult<UpdatePackage, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['update', id],
|
||||
queryFn: () => updateApi.getUpdate(id),
|
||||
enabled: enabled && !!id,
|
||||
onSuccess: (data) => {
|
||||
setSelectedUpdate(data);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(handleApiError(error).message);
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useApproveUpdate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { updateUpdateStatus } = useUpdateStore();
|
||||
const { addNotification } = useRealtimeStore();
|
||||
|
||||
export const useApproveUpdate = (): UseMutationResult<void, Error, { id: string; scheduledAt?: string; }, unknown> => {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, scheduledAt }: { id: string; scheduledAt?: string }) =>
|
||||
updateApi.approveUpdate(id, scheduledAt),
|
||||
onSuccess: (_, { id }) => {
|
||||
// Update local state
|
||||
updateUpdateStatus(id, 'approved');
|
||||
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['update', id] });
|
||||
|
||||
// Show success notification
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Update Approved',
|
||||
message: 'The update has been approved successfully.',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Approval Failed',
|
||||
message: handleApiError(error).message,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useApproveMultipleUpdates = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { bulkUpdateStatus } = useUpdateStore();
|
||||
const { addNotification } = useRealtimeStore();
|
||||
|
||||
export const useApproveMultipleUpdates = (): UseMutationResult<void, Error, UpdateApprovalRequest, unknown> => {
|
||||
return useMutation({
|
||||
mutationFn: (request: UpdateApprovalRequest) => updateApi.approveUpdates(request),
|
||||
onSuccess: (_, request) => {
|
||||
// Update local state
|
||||
bulkUpdateStatus(request.update_ids, 'approved');
|
||||
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
|
||||
// Show success notification
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Updates Approved',
|
||||
message: `${request.update_ids.length} update(s) have been approved successfully.`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Bulk Approval Failed',
|
||||
message: handleApiError(error).message,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRejectUpdate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { updateUpdateStatus } = useUpdateStore();
|
||||
const { addNotification } = useRealtimeStore();
|
||||
|
||||
export const useRejectUpdate = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
return useMutation({
|
||||
mutationFn: updateApi.rejectUpdate,
|
||||
onSuccess: (_, id) => {
|
||||
// Update local state
|
||||
updateUpdateStatus(id, 'pending');
|
||||
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['update', id] });
|
||||
|
||||
// Show success notification
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Update Rejected',
|
||||
message: 'The update has been rejected and moved back to pending status.',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Rejection Failed',
|
||||
message: handleApiError(error).message,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useInstallUpdate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { updateUpdateStatus } = useUpdateStore();
|
||||
const { addNotification } = useRealtimeStore();
|
||||
|
||||
export const useInstallUpdate = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
return useMutation({
|
||||
mutationFn: updateApi.installUpdate,
|
||||
onSuccess: (_, id) => {
|
||||
// Update local state
|
||||
updateUpdateStatus(id, 'installing');
|
||||
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['update', id] });
|
||||
|
||||
// Show success notification
|
||||
addNotification({
|
||||
type: 'info',
|
||||
title: 'Installation Started',
|
||||
message: 'The update installation has been started. This may take a few minutes.',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Installation Failed',
|
||||
message: handleApiError(error).message,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
@apply border-gray-200;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -8,13 +8,21 @@ import {
|
||||
UpdateApprovalRequest,
|
||||
ScanRequest,
|
||||
ListQueryParams,
|
||||
ApiResponse,
|
||||
ApiError
|
||||
ApiError,
|
||||
DockerContainer,
|
||||
DockerImage,
|
||||
DockerContainerListResponse,
|
||||
DockerStats,
|
||||
DockerUpdateRequest,
|
||||
BulkDockerUpdateRequest
|
||||
} from '@/types';
|
||||
|
||||
// Base URL for API
|
||||
export const API_BASE_URL = (import.meta.env?.VITE_API_URL as string) || '/api/v1';
|
||||
|
||||
// Create axios instance
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api/v1',
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -66,6 +74,11 @@ export const agentApi = {
|
||||
scanAgent: async (id: string): Promise<void> => {
|
||||
await api.post(`/agents/${id}/scan`);
|
||||
},
|
||||
|
||||
// Unregister/remove agent
|
||||
unregisterAgent: async (id: string): Promise<void> => {
|
||||
await api.delete(`/agents/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const updateApi = {
|
||||
@@ -178,7 +191,7 @@ export const handleApiError = (error: any): ApiError => {
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
if (status && status >= 500) {
|
||||
return {
|
||||
message: 'Server error. Please try again later.',
|
||||
code: 'SERVER_ERROR',
|
||||
@@ -198,4 +211,75 @@ export const handleApiError = (error: any): ApiError => {
|
||||
};
|
||||
};
|
||||
|
||||
// Docker-specific API endpoints
|
||||
export const dockerApi = {
|
||||
// Get all Docker containers and images across all agents
|
||||
getContainers: async (params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
agent?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}): Promise<DockerContainerListResponse> => {
|
||||
const response = await api.get('/docker/containers', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get Docker containers for a specific agent
|
||||
getAgentContainers: async (agentId: string, params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}): Promise<DockerContainerListResponse> => {
|
||||
const response = await api.get(`/agents/${agentId}/docker`, { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get Docker statistics
|
||||
getStats: async (): Promise<DockerStats> => {
|
||||
const response = await api.get('/docker/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Approve Docker image update
|
||||
approveUpdate: async (containerId: string, imageId: string, scheduledAt?: string): Promise<void> => {
|
||||
await api.post(`/docker/containers/${containerId}/images/${imageId}/approve`, {
|
||||
scheduled_at: scheduledAt,
|
||||
});
|
||||
},
|
||||
|
||||
// Reject Docker image update
|
||||
rejectUpdate: async (containerId: string, imageId: string): Promise<void> => {
|
||||
await api.post(`/docker/containers/${containerId}/images/${imageId}/reject`);
|
||||
},
|
||||
|
||||
// Install Docker image update
|
||||
installUpdate: async (containerId: string, imageId: string): Promise<void> => {
|
||||
await api.post(`/docker/containers/${containerId}/images/${imageId}/install`);
|
||||
},
|
||||
|
||||
// Bulk approve Docker updates
|
||||
bulkApproveUpdates: async (updates: Array<{ containerId: string; imageId: string }>, scheduledAt?: string): Promise<{ approved: number }> => {
|
||||
const response = await api.post('/docker/updates/bulk-approve', {
|
||||
updates,
|
||||
scheduled_at: scheduledAt,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Bulk reject Docker updates
|
||||
bulkRejectUpdates: async (updates: Array<{ containerId: string; imageId: string }>): Promise<{ rejected: number }> => {
|
||||
const response = await api.post('/docker/updates/bulk-reject', {
|
||||
updates,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Trigger Docker scan on agents
|
||||
triggerScan: async (agentIds?: string[]): Promise<void> => {
|
||||
await api.post('/docker/scan', { agent_ids: agentIds });
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -173,7 +173,7 @@ interface RealtimeState {
|
||||
}>;
|
||||
setConnected: (connected: boolean) => void;
|
||||
setLastUpdate: (timestamp: string) => void;
|
||||
addNotification: (notification: Omit<typeof RealtimeState.prototype.notifications[0], 'id' | 'timestamp' | 'read'>) => void;
|
||||
addNotification: (notification: Omit<RealtimeState['notifications'][0], 'id' | 'timestamp' | 'read'>) => void;
|
||||
markNotificationRead: (id: string) => void;
|
||||
clearNotifications: () => void;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,32 @@ export const formatDate = (dateString: string): string => {
|
||||
};
|
||||
|
||||
export const formatRelativeTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
if (!dateString) return 'Never';
|
||||
|
||||
let date: Date;
|
||||
try {
|
||||
// Handle various timestamp formats
|
||||
if (dateString.includes('T') && dateString.includes('Z')) {
|
||||
// ISO 8601 format
|
||||
date = new Date(dateString);
|
||||
} else if (dateString.includes(' ')) {
|
||||
// Database format like "2025-01-15 10:30:00"
|
||||
date = new Date(dateString.replace(' ', 'T') + 'Z');
|
||||
} else {
|
||||
// Try direct parsing
|
||||
date = new Date(dateString);
|
||||
}
|
||||
|
||||
// Check if date is invalid
|
||||
if (isNaN(date.getTime())) {
|
||||
console.warn('Invalid date string:', dateString);
|
||||
return 'Invalid Date';
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error parsing date:', dateString, error);
|
||||
return 'Invalid Date';
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
@@ -35,7 +60,7 @@ export const formatRelativeTime = (dateString: string): string => {
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return formatDate(dateString);
|
||||
return formatDate(date.toISOString());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,7 +69,7 @@ export const isOnline = (lastCheckin: string): boolean => {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - lastCheck.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
return diffMins < 10; // Consider online if checked in within 10 minutes
|
||||
return diffMins < 15; // Consider online if checked in within 15 minutes (allows for 5min check-in + buffer)
|
||||
};
|
||||
|
||||
// Size formatting utilities
|
||||
@@ -103,11 +128,14 @@ export const getSeverityColor = (severity: string): string => {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return 'text-danger-600 bg-danger-100';
|
||||
case 'important':
|
||||
case 'high':
|
||||
return 'text-warning-600 bg-warning-100';
|
||||
case 'moderate':
|
||||
case 'medium':
|
||||
return 'text-blue-600 bg-blue-100';
|
||||
case 'low':
|
||||
case 'none':
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
@@ -194,7 +222,7 @@ export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -10,7 +10,7 @@ const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
staleTime: 10 * 1000, // 10 seconds
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,21 +5,22 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
Activity,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
Globe,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Package,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
GitBranch,
|
||||
Clock,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents } from '@/hooks/useAgents';
|
||||
import { Agent } from '@/types';
|
||||
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
||||
import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import { AgentSystemUpdates } from '@/components/AgentUpdates';
|
||||
|
||||
const Agents: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
@@ -30,8 +31,81 @@ const Agents: React.FC = () => {
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
|
||||
|
||||
// Helper function to get system metadata from agent
|
||||
const getSystemMetadata = (agent: any) => {
|
||||
const metadata = agent.metadata || {};
|
||||
|
||||
return {
|
||||
cpuModel: metadata.cpu_model || 'Unknown',
|
||||
cpuCores: metadata.cpu_cores || 'Unknown',
|
||||
memoryTotal: metadata.memory_total ? parseInt(metadata.memory_total) : 0,
|
||||
diskMount: metadata.disk_mount || 'Unknown',
|
||||
diskTotal: metadata.disk_total ? parseInt(metadata.disk_total) : 0,
|
||||
diskUsed: metadata.disk_used ? parseInt(metadata.disk_used) : 0,
|
||||
processes: metadata.processes || 'Unknown',
|
||||
uptime: metadata.uptime || 'Unknown',
|
||||
installationTime: metadata.installation_time || 'Unknown',
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to parse OS information
|
||||
const parseOSInfo = (agent: any) => {
|
||||
const osType = agent.os_type || '';
|
||||
const osVersion = agent.os_version || '';
|
||||
|
||||
// Extract platform and distribution
|
||||
let platform = osType;
|
||||
let distribution = '';
|
||||
let version = osVersion;
|
||||
|
||||
// Handle Linux distributions
|
||||
if (osType.toLowerCase().includes('linux')) {
|
||||
platform = 'Linux';
|
||||
// Try to extract distribution from version string
|
||||
if (osVersion.toLowerCase().includes('ubuntu')) {
|
||||
distribution = 'Ubuntu';
|
||||
version = osVersion.replace(/ubuntu/i, '').trim();
|
||||
} else if (osVersion.toLowerCase().includes('fedora')) {
|
||||
distribution = 'Fedora';
|
||||
version = osVersion.replace(/fedora/i, '').trim();
|
||||
} else if (osVersion.toLowerCase().includes('debian')) {
|
||||
distribution = 'Debian';
|
||||
version = osVersion.replace(/debian/i, '').trim();
|
||||
} else if (osVersion.toLowerCase().includes('centos')) {
|
||||
distribution = 'CentOS';
|
||||
version = osVersion.replace(/centos/i, '').trim();
|
||||
} else if (osVersion.toLowerCase().includes('proxmox')) {
|
||||
distribution = 'Proxmox';
|
||||
version = osVersion.replace(/proxmox/i, '').trim();
|
||||
} else if (osVersion.toLowerCase().includes('arch')) {
|
||||
distribution = 'Arch Linux';
|
||||
version = osVersion.replace(/arch/i, '').trim();
|
||||
} else {
|
||||
// Try to get first word as distribution
|
||||
const words = osVersion.split(' ');
|
||||
distribution = words[0] || 'Unknown Distribution';
|
||||
version = words.slice(1).join(' ');
|
||||
}
|
||||
} else if (osType.toLowerCase().includes('windows')) {
|
||||
platform = 'Windows';
|
||||
distribution = osVersion; // Windows version info is all in one field
|
||||
version = '';
|
||||
} else if (osType.toLowerCase().includes('darwin') || osType.toLowerCase().includes('macos')) {
|
||||
platform = 'macOS';
|
||||
distribution = 'macOS';
|
||||
version = osVersion;
|
||||
}
|
||||
|
||||
// Truncate long version strings
|
||||
if (version.length > 30) {
|
||||
version = version.substring(0, 30) + '...';
|
||||
}
|
||||
|
||||
return { platform, distribution, version: version.trim() };
|
||||
};
|
||||
|
||||
// Fetch agents list
|
||||
const { data: agentsData, isLoading, error } = useAgents({
|
||||
const { data: agentsData, isPending, error } = useAgents({
|
||||
search: searchQuery || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
});
|
||||
@@ -41,6 +115,7 @@ const Agents: React.FC = () => {
|
||||
|
||||
const scanAgentMutation = useScanAgent();
|
||||
const scanMultipleMutation = useScanMultipleAgents();
|
||||
const unregisterAgentMutation = useUnregisterAgent();
|
||||
|
||||
const agents = agentsData?.agents || [];
|
||||
const selectedAgent = selectedAgentData || agents.find(a => a.id === id);
|
||||
@@ -93,6 +168,27 @@ const Agents: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle agent removal
|
||||
const handleRemoveAgent = async (agentId: string, hostname: string) => {
|
||||
if (!window.confirm(
|
||||
`Are you sure you want to remove agent "${hostname}"? This action cannot be undone and will remove the agent from the system.`
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await unregisterAgentMutation.mutateAsync(agentId);
|
||||
toast.success(`Agent "${hostname}" removed successfully`);
|
||||
|
||||
// Navigate back to agents list if we're on the agent detail page
|
||||
if (id && id === agentId) {
|
||||
navigate('/agents');
|
||||
}
|
||||
} catch (error) {
|
||||
// Error handling is done in the hook
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique OS types for filter
|
||||
const osTypes = [...new Set(agents.map(agent => agent.os_type))];
|
||||
|
||||
@@ -109,19 +205,19 @@ const Agents: React.FC = () => {
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{selectedAgent.hostname}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Agent details and system information
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
System details and update management for this agent
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleScanAgent(selectedAgent.id)}
|
||||
disabled={scanAgentMutation.isLoading}
|
||||
disabled={scanAgentMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{scanAgentMutation.isLoading ? (
|
||||
{scanAgentMutation.isPending ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
@@ -134,30 +230,76 @@ const Agents: React.FC = () => {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Agent info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Status card */}
|
||||
{/* Agent Status Card */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">Status</h2>
|
||||
<span className={cn('badge', getStatusColor(selectedAgent.status))}>
|
||||
{selectedAgent.status}
|
||||
<h2 className="text-lg font-medium text-gray-900">Agent Status</h2>
|
||||
<span className={cn('badge', getStatusColor(isOnline(selectedAgent.last_seen) ? 'online' : 'offline'))}>
|
||||
{isOnline(selectedAgent.last_seen) ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Agent Information */}
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Agent Information</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Agent ID</p>
|
||||
<p className="text-xs font-mono text-gray-700 break-all">
|
||||
{selectedAgent.id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Version</p>
|
||||
<p className="text-xs font-medium text-gray-900">
|
||||
{selectedAgent.agent_version || selectedAgent.version || 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Registered</p>
|
||||
<p className="text-xs font-medium text-gray-900">
|
||||
{formatRelativeTime(selectedAgent.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const meta = getSystemMetadata(selectedAgent);
|
||||
if (meta.installationTime !== 'Unknown') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Installation Time</p>
|
||||
<p className="text-xs font-medium text-gray-900">
|
||||
{formatRelativeTime(meta.installationTime)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<span>Last Check-in</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatRelativeTime(selectedAgent.last_checkin)}
|
||||
{formatRelativeTime(selectedAgent.last_seen)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Last Scan:</span>
|
||||
<span>Last Scan</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.last_scan
|
||||
@@ -167,52 +309,141 @@ const Agents: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System info */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">System Information</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic System Info */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Operating System</p>
|
||||
<p className="text-sm text-gray-600">Platform</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.os_type} {selectedAgent.os_version}
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(selectedAgent);
|
||||
return osInfo.platform;
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Distribution</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(selectedAgent);
|
||||
return osInfo.distribution;
|
||||
})()}
|
||||
</p>
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(selectedAgent);
|
||||
if (osInfo.version) {
|
||||
return (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Version: {osInfo.version}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Architecture</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.architecture}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">IP Address</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.ip_address}
|
||||
{selectedAgent.os_architecture || selectedAgent.architecture}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hardware Specs */}
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const meta = getSystemMetadata(selectedAgent);
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Agent Version</p>
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<Cpu className="h-4 w-4 mr-1" />
|
||||
CPU
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.version}
|
||||
{meta.cpuModel}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{meta.cpuCores} cores
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{meta.memoryTotal > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Registered</p>
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<MemoryStick className="h-4 w-4 mr-1" />
|
||||
Memory
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatRelativeTime(selectedAgent.created_at)}
|
||||
{formatBytes(meta.memoryTotal)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.diskTotal > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<HardDrive className="h-4 w-4 mr-1" />
|
||||
Disk ({meta.diskMount})
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatBytes(meta.diskUsed)} / {formatBytes(meta.diskTotal)}
|
||||
</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${Math.round((meta.diskUsed / meta.diskTotal) * 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{Math.round((meta.diskUsed / meta.diskTotal) * 100)}% used
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.processes !== 'Unknown' && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<GitBranch className="h-4 w-4 mr-1" />
|
||||
Running Processes
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{meta.processes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.uptime !== 'Unknown' && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
Uptime
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{meta.uptime}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* System Updates */}
|
||||
<AgentSystemUpdates agentId={selectedAgent.id} />
|
||||
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
@@ -221,25 +452,25 @@ const Agents: React.FC = () => {
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => handleScanAgent(selectedAgent.id)}
|
||||
disabled={scanAgentMutation.isLoading}
|
||||
className="w-full btn btn-primary"
|
||||
>
|
||||
{scanAgentMutation.isLoading ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Trigger Scan
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/updates?agent=${selectedAgent.id}`)}
|
||||
className="w-full btn btn-secondary"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
View Updates
|
||||
View All Updates
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleRemoveAgent(selectedAgent.id, selectedAgent.hostname)}
|
||||
disabled={unregisterAgentMutation.isPending}
|
||||
className="w-full btn btn-danger"
|
||||
>
|
||||
{unregisterAgentMutation.isPending ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Remove Agent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,10 +529,10 @@ const Agents: React.FC = () => {
|
||||
{selectedAgents.length > 0 && (
|
||||
<button
|
||||
onClick={handleScanSelected}
|
||||
disabled={scanMultipleMutation.isLoading}
|
||||
disabled={scanMultipleMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{scanMultipleMutation.isLoading ? (
|
||||
{scanMultipleMutation.isPending ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
@@ -351,7 +582,7 @@ const Agents: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Agents table */}
|
||||
{isLoading ? (
|
||||
{isPending ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
@@ -401,7 +632,7 @@ const Agents: React.FC = () => {
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredAgents.map((agent) => (
|
||||
<tr key={agent.id} className="hover:bg-gray-50">
|
||||
<tr key={agent.id} className="hover:bg-gray-50 group">
|
||||
<td className="table-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -425,30 +656,46 @@ const Agents: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{agent.ip_address}
|
||||
{agent.metadata && (() => {
|
||||
const meta = getSystemMetadata(agent);
|
||||
const parts = [];
|
||||
if (meta.cpuCores !== 'Unknown') parts.push(`${meta.cpuCores} cores`);
|
||||
if (meta.memoryTotal > 0) parts.push(formatBytes(meta.memoryTotal));
|
||||
if (parts.length > 0) return parts.join(' • ');
|
||||
return 'System info available';
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={cn('badge', getStatusColor(agent.status))}>
|
||||
{agent.status}
|
||||
<span className={cn('badge', getStatusColor(isOnline(agent.last_seen) ? 'online' : 'offline'))}>
|
||||
{isOnline(agent.last_seen) ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{agent.os_type}
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(agent);
|
||||
return osInfo.distribution || agent.os_type;
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{agent.architecture}
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(agent);
|
||||
if (osInfo.version) {
|
||||
return `${osInfo.version} • ${agent.os_architecture || agent.architecture}`;
|
||||
}
|
||||
return `${agent.os_architecture || agent.architecture}`;
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatRelativeTime(agent.last_checkin)}
|
||||
{formatRelativeTime(agent.last_seen)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{isOnline(agent.last_checkin) ? 'Online' : 'Offline'}
|
||||
{isOnline(agent.last_seen) ? 'Online' : 'Offline'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
@@ -462,12 +709,20 @@ const Agents: React.FC = () => {
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleScanAgent(agent.id)}
|
||||
disabled={scanAgentMutation.isLoading}
|
||||
disabled={scanAgentMutation.isPending}
|
||||
className="text-gray-400 hover:text-primary-600"
|
||||
title="Trigger scan"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveAgent(agent.id, agent.hostname)}
|
||||
disabled={unregisterAgentMutation.isPending}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
title="Remove agent"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${agent.id}`)}
|
||||
className="text-gray-400 hover:text-primary-600"
|
||||
|
||||
@@ -7,17 +7,14 @@ import {
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { useDashboardStats } from '@/hooks/useStats';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const { data: stats, isLoading, error } = useDashboardStats();
|
||||
const { data: stats, isPending, error } = useDashboardStats();
|
||||
|
||||
if (isLoading) {
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="animate-pulse">
|
||||
|
||||
@@ -1,32 +1,131 @@
|
||||
import React from 'react';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { useSettingsStore } from '@/lib/store';
|
||||
import { useTimezones, useTimezone, useUpdateTimezone } from '../hooks/useSettings';
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const { autoRefresh, refreshInterval, setAutoRefresh, setRefreshInterval } = useSettingsStore();
|
||||
|
||||
const { data: timezones, isLoading: isLoadingTimezones } = useTimezones();
|
||||
const { data: currentTimezone, isLoading: isLoadingCurrentTimezone } = useTimezone();
|
||||
const updateTimezone = useUpdateTimezone();
|
||||
|
||||
const [selectedTimezone, setSelectedTimezone] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentTimezone?.timezone) {
|
||||
setSelectedTimezone(currentTimezone.timezone);
|
||||
}
|
||||
}, [currentTimezone]);
|
||||
|
||||
const handleTimezoneChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newTimezone = e.target.value;
|
||||
setSelectedTimezone(newTimezone);
|
||||
|
||||
try {
|
||||
await updateTimezone.mutateAsync(newTimezone);
|
||||
} catch (error) {
|
||||
console.error('Failed to update timezone:', error);
|
||||
// Revert on error
|
||||
if (currentTimezone?.timezone) {
|
||||
setSelectedTimezone(currentTimezone.timezone);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<div className="px-4 sm:px-6 lg:px-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Configure your dashboard preferences
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-600">Configure your RedFlag dashboard preferences</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium text-gray-900">Dashboard Settings</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Configure how the dashboard behaves and displays information
|
||||
</p>
|
||||
{/* Timezone Settings */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Timezone Settings</h2>
|
||||
<p className="text-gray-600">Configure the timezone used for displaying timestamps</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="timezone" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Display Timezone
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="timezone"
|
||||
value={selectedTimezone}
|
||||
onChange={handleTimezoneChange}
|
||||
disabled={isLoadingTimezones || isLoadingCurrentTimezone || updateTimezone.isPending}
|
||||
className="w-full px-4 py-2 bg-white border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoadingTimezones ? (
|
||||
<option>Loading timezones...</option>
|
||||
) : (
|
||||
timezones?.map((tz) => (
|
||||
<option key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
|
||||
{/* Custom dropdown arrow */}
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{updateTimezone.isPending && (
|
||||
<p className="mt-2 text-sm text-yellow-600">Updating timezone...</p>
|
||||
)}
|
||||
|
||||
{updateTimezone.isSuccess && (
|
||||
<p className="mt-2 text-sm text-green-600">Timezone updated successfully!</p>
|
||||
)}
|
||||
|
||||
{updateTimezone.isError && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
Failed to update timezone. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
This setting affects how timestamps are displayed throughout the dashboard, including agent
|
||||
last check-in times, scan times, and update timestamps.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Settings */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Dashboard Settings</h2>
|
||||
<p className="text-gray-600">Configure how the dashboard behaves and displays information</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Auto Refresh */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">Auto Refresh</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-gray-600">
|
||||
Automatically refresh dashboard data at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
@@ -51,7 +150,7 @@ const Settings: React.FC = () => {
|
||||
value={refreshInterval}
|
||||
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
||||
disabled={!autoRefresh}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full px-4 py-2 bg-white border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value={10000}>10 seconds</option>
|
||||
<option value={30000}>30 seconds</option>
|
||||
@@ -65,6 +164,26 @@ const Settings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Future Settings Sections */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 opacity-60">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-400">Additional Settings</h2>
|
||||
<p className="text-gray-500">More configuration options coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm text-gray-500">
|
||||
<div>• Notification preferences</div>
|
||||
<div>• Agent monitoring settings</div>
|
||||
<div>• Data retention policies</div>
|
||||
<div>• API access tokens</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,23 +4,23 @@ import {
|
||||
Package,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronDown as ChevronDownIcon,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
Computer,
|
||||
ExternalLink,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Calendar,
|
||||
} from 'lucide-react';
|
||||
import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates } from '@/hooks/useUpdates';
|
||||
import { UpdatePackage } from '@/types';
|
||||
import type { UpdatePackage } from '@/types';
|
||||
import { getSeverityColor, getStatusColor, getPackageTypeIcon, formatBytes, formatRelativeTime } from '@/lib/utils';
|
||||
import { useUpdateStore } from '@/lib/store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
|
||||
const Updates: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -34,6 +34,8 @@ const Updates: React.FC = () => {
|
||||
const [agentFilter, setAgentFilter] = useState(searchParams.get('agent') || '');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedUpdates, setSelectedUpdates] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(parseInt(searchParams.get('page') || '1'));
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
|
||||
// Store filters in URL
|
||||
useEffect(() => {
|
||||
@@ -43,20 +45,24 @@ const Updates: React.FC = () => {
|
||||
if (severityFilter) params.set('severity', severityFilter);
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
if (agentFilter) params.set('agent', agentFilter);
|
||||
if (currentPage > 1) params.set('page', currentPage.toString());
|
||||
if (pageSize !== 100) params.set('page_size', pageSize.toString());
|
||||
|
||||
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
|
||||
if (newUrl !== window.location.href) {
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
}, [searchQuery, statusFilter, severityFilter, typeFilter, agentFilter]);
|
||||
}, [searchQuery, statusFilter, severityFilter, typeFilter, agentFilter, currentPage, pageSize]);
|
||||
|
||||
// Fetch updates list
|
||||
const { data: updatesData, isLoading, error } = useUpdates({
|
||||
const { data: updatesData, isPending, error } = useUpdates({
|
||||
search: searchQuery || undefined,
|
||||
status: statusFilter || undefined,
|
||||
severity: severityFilter || undefined,
|
||||
type: typeFilter || undefined,
|
||||
agent_id: agentFilter || undefined,
|
||||
agent: agentFilter || undefined,
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
});
|
||||
|
||||
// Fetch single update if ID is provided
|
||||
@@ -68,7 +74,13 @@ const Updates: React.FC = () => {
|
||||
const bulkApproveMutation = useApproveMultipleUpdates();
|
||||
|
||||
const updates = updatesData?.updates || [];
|
||||
const selectedUpdate = selectedUpdateData || updates.find(u => u.id === id);
|
||||
const totalCount = updatesData?.total || 0;
|
||||
const selectedUpdate = selectedUpdateData || updates.find((u: UpdatePackage) => u.id === id);
|
||||
|
||||
// Pagination calculations
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const hasNextPage = currentPage < totalPages;
|
||||
const hasPrevPage = currentPage > 1;
|
||||
|
||||
// Handle update selection
|
||||
const handleSelectUpdate = (updateId: string, checked: boolean) => {
|
||||
@@ -81,7 +93,7 @@ const Updates: React.FC = () => {
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUpdates(updates.map(update => update.id));
|
||||
setSelectedUpdates(updates.map((update: UpdatePackage) => update.id));
|
||||
} else {
|
||||
setSelectedUpdates([]);
|
||||
}
|
||||
@@ -127,10 +139,74 @@ const Updates: React.FC = () => {
|
||||
};
|
||||
|
||||
// Get unique values for filters
|
||||
const statuses = [...new Set(updates.map(u => u.status))];
|
||||
const severities = [...new Set(updates.map(u => u.severity))];
|
||||
const types = [...new Set(updates.map(u => u.package_type))];
|
||||
const agents = [...new Set(updates.map(u => u.agent_id))];
|
||||
const statuses = [...new Set(updates.map((u: UpdatePackage) => u.status))];
|
||||
const severities = [...new Set(updates.map((u: UpdatePackage) => u.severity))];
|
||||
const types = [...new Set(updates.map((u: UpdatePackage) => u.package_type))];
|
||||
const agents = [...new Set(updates.map((u: UpdatePackage) => u.agent_id))];
|
||||
|
||||
// Quick filter functions
|
||||
const handleQuickFilter = (filter: string) => {
|
||||
switch (filter) {
|
||||
case 'critical':
|
||||
setSeverityFilter('critical');
|
||||
setStatusFilter('pending');
|
||||
break;
|
||||
case 'pending':
|
||||
setStatusFilter('pending');
|
||||
setSeverityFilter('');
|
||||
break;
|
||||
case 'approved':
|
||||
setStatusFilter('approved');
|
||||
setSeverityFilter('');
|
||||
break;
|
||||
default:
|
||||
// Clear all filters
|
||||
setStatusFilter('');
|
||||
setSeverityFilter('');
|
||||
setTypeFilter('');
|
||||
setAgentFilter('');
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Group updates
|
||||
const groupUpdates = (updates: UpdatePackage[], groupBy: string) => {
|
||||
const groups: Record<string, UpdatePackage[]> = {};
|
||||
|
||||
updates.forEach(update => {
|
||||
let key: string;
|
||||
switch (groupBy) {
|
||||
case 'severity':
|
||||
key = update.severity;
|
||||
break;
|
||||
case 'type':
|
||||
key = update.package_type;
|
||||
break;
|
||||
case 'status':
|
||||
key = update.status;
|
||||
break;
|
||||
default:
|
||||
key = 'all';
|
||||
}
|
||||
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
}
|
||||
groups[key].push(update);
|
||||
});
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
// Get total statistics from API (not just current page)
|
||||
const totalStats = {
|
||||
total: totalCount,
|
||||
pending: updatesData?.stats?.pending_updates || 0,
|
||||
approved: updatesData?.stats?.approved_updates || 0,
|
||||
critical: updatesData?.stats?.critical_updates || 0,
|
||||
high: updatesData?.stats?.high_updates || 0,
|
||||
};
|
||||
|
||||
// Update detail view
|
||||
if (id && selectedUpdate) {
|
||||
@@ -246,7 +322,7 @@ const Updates: React.FC = () => {
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleApproveUpdate(selectedUpdate.id)}
|
||||
disabled={approveMutation.isLoading}
|
||||
disabled={approveMutation.isPending}
|
||||
className="w-full btn btn-success"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
@@ -255,7 +331,7 @@ const Updates: React.FC = () => {
|
||||
|
||||
<button
|
||||
onClick={() => handleRejectUpdate(selectedUpdate.id)}
|
||||
disabled={rejectMutation.isLoading}
|
||||
disabled={rejectMutation.isPending}
|
||||
className="w-full btn btn-secondary"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
@@ -267,7 +343,7 @@ const Updates: React.FC = () => {
|
||||
{selectedUpdate.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => handleInstallUpdate(selectedUpdate.id)}
|
||||
disabled={installMutation.isLoading}
|
||||
disabled={installMutation.isPending}
|
||||
className="w-full btn btn-primary"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
@@ -290,16 +366,151 @@ const Updates: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Pagination handlers
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(1); // Reset to first page when changing page size
|
||||
};
|
||||
|
||||
// Updates list view
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Updates</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Review and approve available updates for your agents
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600">
|
||||
Showing {updates.length} of {totalCount} updates
|
||||
</div>
|
||||
{totalCount > 100 && (
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
||||
className="mt-1 text-sm border border-gray-300 rounded px-3 py-1"
|
||||
>
|
||||
<option value={50}>50 per page</option>
|
||||
<option value={100}>100 per page</option>
|
||||
<option value={200}>200 per page</option>
|
||||
<option value={500}>500 per page</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards - Show total counts across all updates */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Updates</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{totalStats.total}</p>
|
||||
</div>
|
||||
<Package className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-orange-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Pending</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{totalStats.pending}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-orange-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-green-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Approved</p>
|
||||
<p className="text-2xl font-bold text-green-600">{totalStats.approved}</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-red-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Critical</p>
|
||||
<p className="text-2xl font-bold text-red-600">{totalStats.critical}</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-yellow-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">High Priority</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">{totalStats.high}</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-yellow-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Filters */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => handleQuickFilter('all')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
!statusFilter && !severityFilter && !typeFilter && !agentFilter
|
||||
? "bg-primary-100 border-primary-300 text-primary-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
All Updates
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickFilter('critical')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
statusFilter === 'pending' && severityFilter === 'critical'
|
||||
? "bg-red-100 border-red-300 text-red-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 mr-1 inline" />
|
||||
Critical
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickFilter('pending')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
statusFilter === 'pending' && !severityFilter
|
||||
? "bg-orange-100 border-orange-300 text-orange-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-1 inline" />
|
||||
Pending Approval
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickFilter('approved')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
statusFilter === 'approved' && !severityFilter
|
||||
? "bg-green-100 border-green-300 text-green-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1 inline" />
|
||||
Approved
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="mb-6 space-y-4">
|
||||
@@ -336,7 +547,7 @@ const Updates: React.FC = () => {
|
||||
{selectedUpdates.length > 0 && (
|
||||
<button
|
||||
onClick={handleBulkApprove}
|
||||
disabled={bulkApproveMutation.isLoading}
|
||||
disabled={bulkApproveMutation.isPending}
|
||||
className="btn btn-success"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
@@ -359,7 +570,7 @@ const Updates: React.FC = () => {
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
{statuses.map(status => (
|
||||
{statuses.map((status: string) => (
|
||||
<option key={status} value={status}>{status}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -375,7 +586,7 @@ const Updates: React.FC = () => {
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Severities</option>
|
||||
{severities.map(severity => (
|
||||
{severities.map((severity: string) => (
|
||||
<option key={severity} value={severity}>{severity}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -391,7 +602,7 @@ const Updates: React.FC = () => {
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{types.map(type => (
|
||||
{types.map((type: string) => (
|
||||
<option key={type} value={type}>{type.toUpperCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -407,7 +618,7 @@ const Updates: React.FC = () => {
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Agents</option>
|
||||
{agents.map(agentId => (
|
||||
{agents.map((agentId: string) => (
|
||||
<option key={agentId} value={agentId}>{agentId}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -418,7 +629,7 @@ const Updates: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Updates table */}
|
||||
{isLoading ? (
|
||||
{isPending ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
@@ -469,7 +680,7 @@ const Updates: React.FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{updates.map((update) => (
|
||||
{updates.map((update: UpdatePackage) => (
|
||||
<tr key={update.id} className="hover:bg-gray-50">
|
||||
<td className="table-cell">
|
||||
<input
|
||||
@@ -540,7 +751,7 @@ const Updates: React.FC = () => {
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleApproveUpdate(update.id)}
|
||||
disabled={approveMutation.isLoading}
|
||||
disabled={approveMutation.isPending}
|
||||
className="text-success-600 hover:text-success-800"
|
||||
title="Approve"
|
||||
>
|
||||
@@ -548,7 +759,7 @@ const Updates: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRejectUpdate(update.id)}
|
||||
disabled={rejectMutation.isLoading}
|
||||
disabled={rejectMutation.isPending}
|
||||
className="text-gray-600 hover:text-gray-800"
|
||||
title="Reject"
|
||||
>
|
||||
@@ -560,7 +771,7 @@ const Updates: React.FC = () => {
|
||||
{update.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => handleInstallUpdate(update.id)}
|
||||
disabled={installMutation.isLoading}
|
||||
disabled={installMutation.isPending}
|
||||
className="text-primary-600 hover:text-primary-800"
|
||||
title="Install"
|
||||
>
|
||||
@@ -582,6 +793,88 @@ const Updates: React.FC = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={!hasPrevPage}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={!hasNextPage}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> to{' '}
|
||||
<span className="font-medium">{Math.min(currentPage * pageSize, totalCount)}</span> of{' '}
|
||||
<span className="font-medium">{totalCount}</span> results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={!hasPrevPage}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="sr-only">Previous</span>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Page numbers */}
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNum;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||
currentPage === pageNum
|
||||
? 'z-10 bg-primary-50 border-primary-500 text-primary-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={!hasNextPage}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="sr-only">Next</span>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,14 +11,18 @@ export interface Agent {
|
||||
hostname: string;
|
||||
os_type: string;
|
||||
os_version: string;
|
||||
architecture: string;
|
||||
status: 'online' | 'offline';
|
||||
last_checkin: string;
|
||||
os_architecture: string;
|
||||
architecture: string; // For backward compatibility
|
||||
agent_version: string;
|
||||
version: string; // For backward compatibility
|
||||
last_seen: string;
|
||||
last_checkin: string; // For backward compatibility
|
||||
last_scan: string | null;
|
||||
status: 'online' | 'offline';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: string;
|
||||
ip_address: string;
|
||||
metadata?: Record<string, any>;
|
||||
// Note: ip_address not available from API yet
|
||||
}
|
||||
|
||||
export interface AgentSpec {
|
||||
@@ -61,6 +65,97 @@ export interface DockerUpdateInfo {
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
// Docker-specific types for dedicated Docker module
|
||||
export interface DockerContainer {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
name: string;
|
||||
image_id: string;
|
||||
image_name: string;
|
||||
image_tag: string;
|
||||
status: 'running' | 'stopped' | 'paused' | 'restarting' | 'removing' | 'exited' | 'dead';
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
ports: DockerPort[];
|
||||
volumes: DockerVolume[];
|
||||
labels: Record<string, string>;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface DockerImage {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
repository: string;
|
||||
tag: string;
|
||||
digest: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
last_pulled: string | null;
|
||||
update_available: boolean;
|
||||
current_version: string;
|
||||
available_version: string | null;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
status: 'up-to-date' | 'update-available' | 'update-approved' | 'update-scheduled' | 'update-installing' | 'update-failed';
|
||||
update_approved_at: string | null;
|
||||
update_scheduled_at: string | null;
|
||||
update_installed_at: string | null;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface DockerPort {
|
||||
container_port: number;
|
||||
host_port: number | null;
|
||||
protocol: 'tcp' | 'udp';
|
||||
host_ip: string;
|
||||
}
|
||||
|
||||
export interface DockerVolume {
|
||||
name: string;
|
||||
source: string;
|
||||
destination: string;
|
||||
mode: 'ro' | 'rw';
|
||||
driver: string;
|
||||
}
|
||||
|
||||
// Docker API response types
|
||||
export interface DockerContainerListResponse {
|
||||
containers: DockerContainer[];
|
||||
images: DockerImage[];
|
||||
total_containers: number;
|
||||
total_images: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface DockerStats {
|
||||
total_containers: number;
|
||||
running_containers: number;
|
||||
stopped_containers: number;
|
||||
total_images: number;
|
||||
images_with_updates: number;
|
||||
critical_updates: number;
|
||||
high_updates: number;
|
||||
medium_updates: number;
|
||||
low_updates: number;
|
||||
agents_with_docker: number;
|
||||
total_storage_used: number;
|
||||
}
|
||||
|
||||
// Docker action types
|
||||
export interface DockerUpdateRequest {
|
||||
image_id: string;
|
||||
scheduled_at?: string;
|
||||
}
|
||||
|
||||
export interface BulkDockerUpdateRequest {
|
||||
updates: Array<{
|
||||
container_id: string;
|
||||
image_id: string;
|
||||
}>;
|
||||
scheduled_at?: string;
|
||||
}
|
||||
|
||||
export interface AptUpdateInfo {
|
||||
package_name: string;
|
||||
current_version: string;
|
||||
@@ -122,6 +217,22 @@ export interface AgentListResponse {
|
||||
export interface UpdateListResponse {
|
||||
updates: UpdatePackage[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
stats?: UpdateStats;
|
||||
}
|
||||
|
||||
export interface UpdateStats {
|
||||
total_updates: number;
|
||||
pending_updates: number;
|
||||
approved_updates: number;
|
||||
updated_updates: number;
|
||||
failed_updates: number;
|
||||
critical_updates: number;
|
||||
high_updates: number;
|
||||
important_updates: number;
|
||||
moderate_updates: number;
|
||||
low_updates: number;
|
||||
}
|
||||
|
||||
export interface UpdateApprovalRequest {
|
||||
@@ -142,6 +253,7 @@ export interface ListQueryParams {
|
||||
severity?: string;
|
||||
type?: string;
|
||||
search?: string;
|
||||
agent?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
10
aggregator-web/src/vite-env.d.ts
vendored
Normal file
10
aggregator-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
// more env variables...
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user