Session 4 complete - RedFlag update management platform
🚩 Private development - version retention only ✅ Complete web dashboard (React + TypeScript + TailwindCSS) ✅ Production-ready server backend (Go + Gin + PostgreSQL) ✅ Linux agent with APT + Docker scanning + local CLI tools ✅ JWT authentication and REST API ✅ Update discovery and approval workflow 🚧 Status: Alpha software - active development 📦 Purpose: Version retention during development ⚠️ Not for public use or deployment
This commit is contained in:
426
.gitignore
vendored
Normal file
426
.gitignore
vendored
Normal file
@@ -0,0 +1,426 @@
|
||||
# RedFlag .gitignore
|
||||
# Comprehensive ignore file for Go, Node.js, and development files
|
||||
|
||||
# =============================================================================
|
||||
# Go / Go Modules
|
||||
# =============================================================================
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Dependency directories (remove comment if using vendoring)
|
||||
vendor/
|
||||
|
||||
# Go build cache
|
||||
.cache/
|
||||
|
||||
# Go mod download cache (can be large)
|
||||
*.modcache
|
||||
|
||||
# =============================================================================
|
||||
# Node.js / npm / yarn / pnpm
|
||||
# =============================================================================
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# =============================================================================
|
||||
# React / Vite / Frontend Build
|
||||
# =============================================================================
|
||||
# Vite build output
|
||||
dist/
|
||||
dist-ssr/
|
||||
build/
|
||||
|
||||
# Storybook build outputs
|
||||
storybook-static
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# =============================================================================
|
||||
# IDE / Editor Files
|
||||
# =============================================================================
|
||||
# VSCode
|
||||
.vscode/
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
|
||||
# JetBrains / IntelliJ IDEA
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# Vim
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Emacs
|
||||
*~
|
||||
\#*\#
|
||||
/.emacs.desktop
|
||||
/.emacs.desktop.lock
|
||||
*.elc
|
||||
auto-save-list
|
||||
tramp
|
||||
.\#*
|
||||
|
||||
# Sublime Text
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Kate
|
||||
.session
|
||||
|
||||
# Gedit
|
||||
*~
|
||||
|
||||
# =============================================================================
|
||||
# OS Generated Files
|
||||
# =============================================================================
|
||||
# macOS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Windows
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
*.lnk
|
||||
|
||||
# Linux
|
||||
*~
|
||||
.fuse_hidden*
|
||||
.directory
|
||||
.Trash-*
|
||||
.nfs*
|
||||
|
||||
# =============================================================================
|
||||
# Application Specific
|
||||
# =============================================================================
|
||||
# RedFlag specific files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Agent configuration (may contain sensitive data)
|
||||
aggregator-agent/config.json
|
||||
aggregator-agent/.agent-id
|
||||
aggregator-agent/.token
|
||||
|
||||
# Server runtime files
|
||||
aggregator-server/logs/
|
||||
aggregator-server/data/
|
||||
aggregator-server/uploads/
|
||||
|
||||
# Local cache files
|
||||
aggregator-agent/cache/
|
||||
aggregator-agent/*.cache
|
||||
/var/lib/aggregator/
|
||||
/var/cache/aggregator/
|
||||
/var/log/aggregator/
|
||||
|
||||
# Test files and coverage
|
||||
coverage.txt
|
||||
coverage.html
|
||||
*.cover
|
||||
*.prof
|
||||
test-results/
|
||||
|
||||
# Local development files
|
||||
*.local
|
||||
*.dev
|
||||
.devenv/
|
||||
dev/
|
||||
|
||||
# Build artifacts
|
||||
*.tar.gz
|
||||
*.zip
|
||||
*.rpm
|
||||
*.deb
|
||||
*.snap
|
||||
|
||||
# Documentation build
|
||||
docs/_build/
|
||||
docs/build/
|
||||
|
||||
# =============================================================================
|
||||
# Docker / Container Related
|
||||
# =============================================================================
|
||||
# Docker volumes (avoid committing data)
|
||||
volumes/
|
||||
data/
|
||||
|
||||
# Docker build context
|
||||
.dockerignore
|
||||
|
||||
# =============================================================================
|
||||
# Security / Credentials
|
||||
# =============================================================================
|
||||
# Private keys and certificates
|
||||
*.key
|
||||
*.pem
|
||||
*.crt
|
||||
*.p12
|
||||
*.pfx
|
||||
id_rsa
|
||||
id_rsa.pub
|
||||
id_ed25519
|
||||
id_ed25519.pub
|
||||
|
||||
# Passwords and secrets
|
||||
secrets/
|
||||
*.secret
|
||||
*.password
|
||||
*.token
|
||||
.auth
|
||||
|
||||
# Cloud provider credentials
|
||||
.aws/
|
||||
.azure/
|
||||
.gcp/
|
||||
.kube/
|
||||
|
||||
# =============================================================================
|
||||
# Miscellaneous
|
||||
# =============================================================================
|
||||
# Large files
|
||||
*.iso
|
||||
*.dmg
|
||||
*.img
|
||||
*.bin
|
||||
*.dat
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*.old
|
||||
*.orig
|
||||
*.save
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Lock files (keep some, ignore others)
|
||||
*.lock
|
||||
# Keep package-lock.json and yarn.lock for dependency management
|
||||
# yarn.lock
|
||||
# package-lock.json
|
||||
|
||||
# Archive files
|
||||
*.7z
|
||||
*.rar
|
||||
*.tar
|
||||
*.tgz
|
||||
*.gz
|
||||
|
||||
# Profiling and performance data
|
||||
*.prof
|
||||
*.pprof
|
||||
*.cpu
|
||||
*.mem
|
||||
|
||||
# Local database files
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# =============================================================================
|
||||
# AI / LLM Development Files
|
||||
# =============================================================================
|
||||
# Claude AI settings and cache
|
||||
.claude/
|
||||
*claude*
|
||||
|
||||
# =============================================================================
|
||||
# Include ONLY essential project files
|
||||
# =============================================================================
|
||||
!README.md
|
||||
!LICENSE
|
||||
!.gitignore
|
||||
!.env.example
|
||||
!docker-compose.yml
|
||||
!Dockerfile*
|
||||
!Makefile
|
||||
|
||||
# Only minimal README, no other documentation
|
||||
|
||||
# =============================================================================
|
||||
# ALL DOCUMENTATION (private development - version retention only)
|
||||
# =============================================================================
|
||||
# Exclude ALL documentation files - this is private development
|
||||
*.md
|
||||
!LICENSE
|
||||
*.html
|
||||
*.txt
|
||||
|
||||
# Session and development files
|
||||
SESSION_*
|
||||
claude*
|
||||
TECHNICAL_*
|
||||
COMPETITIVE_*
|
||||
PROXMOX_*
|
||||
HOW_TO_*
|
||||
NEXT_*
|
||||
Starting*
|
||||
|
||||
# Setup and documentation files
|
||||
SETUP_*
|
||||
CONTRIBUTING*
|
||||
.github/
|
||||
docs/
|
||||
|
||||
# Only keep actual project code, no documentation
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 RedFlag Project
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
34
Makefile
Normal file
34
Makefile
Normal file
@@ -0,0 +1,34 @@
|
||||
.PHONY: help db-up db-down server agent clean
|
||||
|
||||
help: ## Show this help message
|
||||
@echo 'Usage: make [target]'
|
||||
@echo ''
|
||||
@echo 'Available targets:'
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
db-up: ## Start PostgreSQL database
|
||||
docker-compose up -d aggregator-db
|
||||
@echo "Waiting for database to be ready..."
|
||||
@sleep 3
|
||||
|
||||
db-down: ## Stop PostgreSQL database
|
||||
docker-compose down
|
||||
|
||||
server: ## Build and run the server
|
||||
cd aggregator-server && go mod tidy && go run cmd/server/main.go
|
||||
|
||||
agent: ## Build and run the agent
|
||||
cd aggregator-agent && go mod tidy && go run cmd/agent/main.go
|
||||
|
||||
build-server: ## Build server binary
|
||||
cd aggregator-server && go build -o bin/aggregator-server cmd/server/main.go
|
||||
|
||||
build-agent: ## Build agent binary
|
||||
cd aggregator-agent && go build -o bin/aggregator-agent cmd/agent/main.go
|
||||
|
||||
clean: ## Clean build artifacts
|
||||
rm -rf aggregator-server/bin aggregator-agent/bin
|
||||
|
||||
test: ## Run tests
|
||||
cd aggregator-server && go test ./...
|
||||
cd aggregator-agent && go test ./...
|
||||
BIN
aggregator-agent/aggregator-agent
Executable file
BIN
aggregator-agent/aggregator-agent
Executable file
Binary file not shown.
360
aggregator-agent/cmd/agent/main.go
Normal file
360
aggregator-agent/cmd/agent/main.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/cache"
|
||||
"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/scanner"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
AgentVersion = "0.1.0"
|
||||
ConfigPath = "/etc/aggregator/config.json"
|
||||
)
|
||||
|
||||
func main() {
|
||||
registerCmd := flag.Bool("register", false, "Register agent with server")
|
||||
scanCmd := flag.Bool("scan", false, "Scan for updates and display locally")
|
||||
statusCmd := flag.Bool("status", false, "Show agent status")
|
||||
listUpdatesCmd := flag.Bool("list-updates", false, "List detailed update information")
|
||||
serverURL := flag.String("server", "http://localhost:8080", "Server URL")
|
||||
exportFormat := flag.String("export", "", "Export format: json, csv")
|
||||
flag.Parse()
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load(ConfigPath)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load configuration:", err)
|
||||
}
|
||||
|
||||
// Handle registration
|
||||
if *registerCmd {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle scan command
|
||||
if *scanCmd {
|
||||
if err := handleScanCommand(cfg, *exportFormat); err != nil {
|
||||
log.Fatal("Scan failed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle status command
|
||||
if *statusCmd {
|
||||
if err := handleStatusCommand(cfg); err != nil {
|
||||
log.Fatal("Status command failed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle list-updates command
|
||||
if *listUpdatesCmd {
|
||||
if err := handleListUpdatesCommand(cfg, *exportFormat); err != nil {
|
||||
log.Fatal("List updates failed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if registered
|
||||
if !cfg.IsRegistered() {
|
||||
log.Fatal("Agent not registered. Run with -register flag first.")
|
||||
}
|
||||
|
||||
// Start agent service
|
||||
if err := runAgent(cfg); err != nil {
|
||||
log.Fatal("Agent failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func registerAgent(cfg *config.Config, serverURL string) error {
|
||||
hostname, _ := os.Hostname()
|
||||
osType, osVersion, osArch := client.DetectSystem()
|
||||
|
||||
apiClient := client.NewClient(serverURL, "")
|
||||
|
||||
req := client.RegisterRequest{
|
||||
Hostname: hostname,
|
||||
OSType: osType,
|
||||
OSVersion: osVersion,
|
||||
OSArchitecture: osArch,
|
||||
AgentVersion: AgentVersion,
|
||||
Metadata: map[string]string{
|
||||
"installation_time": time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := apiClient.Register(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
cfg.ServerURL = serverURL
|
||||
cfg.AgentID = resp.AgentID
|
||||
cfg.Token = resp.Token
|
||||
|
||||
// Get check-in interval from server config
|
||||
if interval, ok := resp.Config["check_in_interval"].(float64); ok {
|
||||
cfg.CheckInInterval = int(interval)
|
||||
} else {
|
||||
cfg.CheckInInterval = 300 // Default 5 minutes
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
return cfg.Save(ConfigPath)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
apiClient := client.NewClient(cfg.ServerURL, cfg.Token)
|
||||
|
||||
// Initialize scanners
|
||||
aptScanner := scanner.NewAPTScanner()
|
||||
dockerScanner, _ := scanner.NewDockerScanner()
|
||||
|
||||
// Main check-in loop
|
||||
for {
|
||||
// Add jitter to prevent thundering herd
|
||||
jitter := time.Duration(rand.Intn(30)) * time.Second
|
||||
time.Sleep(jitter)
|
||||
|
||||
log.Println("Checking in with server...")
|
||||
|
||||
// Get commands from server
|
||||
commands, err := apiClient.GetCommands(cfg.AgentID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting commands: %v\n", err)
|
||||
time.Sleep(time.Duration(cfg.CheckInInterval) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// Process each command
|
||||
for _, cmd := range commands {
|
||||
log.Printf("Processing command: %s (%s)\n", cmd.Type, cmd.ID)
|
||||
|
||||
switch cmd.Type {
|
||||
case "scan_updates":
|
||||
if err := handleScanUpdates(apiClient, cfg, aptScanner, dockerScanner, cmd.ID); err != nil {
|
||||
log.Printf("Error scanning updates: %v\n", err)
|
||||
}
|
||||
|
||||
case "collect_specs":
|
||||
log.Println("Spec collection not yet implemented")
|
||||
|
||||
case "install_updates":
|
||||
log.Println("Update installation not yet implemented")
|
||||
|
||||
default:
|
||||
log.Printf("Unknown command type: %s\n", cmd.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for next check-in
|
||||
time.Sleep(time.Duration(cfg.CheckInInterval) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner *scanner.APTScanner, dockerScanner *scanner.DockerScanner, commandID string) error {
|
||||
log.Println("Scanning for updates...")
|
||||
|
||||
var allUpdates []client.UpdateReportItem
|
||||
|
||||
// Scan APT updates
|
||||
if aptScanner.IsAvailable() {
|
||||
log.Println(" - Scanning APT packages...")
|
||||
updates, err := aptScanner.Scan()
|
||||
if err != nil {
|
||||
log.Printf(" APT scan failed: %v\n", err)
|
||||
} else {
|
||||
log.Printf(" Found %d APT updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan Docker updates
|
||||
if dockerScanner != nil && dockerScanner.IsAvailable() {
|
||||
log.Println(" - Scanning Docker images...")
|
||||
updates, err := dockerScanner.Scan()
|
||||
if err != nil {
|
||||
log.Printf(" Docker scan failed: %v\n", err)
|
||||
} else {
|
||||
log.Printf(" Found %d Docker image updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Report to server
|
||||
if len(allUpdates) > 0 {
|
||||
report := client.UpdateReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now(),
|
||||
Updates: allUpdates,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
|
||||
return fmt.Errorf("failed to report updates: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✓ Reported %d updates to server\n", len(allUpdates))
|
||||
} else {
|
||||
log.Println("✓ No updates found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleScanCommand performs a local scan and displays results
|
||||
func handleScanCommand(cfg *config.Config, exportFormat string) error {
|
||||
// Initialize scanners
|
||||
aptScanner := scanner.NewAPTScanner()
|
||||
dockerScanner, _ := scanner.NewDockerScanner()
|
||||
|
||||
fmt.Println("🔍 Scanning for updates...")
|
||||
var allUpdates []client.UpdateReportItem
|
||||
|
||||
// Scan APT updates
|
||||
if aptScanner.IsAvailable() {
|
||||
fmt.Println(" - Scanning APT packages...")
|
||||
updates, err := aptScanner.Scan()
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠️ APT scan failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d APT updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan Docker updates
|
||||
if dockerScanner != nil && dockerScanner.IsAvailable() {
|
||||
fmt.Println(" - Scanning Docker images...")
|
||||
updates, err := dockerScanner.Scan()
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠️ Docker scan failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d Docker image updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Load and update cache
|
||||
localCache, err := cache.Load()
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ Warning: Failed to load cache: %v\n", err)
|
||||
localCache = &cache.LocalCache{}
|
||||
}
|
||||
|
||||
// Update cache with scan results
|
||||
localCache.UpdateScanResults(allUpdates)
|
||||
if cfg.IsRegistered() {
|
||||
localCache.SetAgentInfo(cfg.AgentID, cfg.ServerURL)
|
||||
localCache.SetAgentStatus("online")
|
||||
}
|
||||
|
||||
// Save cache
|
||||
if err := localCache.Save(); err != nil {
|
||||
fmt.Printf("⚠️ Warning: Failed to save cache: %v\n", err)
|
||||
}
|
||||
|
||||
// Display results
|
||||
fmt.Println()
|
||||
return display.PrintScanResults(allUpdates, exportFormat)
|
||||
}
|
||||
|
||||
// handleStatusCommand displays agent status information
|
||||
func handleStatusCommand(cfg *config.Config) error {
|
||||
// Load cache
|
||||
localCache, err := cache.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load cache: %w", err)
|
||||
}
|
||||
|
||||
// Determine status
|
||||
agentStatus := "offline"
|
||||
if cfg.IsRegistered() {
|
||||
agentStatus = "online"
|
||||
}
|
||||
if localCache.AgentStatus != "" {
|
||||
agentStatus = localCache.AgentStatus
|
||||
}
|
||||
|
||||
// Use cached info if available, otherwise use config
|
||||
agentID := cfg.AgentID.String()
|
||||
if localCache.AgentID != (uuid.UUID{}) {
|
||||
agentID = localCache.AgentID.String()
|
||||
}
|
||||
|
||||
serverURL := cfg.ServerURL
|
||||
if localCache.ServerURL != "" {
|
||||
serverURL = localCache.ServerURL
|
||||
}
|
||||
|
||||
// Display status
|
||||
display.PrintAgentStatus(
|
||||
agentID,
|
||||
serverURL,
|
||||
localCache.LastCheckIn,
|
||||
localCache.LastScanTime,
|
||||
localCache.UpdateCount,
|
||||
agentStatus,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleListUpdatesCommand displays detailed update information
|
||||
func handleListUpdatesCommand(cfg *config.Config, exportFormat string) error {
|
||||
// Load cache
|
||||
localCache, err := cache.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load cache: %w", err)
|
||||
}
|
||||
|
||||
// Check if we have cached scan results
|
||||
if len(localCache.Updates) == 0 {
|
||||
fmt.Println("📋 No cached scan results found.")
|
||||
fmt.Println("💡 Run '--scan' first to discover available updates.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Warn if cache is old
|
||||
if localCache.IsExpired(24 * time.Hour) {
|
||||
fmt.Printf("⚠️ Scan results are %s old. Run '--scan' for latest results.\n\n",
|
||||
formatTimeSince(localCache.LastScanTime))
|
||||
}
|
||||
|
||||
// Display detailed results
|
||||
return display.PrintDetailedUpdates(localCache.Updates, exportFormat)
|
||||
}
|
||||
|
||||
// formatTimeSince formats a duration as "X time ago"
|
||||
func formatTimeSince(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
if duration < time.Minute {
|
||||
return fmt.Sprintf("%d seconds ago", int(duration.Seconds()))
|
||||
} else if duration < time.Hour {
|
||||
return fmt.Sprintf("%d minutes ago", int(duration.Minutes()))
|
||||
} else if duration < 24*time.Hour {
|
||||
return fmt.Sprintf("%d hours ago", int(duration.Hours()))
|
||||
} else {
|
||||
return fmt.Sprintf("%d days ago", int(duration.Hours()/24))
|
||||
}
|
||||
}
|
||||
35
aggregator-agent/go.mod
Normal file
35
aggregator-agent/go.mod
Normal file
@@ -0,0 +1,35 @@
|
||||
module github.com/aggregator-project/aggregator-agent
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/docker/docker v27.4.1+incompatible
|
||||
github.com/google/uuid v1.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.4.21 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
)
|
||||
124
aggregator-agent/go.sum
Normal file
124
aggregator-agent/go.sum
Normal file
@@ -0,0 +1,124 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro=
|
||||
github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4=
|
||||
github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
|
||||
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
129
aggregator-agent/internal/cache/local.go
vendored
Normal file
129
aggregator-agent/internal/cache/local.go
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// LocalCache stores scan results locally for offline viewing
|
||||
type LocalCache struct {
|
||||
LastScanTime time.Time `json:"last_scan_time"`
|
||||
LastCheckIn time.Time `json:"last_check_in"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
UpdateCount int `json:"update_count"`
|
||||
Updates []client.UpdateReportItem `json:"updates"`
|
||||
AgentStatus string `json:"agent_status"`
|
||||
}
|
||||
|
||||
// CacheDir is the directory where local cache is stored
|
||||
const CacheDir = "/var/lib/aggregator"
|
||||
|
||||
// CacheFile is the file where scan results are cached
|
||||
const CacheFile = "last_scan.json"
|
||||
|
||||
// GetCachePath returns the full path to the cache file
|
||||
func GetCachePath() string {
|
||||
return filepath.Join(CacheDir, CacheFile)
|
||||
}
|
||||
|
||||
// Load reads the local cache from disk
|
||||
func Load() (*LocalCache, error) {
|
||||
cachePath := GetCachePath()
|
||||
|
||||
// Check if cache file exists
|
||||
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
|
||||
// Return empty cache if file doesn't exist
|
||||
return &LocalCache{}, nil
|
||||
}
|
||||
|
||||
// Read cache file
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read cache file: %w", err)
|
||||
}
|
||||
|
||||
var cache LocalCache
|
||||
if err := json.Unmarshal(data, &cache); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse cache file: %w", err)
|
||||
}
|
||||
|
||||
return &cache, nil
|
||||
}
|
||||
|
||||
// Save writes the local cache to disk
|
||||
func (c *LocalCache) Save() error {
|
||||
cachePath := GetCachePath()
|
||||
|
||||
// Ensure cache directory exists
|
||||
if err := os.MkdirAll(CacheDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
// Marshal cache to JSON with indentation
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal cache: %w", err)
|
||||
}
|
||||
|
||||
// Write cache file with restricted permissions
|
||||
if err := os.WriteFile(cachePath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write cache file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateScanResults updates the cache with new scan results
|
||||
func (c *LocalCache) UpdateScanResults(updates []client.UpdateReportItem) {
|
||||
c.LastScanTime = time.Now()
|
||||
c.Updates = updates
|
||||
c.UpdateCount = len(updates)
|
||||
}
|
||||
|
||||
// UpdateCheckIn updates the last check-in time
|
||||
func (c *LocalCache) UpdateCheckIn() {
|
||||
c.LastCheckIn = time.Now()
|
||||
}
|
||||
|
||||
// SetAgentInfo sets agent identification information
|
||||
func (c *LocalCache) SetAgentInfo(agentID uuid.UUID, serverURL string) {
|
||||
c.AgentID = agentID
|
||||
c.ServerURL = serverURL
|
||||
}
|
||||
|
||||
// SetAgentStatus sets the current agent status
|
||||
func (c *LocalCache) SetAgentStatus(status string) {
|
||||
c.AgentStatus = status
|
||||
}
|
||||
|
||||
// IsExpired checks if the cache is older than the specified duration
|
||||
func (c *LocalCache) IsExpired(maxAge time.Duration) bool {
|
||||
return time.Since(c.LastScanTime) > maxAge
|
||||
}
|
||||
|
||||
// GetUpdatesByType returns updates filtered by package type
|
||||
func (c *LocalCache) GetUpdatesByType(packageType string) []client.UpdateReportItem {
|
||||
var filtered []client.UpdateReportItem
|
||||
for _, update := range c.Updates {
|
||||
if update.PackageType == packageType {
|
||||
filtered = append(filtered, update)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Clear clears the cache
|
||||
func (c *LocalCache) Clear() {
|
||||
c.LastScanTime = time.Time{}
|
||||
c.LastCheckIn = time.Time{}
|
||||
c.UpdateCount = 0
|
||||
c.Updates = []client.UpdateReportItem{}
|
||||
c.AgentStatus = ""
|
||||
}
|
||||
242
aggregator-agent/internal/client/client.go
Normal file
242
aggregator-agent/internal/client/client.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Client handles API communication with the server
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new API client
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
http: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRequest is the payload for agent registration
|
||||
type RegisterRequest 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"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// RegisterResponse is returned after successful registration
|
||||
type RegisterResponse struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
// Register registers the agent with the server
|
||||
func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/agents/register", c.baseURL)
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("registration failed: %s - %s", resp.Status, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result RegisterResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update client token
|
||||
c.token = result.Token
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// Command represents a command from the server
|
||||
type Command struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
}
|
||||
|
||||
// CommandsResponse contains pending commands
|
||||
type CommandsResponse struct {
|
||||
Commands []Command `json:"commands"`
|
||||
}
|
||||
|
||||
// GetCommands retrieves pending commands from the server
|
||||
func (c *Client) GetCommands(agentID uuid.UUID) ([]Command, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/agents/%s/commands", c.baseURL, agentID)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("failed to get commands: %s - %s", resp.Status, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result CommandsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result.Commands, nil
|
||||
}
|
||||
|
||||
// UpdateReport represents discovered updates
|
||||
type UpdateReport struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Updates []UpdateReportItem `json:"updates"`
|
||||
}
|
||||
|
||||
// UpdateReportItem represents a single update
|
||||
type UpdateReportItem struct {
|
||||
PackageType string `json:"package_type"`
|
||||
PackageName string `json:"package_name"`
|
||||
PackageDescription string `json:"package_description"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
AvailableVersion string `json:"available_version"`
|
||||
Severity string `json:"severity"`
|
||||
CVEList []string `json:"cve_list"`
|
||||
KBID string `json:"kb_id"`
|
||||
RepositorySource string `json:"repository_source"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// ReportUpdates sends discovered updates to the server
|
||||
func (c *Client) ReportUpdates(agentID uuid.UUID, report UpdateReport) error {
|
||||
url := fmt.Sprintf("%s/api/v1/agents/%s/updates", c.baseURL, agentID)
|
||||
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("failed to report updates: %s - %s", resp.Status, string(bodyBytes))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogReport represents an execution log
|
||||
type LogReport struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Action string `json:"action"`
|
||||
Result string `json:"result"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
}
|
||||
|
||||
// ReportLog sends an execution log to the server
|
||||
func (c *Client) ReportLog(agentID uuid.UUID, report LogReport) error {
|
||||
url := fmt.Sprintf("%s/api/v1/agents/%s/logs", c.baseURL, agentID)
|
||||
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("failed to report log: %s - %s", resp.Status, string(bodyBytes))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DetectSystem returns basic system information
|
||||
func DetectSystem() (osType, osVersion, osArch string) {
|
||||
osType = runtime.GOOS
|
||||
osArch = runtime.GOARCH
|
||||
|
||||
// Read OS version (simplified for now)
|
||||
switch osType {
|
||||
case "linux":
|
||||
data, _ := os.ReadFile("/etc/os-release")
|
||||
if data != nil {
|
||||
// Parse os-release file (simplified)
|
||||
osVersion = "Linux"
|
||||
}
|
||||
case "windows":
|
||||
osVersion = "Windows"
|
||||
case "darwin":
|
||||
osVersion = "macOS"
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
63
aggregator-agent/internal/config/config.go
Normal file
63
aggregator-agent/internal/config/config.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Config holds agent configuration
|
||||
type Config struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
CheckInInterval int `json:"check_in_interval"`
|
||||
}
|
||||
|
||||
// Load reads configuration from file
|
||||
func Load(configPath string) (*Config, error) {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
// Read config file
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Return empty config if file doesn't exist
|
||||
return &Config{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// Save writes configuration to file
|
||||
func (c *Config) Save(configPath string) error {
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRegistered checks if the agent is registered
|
||||
func (c *Config) IsRegistered() bool {
|
||||
return c.AgentID != uuid.Nil && c.Token != ""
|
||||
}
|
||||
401
aggregator-agent/internal/display/terminal.go
Normal file
401
aggregator-agent/internal/display/terminal.go
Normal file
@@ -0,0 +1,401 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
)
|
||||
|
||||
// Color codes for terminal output
|
||||
const (
|
||||
ColorReset = "\033[0m"
|
||||
ColorRed = "\033[31m"
|
||||
ColorGreen = "\033[32m"
|
||||
ColorYellow = "\033[33m"
|
||||
ColorBlue = "\033[34m"
|
||||
ColorPurple = "\033[35m"
|
||||
ColorCyan = "\033[36m"
|
||||
ColorWhite = "\033[37m"
|
||||
ColorBold = "\033[1m"
|
||||
)
|
||||
|
||||
// SeverityColors maps severity levels to colors
|
||||
var SeverityColors = map[string]string{
|
||||
"critical": ColorRed,
|
||||
"high": ColorRed,
|
||||
"medium": ColorYellow,
|
||||
"moderate": ColorYellow,
|
||||
"low": ColorGreen,
|
||||
"info": ColorBlue,
|
||||
}
|
||||
|
||||
// PrintScanResults displays scan results in a pretty format
|
||||
func PrintScanResults(updates []client.UpdateReportItem, exportFormat string) error {
|
||||
// Handle export formats
|
||||
if exportFormat != "" {
|
||||
return exportResults(updates, exportFormat)
|
||||
}
|
||||
|
||||
// Count updates by type
|
||||
aptCount := 0
|
||||
dockerCount := 0
|
||||
otherCount := 0
|
||||
|
||||
for _, update := range updates {
|
||||
switch update.PackageType {
|
||||
case "apt":
|
||||
aptCount++
|
||||
case "docker":
|
||||
dockerCount++
|
||||
default:
|
||||
otherCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Header
|
||||
fmt.Printf("%s🚩 RedFlag Update Scan Results%s\n", ColorBold+ColorRed, ColorReset)
|
||||
fmt.Printf("%s%sScan completed: %s%s\n", ColorBold, ColorCyan, time.Now().Format("2006-01-02 15:04:05"), ColorReset)
|
||||
fmt.Println()
|
||||
|
||||
// Summary
|
||||
if len(updates) == 0 {
|
||||
fmt.Printf("%s✅ No updates available - system is up to date!%s\n", ColorBold+ColorGreen, ColorReset)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s📊 Summary:%s\n", ColorBold+ColorBlue, ColorReset)
|
||||
fmt.Printf(" Total updates: %s%d%s\n", ColorBold+ColorYellow, len(updates), ColorReset)
|
||||
|
||||
if aptCount > 0 {
|
||||
fmt.Printf(" APT packages: %s%d%s\n", ColorBold+ColorCyan, aptCount, ColorReset)
|
||||
}
|
||||
if dockerCount > 0 {
|
||||
fmt.Printf(" Docker images: %s%d%s\n", ColorBold+ColorCyan, dockerCount, ColorReset)
|
||||
}
|
||||
if otherCount > 0 {
|
||||
fmt.Printf(" Other: %s%d%s\n", ColorBold+ColorCyan, otherCount, ColorReset)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Group by package type
|
||||
if aptCount > 0 {
|
||||
printAPTUpdates(updates)
|
||||
}
|
||||
|
||||
if dockerCount > 0 {
|
||||
printDockerUpdates(updates)
|
||||
}
|
||||
|
||||
if otherCount > 0 {
|
||||
printOtherUpdates(updates)
|
||||
}
|
||||
|
||||
// Footer
|
||||
fmt.Println()
|
||||
fmt.Printf("%s💡 Tip: Use --list-updates for detailed information or --export=json for automation%s\n", ColorBold+ColorYellow, ColorReset)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printAPTUpdates displays APT package updates
|
||||
func printAPTUpdates(updates []client.UpdateReportItem) {
|
||||
fmt.Printf("%s📦 APT Package Updates%s\n", ColorBold+ColorBlue, ColorReset)
|
||||
fmt.Println(strings.Repeat("─", 50))
|
||||
|
||||
for _, update := range updates {
|
||||
if update.PackageType != "apt" {
|
||||
continue
|
||||
}
|
||||
|
||||
severityColor := getSeverityColor(update.Severity)
|
||||
packageIcon := getPackageIcon(update.Severity)
|
||||
|
||||
fmt.Printf("%s %s%s%s\n", packageIcon, ColorBold, update.PackageName, ColorReset)
|
||||
fmt.Printf(" Version: %s→%s\n",
|
||||
getVersionColor(update.CurrentVersion),
|
||||
getVersionColor(update.AvailableVersion))
|
||||
|
||||
if update.Severity != "" {
|
||||
fmt.Printf(" Severity: %s%s%s\n", severityColor, update.Severity, ColorReset)
|
||||
}
|
||||
|
||||
if update.PackageDescription != "" {
|
||||
fmt.Printf(" Description: %s\n", truncateString(update.PackageDescription, 60))
|
||||
}
|
||||
|
||||
if len(update.CVEList) > 0 {
|
||||
fmt.Printf(" CVEs: %s\n", strings.Join(update.CVEList, ", "))
|
||||
}
|
||||
|
||||
if update.RepositorySource != "" {
|
||||
fmt.Printf(" Source: %s\n", update.RepositorySource)
|
||||
}
|
||||
|
||||
if update.SizeBytes > 0 {
|
||||
fmt.Printf(" Size: %s\n", formatBytes(update.SizeBytes))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// printDockerUpdates displays Docker image updates
|
||||
func printDockerUpdates(updates []client.UpdateReportItem) {
|
||||
fmt.Printf("%s🐳 Docker Image Updates%s\n", ColorBold+ColorBlue, ColorReset)
|
||||
fmt.Println(strings.Repeat("─", 50))
|
||||
|
||||
for _, update := range updates {
|
||||
if update.PackageType != "docker" {
|
||||
continue
|
||||
}
|
||||
|
||||
severityColor := getSeverityColor(update.Severity)
|
||||
imageIcon := "🐳"
|
||||
|
||||
fmt.Printf("%s %s%s%s\n", imageIcon, ColorBold, update.PackageName, ColorReset)
|
||||
|
||||
if update.Severity != "" {
|
||||
fmt.Printf(" Severity: %s%s%s\n", severityColor, update.Severity, ColorReset)
|
||||
}
|
||||
|
||||
// Show digest comparison if available
|
||||
if update.CurrentVersion != "" && update.AvailableVersion != "" {
|
||||
fmt.Printf(" Digest: %s→%s\n",
|
||||
truncateString(update.CurrentVersion, 12),
|
||||
truncateString(update.AvailableVersion, 12))
|
||||
}
|
||||
|
||||
if update.PackageDescription != "" {
|
||||
fmt.Printf(" Description: %s\n", truncateString(update.PackageDescription, 60))
|
||||
}
|
||||
|
||||
if len(update.CVEList) > 0 {
|
||||
fmt.Printf(" CVEs: %s\n", strings.Join(update.CVEList, ", "))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// printOtherUpdates displays updates from other package managers
|
||||
func printOtherUpdates(updates []client.UpdateReportItem) {
|
||||
fmt.Printf("%s📋 Other Updates%s\n", ColorBold+ColorBlue, ColorReset)
|
||||
fmt.Println(strings.Repeat("─", 50))
|
||||
|
||||
for _, update := range updates {
|
||||
if update.PackageType == "apt" || update.PackageType == "docker" {
|
||||
continue
|
||||
}
|
||||
|
||||
severityColor := getSeverityColor(update.Severity)
|
||||
packageIcon := "📦"
|
||||
|
||||
fmt.Printf("%s %s%s%s (%s)\n", packageIcon, ColorBold, update.PackageName, ColorReset, update.PackageType)
|
||||
fmt.Printf(" Version: %s→%s\n",
|
||||
getVersionColor(update.CurrentVersion),
|
||||
getVersionColor(update.AvailableVersion))
|
||||
|
||||
if update.Severity != "" {
|
||||
fmt.Printf(" Severity: %s%s%s\n", severityColor, update.Severity, ColorReset)
|
||||
}
|
||||
|
||||
if update.PackageDescription != "" {
|
||||
fmt.Printf(" Description: %s\n", truncateString(update.PackageDescription, 60))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// PrintDetailedUpdates shows full details for all updates
|
||||
func PrintDetailedUpdates(updates []client.UpdateReportItem, exportFormat string) error {
|
||||
// Handle export formats
|
||||
if exportFormat != "" {
|
||||
return exportResults(updates, exportFormat)
|
||||
}
|
||||
|
||||
fmt.Printf("%s🔍 Detailed Update Information%s\n", ColorBold+ColorPurple, ColorReset)
|
||||
fmt.Printf("%sGenerated: %s%s\n\n", ColorCyan, time.Now().Format("2006-01-02 15:04:05"), ColorReset)
|
||||
|
||||
if len(updates) == 0 {
|
||||
fmt.Printf("%s✅ No updates available%s\n", ColorBold+ColorGreen, ColorReset)
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, update := range updates {
|
||||
fmt.Printf("%sUpdate #%d%s\n", ColorBold+ColorYellow, i+1, ColorReset)
|
||||
fmt.Println(strings.Repeat("═", 60))
|
||||
|
||||
fmt.Printf("%sPackage:%s %s\n", ColorBold, ColorReset, update.PackageName)
|
||||
fmt.Printf("%sType:%s %s\n", ColorBold, ColorReset, update.PackageType)
|
||||
fmt.Printf("%sCurrent Version:%s %s\n", ColorBold, ColorReset, update.CurrentVersion)
|
||||
fmt.Printf("%sAvailable Version:%s %s\n", ColorBold, ColorReset, update.AvailableVersion)
|
||||
|
||||
if update.Severity != "" {
|
||||
severityColor := getSeverityColor(update.Severity)
|
||||
fmt.Printf("%sSeverity:%s %s%s%s\n", ColorBold, ColorReset, severityColor, update.Severity, ColorReset)
|
||||
}
|
||||
|
||||
if update.PackageDescription != "" {
|
||||
fmt.Printf("%sDescription:%s %s\n", ColorBold, ColorReset, update.PackageDescription)
|
||||
}
|
||||
|
||||
if len(update.CVEList) > 0 {
|
||||
fmt.Printf("%sCVE List:%s %s\n", ColorBold, ColorReset, strings.Join(update.CVEList, ", "))
|
||||
}
|
||||
|
||||
if update.KBID != "" {
|
||||
fmt.Printf("%sKB Article:%s %s\n", ColorBold, ColorReset, update.KBID)
|
||||
}
|
||||
|
||||
if update.RepositorySource != "" {
|
||||
fmt.Printf("%sRepository:%s %s\n", ColorBold, ColorReset, update.RepositorySource)
|
||||
}
|
||||
|
||||
if update.SizeBytes > 0 {
|
||||
fmt.Printf("%sSize:%s %s\n", ColorBold, ColorReset, formatBytes(update.SizeBytes))
|
||||
}
|
||||
|
||||
if len(update.Metadata) > 0 {
|
||||
fmt.Printf("%sMetadata:%s\n", ColorBold, ColorReset)
|
||||
for key, value := range update.Metadata {
|
||||
fmt.Printf(" %s: %v\n", key, value)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintAgentStatus displays agent status information
|
||||
func PrintAgentStatus(agentID string, serverURL string, lastCheckIn time.Time, lastScan time.Time, updateCount int, agentStatus string) {
|
||||
fmt.Printf("%s🚩 RedFlag Agent Status%s\n", ColorBold+ColorRed, ColorReset)
|
||||
fmt.Println(strings.Repeat("─", 40))
|
||||
|
||||
fmt.Printf("%sAgent ID:%s %s\n", ColorBold, ColorReset, agentID)
|
||||
fmt.Printf("%sServer:%s %s\n", ColorBold, ColorReset, serverURL)
|
||||
fmt.Printf("%sStatus:%s %s%s%s\n", ColorBold, ColorReset, getSeverityColor(agentStatus), agentStatus, ColorReset)
|
||||
|
||||
if !lastCheckIn.IsZero() {
|
||||
fmt.Printf("%sLast Check-in:%s %s\n", ColorBold, ColorReset, formatTimeSince(lastCheckIn))
|
||||
} else {
|
||||
fmt.Printf("%sLast Check-in:%s %sNever%s\n", ColorBold, ColorReset, ColorYellow, ColorReset)
|
||||
}
|
||||
|
||||
if !lastScan.IsZero() {
|
||||
fmt.Printf("%sLast Scan:%s %s\n", ColorBold, ColorReset, formatTimeSince(lastScan))
|
||||
fmt.Printf("%sUpdates Found:%s %s%d%s\n", ColorBold, ColorReset, ColorYellow, updateCount, ColorReset)
|
||||
} else {
|
||||
fmt.Printf("%sLast Scan:%s %sNever%s\n", ColorBold, ColorReset, ColorYellow, ColorReset)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func getSeverityColor(severity string) string {
|
||||
if color, ok := SeverityColors[severity]; ok {
|
||||
return color
|
||||
}
|
||||
return ColorWhite
|
||||
}
|
||||
|
||||
func getPackageIcon(severity string) string {
|
||||
switch strings.ToLower(severity) {
|
||||
case "critical", "high":
|
||||
return "🔴"
|
||||
case "medium", "moderate":
|
||||
return "🟡"
|
||||
case "low":
|
||||
return "🟢"
|
||||
default:
|
||||
return "🔵"
|
||||
}
|
||||
}
|
||||
|
||||
func getVersionColor(version string) string {
|
||||
if version == "" {
|
||||
return ColorRed + "unknown" + ColorReset
|
||||
}
|
||||
return ColorCyan + version + ColorReset
|
||||
}
|
||||
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func formatTimeSince(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
if duration < time.Minute {
|
||||
return fmt.Sprintf("%d seconds ago", int(duration.Seconds()))
|
||||
} else if duration < time.Hour {
|
||||
return fmt.Sprintf("%d minutes ago", int(duration.Minutes()))
|
||||
} else if duration < 24*time.Hour {
|
||||
return fmt.Sprintf("%d hours ago", int(duration.Hours()))
|
||||
} else {
|
||||
return fmt.Sprintf("%d days ago", int(duration.Hours()/24))
|
||||
}
|
||||
}
|
||||
|
||||
func exportResults(updates []client.UpdateReportItem, format string) error {
|
||||
switch strings.ToLower(format) {
|
||||
case "json":
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(updates)
|
||||
|
||||
case "csv":
|
||||
return exportCSV(updates)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported export format: %s (supported: json, csv)", format)
|
||||
}
|
||||
}
|
||||
|
||||
func exportCSV(updates []client.UpdateReportItem) error {
|
||||
// Print CSV header
|
||||
fmt.Println("PackageType,PackageName,CurrentVersion,AvailableVersion,Severity,CVEList,Description,SizeBytes")
|
||||
|
||||
// Print each update as CSV row
|
||||
for _, update := range updates {
|
||||
cveList := strings.Join(update.CVEList, ";")
|
||||
description := strings.ReplaceAll(update.PackageDescription, ",", ";")
|
||||
description = strings.ReplaceAll(description, "\n", " ")
|
||||
|
||||
fmt.Printf("%s,%s,%s,%s,%s,%s,%s,%d\n",
|
||||
update.PackageType,
|
||||
update.PackageName,
|
||||
update.CurrentVersion,
|
||||
update.AvailableVersion,
|
||||
update.Severity,
|
||||
cveList,
|
||||
description,
|
||||
update.SizeBytes,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
90
aggregator-agent/internal/scanner/apt.go
Normal file
90
aggregator-agent/internal/scanner/apt.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
)
|
||||
|
||||
// APTScanner scans for APT package updates
|
||||
type APTScanner struct{}
|
||||
|
||||
// NewAPTScanner creates a new APT scanner
|
||||
func NewAPTScanner() *APTScanner {
|
||||
return &APTScanner{}
|
||||
}
|
||||
|
||||
// IsAvailable checks if APT is available on this system
|
||||
func (s *APTScanner) IsAvailable() bool {
|
||||
_, err := exec.LookPath("apt")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Scan scans for available APT updates
|
||||
func (s *APTScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
// Update package cache (sudo may be required, but try anyway)
|
||||
updateCmd := exec.Command("apt-get", "update")
|
||||
updateCmd.Run() // Ignore errors since we might not have sudo
|
||||
|
||||
// Get upgradable packages
|
||||
cmd := exec.Command("apt", "list", "--upgradable")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to run apt list: %w", err)
|
||||
}
|
||||
|
||||
return parseAPTOutput(output)
|
||||
}
|
||||
|
||||
func parseAPTOutput(output []byte) ([]client.UpdateReportItem, error) {
|
||||
var updates []client.UpdateReportItem
|
||||
scanner := bufio.NewScanner(bytes.NewReader(output))
|
||||
|
||||
// Regex to parse apt output:
|
||||
// package/repo version arch [upgradable from: old_version]
|
||||
re := regexp.MustCompile(`^([^\s/]+)/([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+\[upgradable from:\s+([^\]]+)\]`)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "Listing...") {
|
||||
continue
|
||||
}
|
||||
|
||||
matches := re.FindStringSubmatch(line)
|
||||
if len(matches) < 6 {
|
||||
continue
|
||||
}
|
||||
|
||||
packageName := matches[1]
|
||||
repository := matches[2]
|
||||
newVersion := matches[3]
|
||||
oldVersion := matches[5]
|
||||
|
||||
// Determine severity (simplified - in production, query Ubuntu Security Advisories)
|
||||
severity := "moderate"
|
||||
if strings.Contains(repository, "security") {
|
||||
severity = "important"
|
||||
}
|
||||
|
||||
update := client.UpdateReportItem{
|
||||
PackageType: "apt",
|
||||
PackageName: packageName,
|
||||
CurrentVersion: oldVersion,
|
||||
AvailableVersion: newVersion,
|
||||
Severity: severity,
|
||||
RepositorySource: repository,
|
||||
Metadata: map[string]interface{}{
|
||||
"architecture": matches[4],
|
||||
},
|
||||
}
|
||||
|
||||
updates = append(updates, update)
|
||||
}
|
||||
|
||||
return updates, nil
|
||||
}
|
||||
162
aggregator-agent/internal/scanner/docker.go
Normal file
162
aggregator-agent/internal/scanner/docker.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
// DockerScanner scans for Docker image updates
|
||||
type DockerScanner struct {
|
||||
client *dockerclient.Client
|
||||
registryClient *RegistryClient
|
||||
}
|
||||
|
||||
// NewDockerScanner creates a new Docker scanner
|
||||
func NewDockerScanner() (*DockerScanner, error) {
|
||||
cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DockerScanner{
|
||||
client: cli,
|
||||
registryClient: NewRegistryClient(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsAvailable checks if Docker is available on this system
|
||||
func (s *DockerScanner) IsAvailable() bool {
|
||||
_, err := exec.LookPath("docker")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to ping Docker daemon
|
||||
if s.client != nil {
|
||||
_, err := s.client.Ping(context.Background())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Scan scans for available Docker image updates
|
||||
func (s *DockerScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// List all containers
|
||||
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list containers: %w", err)
|
||||
}
|
||||
|
||||
var updates []client.UpdateReportItem
|
||||
seenImages := make(map[string]bool)
|
||||
|
||||
for _, c := range containers {
|
||||
imageName := c.Image
|
||||
|
||||
// Skip if we've already checked this image
|
||||
if seenImages[imageName] {
|
||||
continue
|
||||
}
|
||||
seenImages[imageName] = true
|
||||
|
||||
// Get current image details
|
||||
imageInspect, _, err := s.client.ImageInspectWithRaw(ctx, imageName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse image name and tag
|
||||
parts := strings.Split(imageName, ":")
|
||||
baseImage := parts[0]
|
||||
currentTag := "latest"
|
||||
if len(parts) > 1 {
|
||||
currentTag = parts[1]
|
||||
}
|
||||
|
||||
// Check if update is available by comparing with registry
|
||||
hasUpdate, remoteDigest := s.checkForUpdate(ctx, baseImage, currentTag, imageInspect.ID)
|
||||
|
||||
if hasUpdate {
|
||||
// Extract short digest for display (first 12 chars of sha256 hash)
|
||||
localDigest := imageInspect.ID
|
||||
remoteShortDigest := "unknown"
|
||||
if len(remoteDigest) > 7 {
|
||||
// Format: sha256:abcd... -> take first 12 chars of hash
|
||||
parts := strings.SplitN(remoteDigest, ":", 2)
|
||||
if len(parts) == 2 && len(parts[1]) >= 12 {
|
||||
remoteShortDigest = parts[1][:12]
|
||||
}
|
||||
}
|
||||
|
||||
update := client.UpdateReportItem{
|
||||
PackageType: "docker_image",
|
||||
PackageName: imageName,
|
||||
PackageDescription: fmt.Sprintf("Container: %s", strings.Join(c.Names, ", ")),
|
||||
CurrentVersion: localDigest[:12], // Short hash
|
||||
AvailableVersion: remoteShortDigest,
|
||||
Severity: "moderate",
|
||||
RepositorySource: baseImage,
|
||||
Metadata: map[string]interface{}{
|
||||
"container_id": c.ID[:12],
|
||||
"container_names": c.Names,
|
||||
"container_state": c.State,
|
||||
"image_created": imageInspect.Created,
|
||||
"local_full_digest": localDigest,
|
||||
"remote_digest": remoteDigest,
|
||||
},
|
||||
}
|
||||
|
||||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
// checkForUpdate checks if a newer image version is available by comparing digests
|
||||
// Returns (hasUpdate bool, remoteDigest string)
|
||||
//
|
||||
// This implementation:
|
||||
// 1. Queries Docker Registry HTTP API v2 for remote manifest
|
||||
// 2. Compares image digests (sha256 hashes) between local and remote
|
||||
// 3. Handles authentication for Docker Hub (anonymous pull)
|
||||
// 4. Caches registry responses (5 min TTL) to respect rate limits
|
||||
// 5. Returns both the update status and remote digest for metadata
|
||||
//
|
||||
// Note: This compares exact digests. If local digest != remote digest, an update exists.
|
||||
// This works for all tags including "latest", version tags, etc.
|
||||
func (s *DockerScanner) checkForUpdate(ctx context.Context, imageName, tag, currentID string) (bool, string) {
|
||||
// Get remote digest from registry
|
||||
remoteDigest, err := s.registryClient.GetRemoteDigest(ctx, imageName, tag)
|
||||
if err != nil {
|
||||
// If we can't check the registry, log the error but don't report an update
|
||||
// This prevents false positives when registry is down or rate-limited
|
||||
fmt.Printf("Warning: Failed to check registry for %s:%s: %v\n", imageName, tag, err)
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Compare digests
|
||||
// Local Docker image ID format: sha256:abc123...
|
||||
// Remote digest format: sha256:def456...
|
||||
// If they differ, an update is available
|
||||
hasUpdate := currentID != remoteDigest
|
||||
|
||||
return hasUpdate, remoteDigest
|
||||
}
|
||||
|
||||
// Close closes the Docker client
|
||||
func (s *DockerScanner) Close() error {
|
||||
if s.client != nil {
|
||||
return s.client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
259
aggregator-agent/internal/scanner/registry.go
Normal file
259
aggregator-agent/internal/scanner/registry.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RegistryClient handles communication with Docker registries (Docker Hub and custom registries)
|
||||
type RegistryClient struct {
|
||||
httpClient *http.Client
|
||||
cache *manifestCache
|
||||
}
|
||||
|
||||
// manifestCache stores registry responses to avoid hitting rate limits
|
||||
type manifestCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*cacheEntry
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
digest string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// ManifestResponse represents the response from a Docker Registry API v2 manifest request
|
||||
type ManifestResponse struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
MediaType string `json:"mediaType"`
|
||||
Config struct {
|
||||
Digest string `json:"digest"`
|
||||
} `json:"config"`
|
||||
}
|
||||
|
||||
// DockerHubTokenResponse represents the authentication token response from Docker Hub
|
||||
type DockerHubTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
}
|
||||
|
||||
// NewRegistryClient creates a new registry client with caching
|
||||
func NewRegistryClient() *RegistryClient {
|
||||
return &RegistryClient{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
cache: &manifestCache{
|
||||
entries: make(map[string]*cacheEntry),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetRemoteDigest fetches the digest of a remote image from the registry
|
||||
// Returns the digest string (e.g., "sha256:abc123...") or an error
|
||||
func (c *RegistryClient) GetRemoteDigest(ctx context.Context, imageName, tag string) (string, error) {
|
||||
// Parse image name to determine registry and repository
|
||||
registry, repository := parseImageName(imageName)
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("%s/%s:%s", registry, repository, tag)
|
||||
if digest := c.cache.get(cacheKey); digest != "" {
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// Get authentication token (if needed)
|
||||
token, err := c.getAuthToken(ctx, registry, repository)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get auth token: %w", err)
|
||||
}
|
||||
|
||||
// Fetch manifest from registry
|
||||
digest, err := c.fetchManifestDigest(ctx, registry, repository, tag, token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch manifest: %w", err)
|
||||
}
|
||||
|
||||
// Cache the result (5 minute TTL to avoid hammering registries)
|
||||
c.cache.set(cacheKey, digest, 5*time.Minute)
|
||||
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// parseImageName splits an image name into registry and repository
|
||||
// Examples:
|
||||
// - "nginx" -> ("registry-1.docker.io", "library/nginx")
|
||||
// - "myuser/myimage" -> ("registry-1.docker.io", "myuser/myimage")
|
||||
// - "gcr.io/myproject/myimage" -> ("gcr.io", "myproject/myimage")
|
||||
func parseImageName(imageName string) (registry, repository string) {
|
||||
parts := strings.Split(imageName, "/")
|
||||
|
||||
// Check if first part looks like a domain (contains . or :)
|
||||
if len(parts) >= 2 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
|
||||
// Custom registry: gcr.io/myproject/myimage
|
||||
registry = parts[0]
|
||||
repository = strings.Join(parts[1:], "/")
|
||||
} else if len(parts) == 1 {
|
||||
// Official image: nginx -> library/nginx
|
||||
registry = "registry-1.docker.io"
|
||||
repository = "library/" + parts[0]
|
||||
} else {
|
||||
// User image: myuser/myimage
|
||||
registry = "registry-1.docker.io"
|
||||
repository = imageName
|
||||
}
|
||||
|
||||
return registry, repository
|
||||
}
|
||||
|
||||
// getAuthToken obtains an authentication token for the registry
|
||||
// For Docker Hub, uses the token authentication flow
|
||||
// For other registries, may need different auth mechanisms (TODO: implement)
|
||||
func (c *RegistryClient) getAuthToken(ctx context.Context, registry, repository string) (string, error) {
|
||||
// Docker Hub token authentication
|
||||
if registry == "registry-1.docker.io" {
|
||||
return c.getDockerHubToken(ctx, repository)
|
||||
}
|
||||
|
||||
// For other registries, we'll try unauthenticated first
|
||||
// TODO: Support authentication for private registries (basic auth, bearer tokens, etc.)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// getDockerHubToken obtains a token from Docker Hub's authentication service
|
||||
func (c *RegistryClient) getDockerHubToken(ctx context.Context, repository string) (string, error) {
|
||||
authURL := fmt.Sprintf(
|
||||
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull",
|
||||
repository,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp DockerHubTokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode token response: %w", err)
|
||||
}
|
||||
|
||||
// Docker Hub can return either 'token' or 'access_token'
|
||||
if tokenResp.Token != "" {
|
||||
return tokenResp.Token, nil
|
||||
}
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
// fetchManifestDigest fetches the manifest from the registry and extracts the digest
|
||||
func (c *RegistryClient) fetchManifestDigest(ctx context.Context, registry, repository, tag, token string) (string, error) {
|
||||
// Build manifest URL
|
||||
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registry, repository, tag)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", manifestURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Set required headers
|
||||
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return "", fmt.Errorf("rate limited by registry (429 Too Many Requests)")
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return "", fmt.Errorf("unauthorized: authentication failed for %s/%s:%s", registry, repository, tag)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("manifest request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Try to get digest from Docker-Content-Digest header first (faster)
|
||||
if digest := resp.Header.Get("Docker-Content-Digest"); digest != "" {
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// Fallback: parse manifest and extract config digest
|
||||
var manifest ManifestResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
|
||||
return "", fmt.Errorf("failed to decode manifest: %w", err)
|
||||
}
|
||||
|
||||
if manifest.Config.Digest == "" {
|
||||
return "", fmt.Errorf("manifest does not contain a config digest")
|
||||
}
|
||||
|
||||
return manifest.Config.Digest, nil
|
||||
}
|
||||
|
||||
// manifestCache methods
|
||||
|
||||
func (mc *manifestCache) get(key string) string {
|
||||
mc.mu.RLock()
|
||||
defer mc.mu.RUnlock()
|
||||
|
||||
entry, exists := mc.entries[key]
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
// Entry expired
|
||||
delete(mc.entries, key)
|
||||
return ""
|
||||
}
|
||||
|
||||
return entry.digest
|
||||
}
|
||||
|
||||
func (mc *manifestCache) set(key, digest string, ttl time.Duration) {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
|
||||
mc.entries[key] = &cacheEntry{
|
||||
digest: digest,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupExpired removes expired entries from the cache (called periodically)
|
||||
func (mc *manifestCache) cleanupExpired() {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, entry := range mc.entries {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(mc.entries, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
aggregator-server/.env.example
Normal file
12
aggregator-server/.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Server Configuration
|
||||
SERVER_PORT=8080
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgres://aggregator:aggregator@localhost:5432/aggregator?sslmode=disable
|
||||
|
||||
# JWT Secret (CHANGE IN PRODUCTION!)
|
||||
JWT_SECRET=change-me-in-production-use-long-random-string
|
||||
|
||||
# Agent Configuration
|
||||
CHECK_IN_INTERVAL=300 # seconds
|
||||
OFFLINE_THRESHOLD=600 # seconds before marking agent offline
|
||||
86
aggregator-server/cmd/server/main.go
Normal file
86
aggregator-server/cmd/server/main.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
"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/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load configuration:", err)
|
||||
}
|
||||
|
||||
// Set JWT secret
|
||||
middleware.JWTSecret = cfg.JWTSecret
|
||||
|
||||
// Connect to database
|
||||
db, err := database.Connect(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Run migrations
|
||||
migrationsPath := filepath.Join("internal", "database", "migrations")
|
||||
if err := db.Migrate(migrationsPath); err != nil {
|
||||
log.Fatal("Failed to run migrations:", err)
|
||||
}
|
||||
|
||||
// Initialize queries
|
||||
agentQueries := queries.NewAgentQueries(db.DB)
|
||||
updateQueries := queries.NewUpdateQueries(db.DB)
|
||||
commandQueries := queries.NewCommandQueries(db.DB)
|
||||
|
||||
// Initialize handlers
|
||||
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, cfg.CheckInInterval)
|
||||
updateHandler := handlers.NewUpdateHandler(updateQueries)
|
||||
|
||||
// Setup router
|
||||
router := gin.Default()
|
||||
|
||||
// Health check
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "healthy"})
|
||||
})
|
||||
|
||||
// API routes
|
||||
api := router.Group("/api/v1")
|
||||
{
|
||||
// Public routes
|
||||
api.POST("/agents/register", agentHandler.RegisterAgent)
|
||||
|
||||
// Protected agent routes
|
||||
agents := api.Group("/agents")
|
||||
agents.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
agents.GET("/:id/commands", agentHandler.GetCommands)
|
||||
agents.POST("/:id/updates", updateHandler.ReportUpdates)
|
||||
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)
|
||||
}
|
||||
|
||||
// Start server
|
||||
addr := ":" + cfg.ServerPort
|
||||
fmt.Printf("\n🚩 RedFlag Aggregator Server starting on %s\n\n", addr)
|
||||
if err := router.Run(addr); err != nil {
|
||||
log.Fatal("Failed to start server:", err)
|
||||
}
|
||||
}
|
||||
46
aggregator-server/go.mod
Normal file
46
aggregator-server/go.mod
Normal file
@@ -0,0 +1,46 @@
|
||||
module github.com/aggregator-project/aggregator-server
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
104
aggregator-server/go.sum
Normal file
104
aggregator-server/go.sum
Normal file
@@ -0,0 +1,104 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
178
aggregator-server/internal/api/handlers/agents.go
Normal file
178
aggregator-server/internal/api/handlers/agents.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-server/internal/api/middleware"
|
||||
"github.com/aggregator-project/aggregator-server/internal/database/queries"
|
||||
"github.com/aggregator-project/aggregator-server/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AgentHandler struct {
|
||||
agentQueries *queries.AgentQueries
|
||||
commandQueries *queries.CommandQueries
|
||||
checkInInterval int
|
||||
}
|
||||
|
||||
func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, checkInInterval int) *AgentHandler {
|
||||
return &AgentHandler{
|
||||
agentQueries: aq,
|
||||
commandQueries: cq,
|
||||
checkInInterval: checkInInterval,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAgent handles agent registration
|
||||
func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
||||
var req models.AgentRegistrationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Create new agent
|
||||
agent := &models.Agent{
|
||||
ID: uuid.New(),
|
||||
Hostname: req.Hostname,
|
||||
OSType: req.OSType,
|
||||
OSVersion: req.OSVersion,
|
||||
OSArchitecture: req.OSArchitecture,
|
||||
AgentVersion: req.AgentVersion,
|
||||
LastSeen: time.Now(),
|
||||
Status: "online",
|
||||
Metadata: models.JSONB{},
|
||||
}
|
||||
|
||||
// Add metadata if provided
|
||||
if req.Metadata != nil {
|
||||
for k, v := range req.Metadata {
|
||||
agent.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Save to database
|
||||
if err := h.agentQueries.CreateAgent(agent); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register agent"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := middleware.GenerateAgentToken(agent.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return response
|
||||
response := models.AgentRegistrationResponse{
|
||||
AgentID: agent.ID,
|
||||
Token: token,
|
||||
Config: map[string]interface{}{
|
||||
"check_in_interval": h.checkInInterval,
|
||||
"server_url": c.Request.Host,
|
||||
},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetCommands returns pending commands for an agent
|
||||
func (h *AgentHandler) GetCommands(c *gin.Context) {
|
||||
agentID := c.MustGet("agent_id").(uuid.UUID)
|
||||
|
||||
// Update last_seen
|
||||
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get pending commands
|
||||
commands, err := h.commandQueries.GetPendingCommands(agentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve commands"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
commandItems := make([]models.CommandItem, 0, len(commands))
|
||||
for _, cmd := range commands {
|
||||
commandItems = append(commandItems, models.CommandItem{
|
||||
ID: cmd.ID.String(),
|
||||
Type: cmd.CommandType,
|
||||
Params: cmd.Params,
|
||||
})
|
||||
|
||||
// Mark as sent
|
||||
h.commandQueries.MarkCommandSent(cmd.ID)
|
||||
}
|
||||
|
||||
response := models.CommandsResponse{
|
||||
Commands: commandItems,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ListAgents returns all agents
|
||||
func (h *AgentHandler) ListAgents(c *gin.Context) {
|
||||
status := c.Query("status")
|
||||
osType := c.Query("os_type")
|
||||
|
||||
agents, err := h.agentQueries.ListAgents(status, osType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list agents"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"agents": agents,
|
||||
"total": len(agents),
|
||||
})
|
||||
}
|
||||
|
||||
// GetAgent returns a single agent by ID
|
||||
func (h *AgentHandler) GetAgent(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := h.agentQueries.GetAgentByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, agent)
|
||||
}
|
||||
|
||||
// TriggerScan creates a scan command for an agent
|
||||
func (h *AgentHandler) TriggerScan(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
agentID, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create scan command
|
||||
cmd := &models.AgentCommand{
|
||||
ID: uuid.New(),
|
||||
AgentID: agentID,
|
||||
CommandType: models.CommandTypeScanUpdates,
|
||||
Params: models.JSONB{},
|
||||
Status: models.CommandStatusPending,
|
||||
}
|
||||
|
||||
if err := h.commandQueries.CreateCommand(cmd); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create command"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "scan triggered", "command_id": cmd.ID})
|
||||
}
|
||||
163
aggregator-server/internal/api/handlers/updates.go
Normal file
163
aggregator-server/internal/api/handlers/updates.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-server/internal/database/queries"
|
||||
"github.com/aggregator-project/aggregator-server/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UpdateHandler struct {
|
||||
updateQueries *queries.UpdateQueries
|
||||
}
|
||||
|
||||
func NewUpdateHandler(uq *queries.UpdateQueries) *UpdateHandler {
|
||||
return &UpdateHandler{updateQueries: uq}
|
||||
}
|
||||
|
||||
// ReportUpdates handles update reports from agents
|
||||
func (h *UpdateHandler) ReportUpdates(c *gin.Context) {
|
||||
agentID := c.MustGet("agent_id").(uuid.UUID)
|
||||
|
||||
var req models.UpdateReportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Process each update
|
||||
for _, item := range req.Updates {
|
||||
update := &models.UpdatePackage{
|
||||
ID: uuid.New(),
|
||||
AgentID: agentID,
|
||||
PackageType: item.PackageType,
|
||||
PackageName: item.PackageName,
|
||||
PackageDescription: item.PackageDescription,
|
||||
CurrentVersion: item.CurrentVersion,
|
||||
AvailableVersion: item.AvailableVersion,
|
||||
Severity: item.Severity,
|
||||
CVEList: models.StringArray(item.CVEList),
|
||||
KBID: item.KBID,
|
||||
RepositorySource: item.RepositorySource,
|
||||
SizeBytes: item.SizeBytes,
|
||||
Status: "pending",
|
||||
Metadata: item.Metadata,
|
||||
}
|
||||
|
||||
if err := h.updateQueries.UpsertUpdate(update); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save update"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "updates recorded",
|
||||
"count": len(req.Updates),
|
||||
})
|
||||
}
|
||||
|
||||
// ListUpdates retrieves updates with filtering
|
||||
func (h *UpdateHandler) ListUpdates(c *gin.Context) {
|
||||
filters := &models.UpdateFilters{
|
||||
Status: c.Query("status"),
|
||||
Severity: c.Query("severity"),
|
||||
PackageType: c.Query("package_type"),
|
||||
}
|
||||
|
||||
// Parse agent_id if provided
|
||||
if agentIDStr := c.Query("agent_id"); agentIDStr != "" {
|
||||
agentID, err := uuid.Parse(agentIDStr)
|
||||
if err == nil {
|
||||
filters.AgentID = &agentID
|
||||
}
|
||||
}
|
||||
|
||||
// Parse pagination
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
filters.Page = page
|
||||
filters.PageSize = pageSize
|
||||
|
||||
updates, total, err := h.updateQueries.ListUpdates(filters)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list updates"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"updates": updates,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUpdate retrieves a single update by ID
|
||||
func (h *UpdateHandler) GetUpdate(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
|
||||
return
|
||||
}
|
||||
|
||||
update, err := h.updateQueries.GetUpdateByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "update not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, update)
|
||||
}
|
||||
|
||||
// ApproveUpdate marks an update as approved
|
||||
func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// For now, use "admin" as approver. Will integrate with proper auth later
|
||||
if err := h.updateQueries.ApproveUpdate(id, "admin"); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to approve update"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "update approved"})
|
||||
}
|
||||
|
||||
// ReportLog handles update execution logs from agents
|
||||
func (h *UpdateHandler) ReportLog(c *gin.Context) {
|
||||
agentID := c.MustGet("agent_id").(uuid.UUID)
|
||||
|
||||
var req models.UpdateLogRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
log := &models.UpdateLog{
|
||||
ID: uuid.New(),
|
||||
AgentID: agentID,
|
||||
Action: req.Action,
|
||||
Result: req.Result,
|
||||
Stdout: req.Stdout,
|
||||
Stderr: req.Stderr,
|
||||
ExitCode: req.ExitCode,
|
||||
DurationSeconds: req.DurationSeconds,
|
||||
ExecutedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := h.updateQueries.CreateUpdateLog(log); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save log"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "log recorded"})
|
||||
}
|
||||
71
aggregator-server/internal/api/middleware/auth.go
Normal file
71
aggregator-server/internal/api/middleware/auth.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AgentClaims represents JWT claims for agent authentication
|
||||
type AgentClaims struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// JWTSecret is set by the server at initialization
|
||||
var JWTSecret string
|
||||
|
||||
// GenerateAgentToken creates a new JWT token for an agent
|
||||
func GenerateAgentToken(agentID uuid.UUID) (string, error) {
|
||||
claims := AgentClaims{
|
||||
AgentID: agentID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(JWTSecret))
|
||||
}
|
||||
|
||||
// AuthMiddleware validates JWT tokens from agents
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization format"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &AgentClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*AgentClaims); ok {
|
||||
c.Set("agent_id", claims.AgentID)
|
||||
c.Next()
|
||||
} else {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"})
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
41
aggregator-server/internal/config/config.go
Normal file
41
aggregator-server/internal/config/config.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config holds the application configuration
|
||||
type Config struct {
|
||||
ServerPort string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
CheckInInterval int
|
||||
OfflineThreshold int
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables
|
||||
func Load() (*Config, error) {
|
||||
// Load .env file if it exists (for development)
|
||||
_ = godotenv.Load()
|
||||
|
||||
checkInInterval, _ := strconv.Atoi(getEnv("CHECK_IN_INTERVAL", "300"))
|
||||
offlineThreshold, _ := strconv.Atoi(getEnv("OFFLINE_THRESHOLD", "600"))
|
||||
|
||||
return &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"),
|
||||
CheckInInterval: checkInInterval,
|
||||
OfflineThreshold: offlineThreshold,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
76
aggregator-server/internal/database/db.go
Normal file
76
aggregator-server/internal/database/db.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// DB wraps the database connection
|
||||
type DB struct {
|
||||
*sqlx.DB
|
||||
}
|
||||
|
||||
// Connect establishes a connection to the PostgreSQL database
|
||||
func Connect(databaseURL string) (*DB, error) {
|
||||
db, err := sqlx.Connect("postgres", databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Configure connection pool
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
|
||||
// Test the connection
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return &DB{db}, nil
|
||||
}
|
||||
|
||||
// Migrate runs database migrations
|
||||
func (db *DB) Migrate(migrationsPath string) error {
|
||||
// Read migration files
|
||||
files, err := os.ReadDir(migrationsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migrations directory: %w", err)
|
||||
}
|
||||
|
||||
// Filter and sort .up.sql files
|
||||
var migrationFiles []string
|
||||
for _, file := range files {
|
||||
if strings.HasSuffix(file.Name(), ".up.sql") {
|
||||
migrationFiles = append(migrationFiles, file.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(migrationFiles)
|
||||
|
||||
// Execute migrations
|
||||
for _, filename := range migrationFiles {
|
||||
path := filepath.Join(migrationsPath, filename)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(string(content)); err != nil {
|
||||
return fmt.Errorf("failed to execute migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Executed migration: %s\n", filename)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (db *DB) Close() error {
|
||||
return db.DB.Close()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Drop tables in reverse order (respecting foreign key constraints)
|
||||
DROP TABLE IF EXISTS agent_commands;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TABLE IF EXISTS agent_tags;
|
||||
DROP TABLE IF EXISTS update_logs;
|
||||
DROP TABLE IF EXISTS update_packages;
|
||||
DROP TABLE IF EXISTS agent_specs;
|
||||
DROP TABLE IF EXISTS agents;
|
||||
|
||||
-- Drop extension
|
||||
DROP EXTENSION IF EXISTS "uuid-ossp";
|
||||
@@ -0,0 +1,127 @@
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Agents table
|
||||
CREATE TABLE agents (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
hostname VARCHAR(255) NOT NULL,
|
||||
os_type VARCHAR(50) NOT NULL CHECK (os_type IN ('windows', 'linux', 'macos')),
|
||||
os_version VARCHAR(100),
|
||||
os_architecture VARCHAR(20),
|
||||
agent_version VARCHAR(20) NOT NULL,
|
||||
last_seen TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
status VARCHAR(20) DEFAULT 'online' CHECK (status IN ('online', 'offline', 'error')),
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_agents_status ON agents(status);
|
||||
CREATE INDEX idx_agents_os_type ON agents(os_type);
|
||||
CREATE INDEX idx_agents_last_seen ON agents(last_seen);
|
||||
|
||||
-- Agent specs
|
||||
CREATE TABLE agent_specs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
|
||||
cpu_model VARCHAR(255),
|
||||
cpu_cores INTEGER,
|
||||
memory_total_mb INTEGER,
|
||||
disk_total_gb INTEGER,
|
||||
disk_free_gb INTEGER,
|
||||
network_interfaces JSONB,
|
||||
docker_installed BOOLEAN DEFAULT false,
|
||||
docker_version VARCHAR(50),
|
||||
package_managers TEXT[],
|
||||
collected_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_agent_specs_agent_id ON agent_specs(agent_id);
|
||||
|
||||
-- Update packages
|
||||
CREATE TABLE update_packages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
|
||||
package_type VARCHAR(50) NOT NULL,
|
||||
package_name VARCHAR(500) NOT NULL,
|
||||
package_description TEXT,
|
||||
current_version VARCHAR(100),
|
||||
available_version VARCHAR(100) NOT NULL,
|
||||
severity VARCHAR(20) CHECK (severity IN ('critical', 'important', 'moderate', 'low', 'none')),
|
||||
cve_list TEXT[],
|
||||
kb_id VARCHAR(50),
|
||||
repository_source VARCHAR(255),
|
||||
size_bytes BIGINT,
|
||||
status VARCHAR(30) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'scheduled', 'installing', 'installed', 'failed', 'ignored')),
|
||||
discovered_at TIMESTAMP DEFAULT NOW(),
|
||||
approved_by VARCHAR(255),
|
||||
approved_at TIMESTAMP,
|
||||
scheduled_for TIMESTAMP,
|
||||
installed_at TIMESTAMP,
|
||||
error_message TEXT,
|
||||
metadata JSONB,
|
||||
UNIQUE(agent_id, package_type, package_name, available_version)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_updates_status ON update_packages(status);
|
||||
CREATE INDEX idx_updates_agent ON update_packages(agent_id);
|
||||
CREATE INDEX idx_updates_severity ON update_packages(severity);
|
||||
CREATE INDEX idx_updates_package_type ON update_packages(package_type);
|
||||
CREATE INDEX idx_updates_composite ON update_packages(status, severity, agent_id);
|
||||
|
||||
-- Update logs
|
||||
CREATE TABLE update_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
|
||||
update_package_id UUID REFERENCES update_packages(id) ON DELETE SET NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
result VARCHAR(20) NOT NULL CHECK (result IN ('success', 'failed', 'partial')),
|
||||
stdout TEXT,
|
||||
stderr TEXT,
|
||||
exit_code INTEGER,
|
||||
duration_seconds INTEGER,
|
||||
executed_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_logs_agent ON update_logs(agent_id);
|
||||
CREATE INDEX idx_logs_result ON update_logs(result);
|
||||
CREATE INDEX idx_logs_executed_at ON update_logs(executed_at DESC);
|
||||
|
||||
-- Agent tags
|
||||
CREATE TABLE agent_tags (
|
||||
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
|
||||
tag VARCHAR(100) NOT NULL,
|
||||
PRIMARY KEY (agent_id, tag)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_agent_tags_tag ON agent_tags(tag);
|
||||
|
||||
-- Users (for authentication)
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(50) DEFAULT 'user' CHECK (role IN ('admin', 'user', 'readonly')),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
last_login TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
|
||||
-- Commands queue (for agent orchestration)
|
||||
CREATE TABLE agent_commands (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
|
||||
command_type VARCHAR(50) NOT NULL,
|
||||
params JSONB,
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'completed', 'failed')),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
sent_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
result JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX idx_commands_agent_status ON agent_commands(agent_id, status);
|
||||
CREATE INDEX idx_commands_created_at ON agent_commands(created_at DESC);
|
||||
83
aggregator-server/internal/database/queries/agents.go
Normal file
83
aggregator-server/internal/database/queries/agents.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-server/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type AgentQueries struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewAgentQueries(db *sqlx.DB) *AgentQueries {
|
||||
return &AgentQueries{db: db}
|
||||
}
|
||||
|
||||
// CreateAgent inserts a new agent into the database
|
||||
func (q *AgentQueries) CreateAgent(agent *models.Agent) error {
|
||||
query := `
|
||||
INSERT INTO agents (
|
||||
id, hostname, os_type, os_version, os_architecture,
|
||||
agent_version, last_seen, status, metadata
|
||||
) VALUES (
|
||||
:id, :hostname, :os_type, :os_version, :os_architecture,
|
||||
:agent_version, :last_seen, :status, :metadata
|
||||
)
|
||||
`
|
||||
_, err := q.db.NamedExec(query, agent)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAgentByID retrieves an agent by ID
|
||||
func (q *AgentQueries) GetAgentByID(id uuid.UUID) (*models.Agent, error) {
|
||||
var agent models.Agent
|
||||
query := `SELECT * FROM agents WHERE id = $1`
|
||||
err := q.db.Get(&agent, query, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &agent, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAgents returns all agents with optional filtering
|
||||
func (q *AgentQueries) ListAgents(status, osType string) ([]models.Agent, error) {
|
||||
var agents []models.Agent
|
||||
query := `SELECT * FROM agents WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if status != "" {
|
||||
query += ` AND status = $` + string(rune(argIdx+'0'))
|
||||
args = append(args, status)
|
||||
argIdx++
|
||||
}
|
||||
if osType != "" {
|
||||
query += ` AND os_type = $` + string(rune(argIdx+'0'))
|
||||
args = append(args, osType)
|
||||
}
|
||||
|
||||
query += ` ORDER BY last_seen DESC`
|
||||
err := q.db.Select(&agents, query, args...)
|
||||
return agents, err
|
||||
}
|
||||
|
||||
// MarkOfflineAgents marks agents as offline if they haven't checked in recently
|
||||
func (q *AgentQueries) MarkOfflineAgents(threshold time.Duration) error {
|
||||
query := `
|
||||
UPDATE agents
|
||||
SET status = 'offline'
|
||||
WHERE last_seen < $1 AND status = 'online'
|
||||
`
|
||||
_, err := q.db.Exec(query, time.Now().Add(-threshold))
|
||||
return err
|
||||
}
|
||||
79
aggregator-server/internal/database/queries/commands.go
Normal file
79
aggregator-server/internal/database/queries/commands.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-server/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type CommandQueries struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewCommandQueries(db *sqlx.DB) *CommandQueries {
|
||||
return &CommandQueries{db: db}
|
||||
}
|
||||
|
||||
// CreateCommand inserts a new command for an agent
|
||||
func (q *CommandQueries) CreateCommand(cmd *models.AgentCommand) error {
|
||||
query := `
|
||||
INSERT INTO agent_commands (
|
||||
id, agent_id, command_type, params, status
|
||||
) VALUES (
|
||||
:id, :agent_id, :command_type, :params, :status
|
||||
)
|
||||
`
|
||||
_, err := q.db.NamedExec(query, cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetPendingCommands retrieves pending commands for an agent
|
||||
func (q *CommandQueries) GetPendingCommands(agentID uuid.UUID) ([]models.AgentCommand, error) {
|
||||
var commands []models.AgentCommand
|
||||
query := `
|
||||
SELECT * FROM agent_commands
|
||||
WHERE agent_id = $1 AND status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 10
|
||||
`
|
||||
err := q.db.Select(&commands, query, agentID)
|
||||
return commands, err
|
||||
}
|
||||
|
||||
// MarkCommandSent updates a command's status to sent
|
||||
func (q *CommandQueries) MarkCommandSent(id uuid.UUID) error {
|
||||
now := time.Now()
|
||||
query := `
|
||||
UPDATE agent_commands
|
||||
SET status = 'sent', sent_at = $1
|
||||
WHERE id = $2
|
||||
`
|
||||
_, err := q.db.Exec(query, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkCommandCompleted updates a command's status to completed
|
||||
func (q *CommandQueries) MarkCommandCompleted(id uuid.UUID, result models.JSONB) error {
|
||||
now := time.Now()
|
||||
query := `
|
||||
UPDATE agent_commands
|
||||
SET status = 'completed', completed_at = $1, result = $2
|
||||
WHERE id = $3
|
||||
`
|
||||
_, err := q.db.Exec(query, now, result, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkCommandFailed updates a command's status to failed
|
||||
func (q *CommandQueries) MarkCommandFailed(id uuid.UUID, result models.JSONB) error {
|
||||
now := time.Now()
|
||||
query := `
|
||||
UPDATE agent_commands
|
||||
SET status = 'failed', completed_at = $1, result = $2
|
||||
WHERE id = $3
|
||||
`
|
||||
_, err := q.db.Exec(query, now, result, id)
|
||||
return err
|
||||
}
|
||||
141
aggregator-server/internal/database/queries/updates.go
Normal file
141
aggregator-server/internal/database/queries/updates.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/aggregator-project/aggregator-server/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type UpdateQueries struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewUpdateQueries(db *sqlx.DB) *UpdateQueries {
|
||||
return &UpdateQueries{db: db}
|
||||
}
|
||||
|
||||
// UpsertUpdate inserts or updates an update package
|
||||
func (q *UpdateQueries) UpsertUpdate(update *models.UpdatePackage) error {
|
||||
query := `
|
||||
INSERT INTO update_packages (
|
||||
id, agent_id, package_type, package_name, package_description,
|
||||
current_version, available_version, severity, cve_list, kb_id,
|
||||
repository_source, size_bytes, status, metadata
|
||||
) VALUES (
|
||||
:id, :agent_id, :package_type, :package_name, :package_description,
|
||||
:current_version, :available_version, :severity, :cve_list, :kb_id,
|
||||
:repository_source, :size_bytes, :status, :metadata
|
||||
)
|
||||
ON CONFLICT (agent_id, package_type, package_name, available_version)
|
||||
DO UPDATE SET
|
||||
package_description = EXCLUDED.package_description,
|
||||
current_version = EXCLUDED.current_version,
|
||||
severity = EXCLUDED.severity,
|
||||
cve_list = EXCLUDED.cve_list,
|
||||
kb_id = EXCLUDED.kb_id,
|
||||
repository_source = EXCLUDED.repository_source,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
metadata = EXCLUDED.metadata,
|
||||
discovered_at = NOW()
|
||||
`
|
||||
_, err := q.db.NamedExec(query, update)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListUpdates retrieves updates with filtering
|
||||
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 {
|
||||
whereClause = append(whereClause, fmt.Sprintf("agent_id = $%d", argIdx))
|
||||
args = append(args, *filters.AgentID)
|
||||
argIdx++
|
||||
}
|
||||
if filters.Status != "" {
|
||||
whereClause = append(whereClause, fmt.Sprintf("status = $%d", argIdx))
|
||||
args = append(args, filters.Status)
|
||||
argIdx++
|
||||
}
|
||||
if filters.Severity != "" {
|
||||
whereClause = append(whereClause, fmt.Sprintf("severity = $%d", argIdx))
|
||||
args = append(args, filters.Severity)
|
||||
argIdx++
|
||||
}
|
||||
if filters.PackageType != "" {
|
||||
whereClause = append(whereClause, fmt.Sprintf("package_type = $%d", argIdx))
|
||||
args = append(args, filters.PackageType)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
// Get total count
|
||||
countQuery := "SELECT COUNT(*) FROM update_packages WHERE " + strings.Join(whereClause, " AND ")
|
||||
var total int
|
||||
err := q.db.Get(&total, countQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Get paginated results
|
||||
query := fmt.Sprintf(`
|
||||
SELECT * FROM update_packages
|
||||
WHERE %s
|
||||
ORDER BY discovered_at DESC
|
||||
LIMIT $%d OFFSET $%d
|
||||
`, strings.Join(whereClause, " AND "), argIdx, argIdx+1)
|
||||
|
||||
limit := filters.PageSize
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
offset := (filters.Page - 1) * limit
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
args = append(args, limit, offset)
|
||||
err = q.db.Select(&updates, query, args...)
|
||||
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`
|
||||
err := q.db.Get(&update, query, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &update, nil
|
||||
}
|
||||
|
||||
// ApproveUpdate marks an update as approved
|
||||
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'
|
||||
`
|
||||
_, err := q.db.Exec(query, approvedBy, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateUpdateLog inserts an update log entry
|
||||
func (q *UpdateQueries) CreateUpdateLog(log *models.UpdateLog) error {
|
||||
query := `
|
||||
INSERT INTO update_logs (
|
||||
id, agent_id, update_package_id, action, result,
|
||||
stdout, stderr, exit_code, duration_seconds
|
||||
) VALUES (
|
||||
:id, :agent_id, :update_package_id, :action, :result,
|
||||
:stdout, :stderr, :exit_code, :duration_seconds
|
||||
)
|
||||
`
|
||||
_, err := q.db.NamedExec(query, log)
|
||||
return err
|
||||
}
|
||||
105
aggregator-server/internal/models/agent.go
Normal file
105
aggregator-server/internal/models/agent.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Agent represents a registered update agent
|
||||
type Agent 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"`
|
||||
}
|
||||
|
||||
// AgentSpecs represents system specifications for an agent
|
||||
type AgentSpecs struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
||||
CPUModel string `json:"cpu_model" db:"cpu_model"`
|
||||
CPUCores int `json:"cpu_cores" db:"cpu_cores"`
|
||||
MemoryTotalMB int `json:"memory_total_mb" db:"memory_total_mb"`
|
||||
DiskTotalGB int `json:"disk_total_gb" db:"disk_total_gb"`
|
||||
DiskFreeGB int `json:"disk_free_gb" db:"disk_free_gb"`
|
||||
NetworkInterfaces JSONB `json:"network_interfaces" db:"network_interfaces"`
|
||||
DockerInstalled bool `json:"docker_installed" db:"docker_installed"`
|
||||
DockerVersion string `json:"docker_version" db:"docker_version"`
|
||||
PackageManagers StringArray `json:"package_managers" db:"package_managers"`
|
||||
CollectedAt time.Time `json:"collected_at" db:"collected_at"`
|
||||
}
|
||||
|
||||
// AgentRegistrationRequest is the payload for agent registration
|
||||
type AgentRegistrationRequest struct {
|
||||
Hostname string `json:"hostname" binding:"required"`
|
||||
OSType string `json:"os_type" binding:"required"`
|
||||
OSVersion string `json:"os_version"`
|
||||
OSArchitecture string `json:"os_architecture"`
|
||||
AgentVersion string `json:"agent_version" binding:"required"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// AgentRegistrationResponse is returned after successful registration
|
||||
type AgentRegistrationResponse struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
// JSONB type for PostgreSQL JSONB columns
|
||||
type JSONB map[string]interface{}
|
||||
|
||||
// Value implements driver.Valuer for database storage
|
||||
func (j JSONB) Value() (driver.Value, error) {
|
||||
if j == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(j)
|
||||
}
|
||||
|
||||
// Scan implements sql.Scanner for database retrieval
|
||||
func (j *JSONB) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*j = nil
|
||||
return nil
|
||||
}
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(bytes, j)
|
||||
}
|
||||
|
||||
// StringArray type for PostgreSQL text[] columns
|
||||
type StringArray []string
|
||||
|
||||
// Value implements driver.Valuer
|
||||
func (s StringArray) Value() (driver.Value, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
// Scan implements sql.Scanner
|
||||
func (s *StringArray) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*s = nil
|
||||
return nil
|
||||
}
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(bytes, s)
|
||||
}
|
||||
49
aggregator-server/internal/models/command.go
Normal file
49
aggregator-server/internal/models/command.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AgentCommand represents a command to be executed by an agent
|
||||
type AgentCommand struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
||||
CommandType string `json:"command_type" db:"command_type"`
|
||||
Params JSONB `json:"params" db:"params"`
|
||||
Status string `json:"status" db:"status"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
|
||||
Result JSONB `json:"result,omitempty" db:"result"`
|
||||
}
|
||||
|
||||
// CommandsResponse is returned when an agent checks in for commands
|
||||
type CommandsResponse struct {
|
||||
Commands []CommandItem `json:"commands"`
|
||||
}
|
||||
|
||||
// CommandItem represents a command in the response
|
||||
type CommandItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Params JSONB `json:"params"`
|
||||
}
|
||||
|
||||
// Command types
|
||||
const (
|
||||
CommandTypeScanUpdates = "scan_updates"
|
||||
CommandTypeCollectSpecs = "collect_specs"
|
||||
CommandTypeInstallUpdate = "install_updates"
|
||||
CommandTypeRollback = "rollback_update"
|
||||
CommandTypeUpdateAgent = "update_agent"
|
||||
)
|
||||
|
||||
// Command statuses
|
||||
const (
|
||||
CommandStatusPending = "pending"
|
||||
CommandStatusSent = "sent"
|
||||
CommandStatusCompleted = "completed"
|
||||
CommandStatusFailed = "failed"
|
||||
)
|
||||
88
aggregator-server/internal/models/update.go
Normal file
88
aggregator-server/internal/models/update.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// UpdatePackage represents a single update available for installation
|
||||
type UpdatePackage 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"`
|
||||
PackageDescription string `json:"package_description" db:"package_description"`
|
||||
CurrentVersion string `json:"current_version" db:"current_version"`
|
||||
AvailableVersion string `json:"available_version" db:"available_version"`
|
||||
Severity string `json:"severity" db:"severity"`
|
||||
CVEList StringArray `json:"cve_list" db:"cve_list"`
|
||||
KBID string `json:"kb_id" db:"kb_id"`
|
||||
RepositorySource string `json:"repository_source" db:"repository_source"`
|
||||
SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
|
||||
Status string `json:"status" db:"status"`
|
||||
DiscoveredAt time.Time `json:"discovered_at" db:"discovered_at"`
|
||||
ApprovedBy string `json:"approved_by,omitempty" db:"approved_by"`
|
||||
ApprovedAt *time.Time `json:"approved_at,omitempty" db:"approved_at"`
|
||||
ScheduledFor *time.Time `json:"scheduled_for,omitempty" db:"scheduled_for"`
|
||||
InstalledAt *time.Time `json:"installed_at,omitempty" db:"installed_at"`
|
||||
ErrorMessage string `json:"error_message,omitempty" db:"error_message"`
|
||||
Metadata JSONB `json:"metadata" db:"metadata"`
|
||||
}
|
||||
|
||||
// UpdateReportRequest is sent by agents when reporting discovered updates
|
||||
type UpdateReportRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Updates []UpdateReportItem `json:"updates"`
|
||||
}
|
||||
|
||||
// UpdateReportItem represents a single update discovered by an agent
|
||||
type UpdateReportItem struct {
|
||||
PackageType string `json:"package_type" binding:"required"`
|
||||
PackageName string `json:"package_name" binding:"required"`
|
||||
PackageDescription string `json:"package_description"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
AvailableVersion string `json:"available_version" binding:"required"`
|
||||
Severity string `json:"severity"`
|
||||
CVEList []string `json:"cve_list"`
|
||||
KBID string `json:"kb_id"`
|
||||
RepositorySource string `json:"repository_source"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Metadata JSONB `json:"metadata"`
|
||||
}
|
||||
|
||||
// UpdateLog represents an execution log entry
|
||||
type UpdateLog struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
||||
UpdatePackageID *uuid.UUID `json:"update_package_id,omitempty" db:"update_package_id"`
|
||||
Action string `json:"action" db:"action"`
|
||||
Result string `json:"result" db:"result"`
|
||||
Stdout string `json:"stdout" db:"stdout"`
|
||||
Stderr string `json:"stderr" db:"stderr"`
|
||||
ExitCode int `json:"exit_code" db:"exit_code"`
|
||||
DurationSeconds int `json:"duration_seconds" db:"duration_seconds"`
|
||||
ExecutedAt time.Time `json:"executed_at" db:"executed_at"`
|
||||
}
|
||||
|
||||
// UpdateLogRequest is sent by agents when reporting execution results
|
||||
type UpdateLogRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Action string `json:"action" binding:"required"`
|
||||
Result string `json:"result" binding:"required"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
}
|
||||
|
||||
// UpdateFilters for querying updates
|
||||
type UpdateFilters struct {
|
||||
AgentID *uuid.UUID
|
||||
Status string
|
||||
Severity string
|
||||
PackageType string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
5
aggregator-web/.env.example
Normal file
5
aggregator-web/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# API Configuration
|
||||
VITE_API_URL=http://localhost:8080/api/v1
|
||||
|
||||
# Environment
|
||||
VITE_NODE_ENV=development
|
||||
40
aggregator-web/package.json
Normal file
40
aggregator-web/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "aggregator-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.8.4",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"axios": "^1.6.2",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.1.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
6
aggregator-web/postcss.config.js
Normal file
6
aggregator-web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
112
aggregator-web/src/App.tsx
Normal file
112
aggregator-web/src/App.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
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 Layout from '@/components/Layout';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import Agents from '@/pages/Agents';
|
||||
import Updates from '@/pages/Updates';
|
||||
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 }) => {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { isAuthenticated, token } = useAuthStore();
|
||||
const { theme } = useSettingsStore();
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// Check for existing token on app start
|
||||
useEffect(() => {
|
||||
const storedToken = localStorage.getItem('auth_token');
|
||||
if (storedToken && !token) {
|
||||
useAuthStore.getState().setToken(storedToken);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-gray-50 ${theme === 'dark' ? 'dark' : ''}`}>
|
||||
{/* Toast notifications */}
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: theme === 'dark' ? '#374151' : '#ffffff',
|
||||
color: theme === 'dark' ? '#ffffff' : '#000000',
|
||||
border: '1px solid',
|
||||
borderColor: theme === 'dark' ? '#4b5563' : '#e5e7eb',
|
||||
},
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: '#22c55e',
|
||||
secondary: '#ffffff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#ffffff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Notification center */}
|
||||
{isAuthenticated && <NotificationCenter />}
|
||||
|
||||
{/* App routes */}
|
||||
<Routes>
|
||||
{/* Login route */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
|
||||
/>
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/agents" element={<Agents />} />
|
||||
<Route path="/agents/:id" element={<Agents />} />
|
||||
<Route path="/updates" element={<Updates />} />
|
||||
<Route path="/updates/:id" element={<Updates />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
214
aggregator-web/src/components/Layout.tsx
Normal file
214
aggregator-web/src/components/Layout.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Computer,
|
||||
Package,
|
||||
FileText,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
Bell,
|
||||
Search,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useUIStore, useAuthStore, useRealtimeStore } from '@/lib/store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { sidebarOpen, setSidebarOpen, setActiveTab } = useUIStore();
|
||||
const { logout } = useAuthStore();
|
||||
const { notifications } = useRealtimeStore();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
current: location.pathname === '/' || location.pathname === '/dashboard',
|
||||
},
|
||||
{
|
||||
name: 'Agents',
|
||||
href: '/agents',
|
||||
icon: Computer,
|
||||
current: location.pathname.startsWith('/agents'),
|
||||
},
|
||||
{
|
||||
name: 'Updates',
|
||||
href: '/updates',
|
||||
icon: Package,
|
||||
current: location.pathname.startsWith('/updates'),
|
||||
},
|
||||
{
|
||||
name: 'Logs',
|
||||
href: '/logs',
|
||||
icon: FileText,
|
||||
current: location.pathname === '/logs',
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
href: '/settings',
|
||||
icon: Settings,
|
||||
current: location.pathname === '/settings',
|
||||
},
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
localStorage.removeItem('auth_token');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
// Navigate to updates page with search query
|
||||
navigate(`/updates?search=${encodeURIComponent(searchQuery.trim())}`);
|
||||
setSearchQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static lg:inset-0',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg">🚩</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900">RedFlag</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="lg:hidden text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="mt-6 px-3">
|
||||
<div className="space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
onClick={() => setActiveTab(item.name)}
|
||||
className={cn(
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
item.current
|
||||
? 'bg-primary-50 text-primary-700 border-r-2 border-primary-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'mr-3 h-5 w-5 flex-shrink-0',
|
||||
item.current ? 'text-primary-700' : 'text-gray-400 group-hover:text-gray-500'
|
||||
)}
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* User section */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center w-full px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<LogOut className="mr-3 h-5 w-5 text-gray-400" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col lg:pl-0">
|
||||
{/* 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">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="lg:hidden text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="hidden md:block">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search 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"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-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"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Notifications */}
|
||||
<button className="relative text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100">
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
128
aggregator-web/src/components/NotificationCenter.tsx
Normal file
128
aggregator-web/src/components/NotificationCenter.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Bell, X, Check, Info, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { useRealtimeStore } from '@/lib/store';
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
const NotificationCenter: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { notifications, markNotificationRead, clearNotifications } = useRealtimeStore();
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircle className="w-5 h-5 text-success-600" />;
|
||||
case 'error':
|
||||
return <XCircle className="w-5 h-5 text-danger-600" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="w-5 h-5 text-warning-600" />;
|
||||
default:
|
||||
return <Info className="w-5 h-5 text-blue-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'border-success-200 bg-success-50';
|
||||
case 'error':
|
||||
return 'border-danger-200 bg-danger-50';
|
||||
case 'warning':
|
||||
return 'border-warning-200 bg-warning-50';
|
||||
default:
|
||||
return 'border-blue-200 bg-blue-50';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
{/* Notification bell */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative p-2 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<Bell className="w-5 h-5 text-gray-600" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-danger-600 text-white text-xs rounded-full flex items-center justify-center">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Notifications dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-12 right-0 w-96 bg-white rounded-lg shadow-lg border border-gray-200 max-h-96 overflow-hidden">
|
||||
{/* 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"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</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)}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenter;
|
||||
99
aggregator-web/src/hooks/useAgents.ts
Normal file
99
aggregator-web/src/hooks/useAgents.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useQuery, useMutation, useQueryClient } 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();
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useAgent = (id: string, enabled: boolean = true) => {
|
||||
const { setSelectedAgent, setLoading, setError } = useAgentStore();
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
16
aggregator-web/src/hooks/useStats.ts
Normal file
16
aggregator-web/src/hooks/useStats.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { statsApi } from '@/lib/api';
|
||||
import { DashboardStats } from '@/types';
|
||||
import { handleApiError } from '@/lib/api';
|
||||
|
||||
export const useDashboardStats = () => {
|
||||
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));
|
||||
},
|
||||
});
|
||||
};
|
||||
173
aggregator-web/src/hooks/useUpdates.ts
Normal file
173
aggregator-web/src/hooks/useUpdates.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useQuery, useMutation, useQueryClient } 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();
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
114
aggregator-web/src/index.css
Normal file
114
aggregator-web/src/index.css
Normal file
@@ -0,0 +1,114 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 font-sans antialiased;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply btn bg-success-600 text-white hover:bg-success-700 focus:ring-success-500;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply btn bg-warning-600 text-white hover:bg-warning-700 focus:ring-warning-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply btn bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-500;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply badge bg-success-100 text-success-800;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply badge bg-warning-100 text-warning-800;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply badge bg-danger-100 text-danger-800;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
@apply badge bg-blue-100 text-blue-800;
|
||||
}
|
||||
|
||||
.table {
|
||||
@apply min-w-full divide-y divide-gray-200;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@apply bg-gray-50 px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
@apply bg-white hover:bg-gray-50;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
@apply bg-gray-900 text-green-400 font-mono text-sm p-4 rounded-lg overflow-x-auto;
|
||||
}
|
||||
|
||||
.terminal-prompt {
|
||||
@apply text-blue-400;
|
||||
}
|
||||
|
||||
.terminal-command {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
@apply text-gray-300;
|
||||
}
|
||||
|
||||
.hierarchy-tree {
|
||||
@apply border-l-2 border-gray-200 ml-4 pl-4;
|
||||
}
|
||||
|
||||
.hierarchy-item {
|
||||
@apply flex items-center space-x-2 py-1;
|
||||
}
|
||||
|
||||
.hierarchy-toggle {
|
||||
@apply w-4 h-4 text-gray-500 hover:text-gray-700 cursor-pointer;
|
||||
}
|
||||
}
|
||||
201
aggregator-web/src/lib/api.ts
Normal file
201
aggregator-web/src/lib/api.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
Agent,
|
||||
UpdatePackage,
|
||||
DashboardStats,
|
||||
AgentListResponse,
|
||||
UpdateListResponse,
|
||||
UpdateApprovalRequest,
|
||||
ScanRequest,
|
||||
ListQueryParams,
|
||||
ApiResponse,
|
||||
ApiError
|
||||
} from '@/types';
|
||||
|
||||
// Create axios instance
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api/v1',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor to handle errors
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Clear token and redirect to login
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// API endpoints
|
||||
export const agentApi = {
|
||||
// Get all agents
|
||||
getAgents: async (params?: ListQueryParams): Promise<AgentListResponse> => {
|
||||
const response = await api.get('/agents', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get single agent
|
||||
getAgent: async (id: string): Promise<Agent> => {
|
||||
const response = await api.get(`/agents/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Trigger scan on agents
|
||||
triggerScan: async (request: ScanRequest): Promise<void> => {
|
||||
await api.post('/agents/scan', request);
|
||||
},
|
||||
|
||||
// Trigger scan on single agent
|
||||
scanAgent: async (id: string): Promise<void> => {
|
||||
await api.post(`/agents/${id}/scan`);
|
||||
},
|
||||
};
|
||||
|
||||
export const updateApi = {
|
||||
// Get all updates
|
||||
getUpdates: async (params?: ListQueryParams): Promise<UpdateListResponse> => {
|
||||
const response = await api.get('/updates', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get single update
|
||||
getUpdate: async (id: string): Promise<UpdatePackage> => {
|
||||
const response = await api.get(`/updates/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Approve updates
|
||||
approveUpdates: async (request: UpdateApprovalRequest): Promise<void> => {
|
||||
await api.post('/updates/approve', request);
|
||||
},
|
||||
|
||||
// Approve single update
|
||||
approveUpdate: async (id: string, scheduledAt?: string): Promise<void> => {
|
||||
await api.post(`/updates/${id}/approve`, { scheduled_at: scheduledAt });
|
||||
},
|
||||
|
||||
// Reject/cancel update
|
||||
rejectUpdate: async (id: string): Promise<void> => {
|
||||
await api.post(`/updates/${id}/reject`);
|
||||
},
|
||||
|
||||
// Install update immediately
|
||||
installUpdate: async (id: string): Promise<void> => {
|
||||
await api.post(`/updates/${id}/install`);
|
||||
},
|
||||
};
|
||||
|
||||
export const statsApi = {
|
||||
// Get dashboard statistics
|
||||
getDashboardStats: async (): Promise<DashboardStats> => {
|
||||
const response = await api.get('/stats/summary');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const authApi = {
|
||||
// Simple login (using API key or token)
|
||||
login: async (credentials: { token: string }): Promise<{ token: string }> => {
|
||||
const response = await api.post('/auth/login', credentials);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Verify token
|
||||
verifyToken: async (): Promise<{ valid: boolean }> => {
|
||||
const response = await api.get('/auth/verify');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: async (): Promise<void> => {
|
||||
await api.post('/auth/logout');
|
||||
},
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
export const createQueryString = (params: Record<string, any>): string => {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => searchParams.append(key, v));
|
||||
} else {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
return searchParams.toString();
|
||||
};
|
||||
|
||||
// Error handling utility
|
||||
export const handleApiError = (error: any): ApiError => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data;
|
||||
|
||||
if (status === 401) {
|
||||
return {
|
||||
message: 'Authentication required. Please log in.',
|
||||
code: 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return {
|
||||
message: 'Access denied. You do not have permission to perform this action.',
|
||||
code: 'FORBIDDEN',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
return {
|
||||
message: 'The requested resource was not found.',
|
||||
code: 'NOT_FOUND',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
return {
|
||||
message: 'Too many requests. Please try again later.',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
return {
|
||||
message: 'Server error. Please try again later.',
|
||||
code: 'SERVER_ERROR',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: data?.message || error.message || 'An error occurred',
|
||||
code: data?.code || 'UNKNOWN_ERROR',
|
||||
details: data?.details,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: error.message || 'An unexpected error occurred',
|
||||
code: 'UNKNOWN_ERROR',
|
||||
};
|
||||
};
|
||||
|
||||
export default api;
|
||||
241
aggregator-web/src/lib/store.ts
Normal file
241
aggregator-web/src/lib/store.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { Agent, UpdatePackage, FilterState } from '@/types';
|
||||
|
||||
// Auth store
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
setToken: (token: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
setToken: (token) => set({ token, isAuthenticated: true }),
|
||||
logout: () => set({ token: null, isAuthenticated: false }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({ token: state.token, isAuthenticated: state.isAuthenticated }),
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// UI store for global state
|
||||
interface UIState {
|
||||
sidebarOpen: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
activeTab: string;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setActiveTab: (tab: string) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sidebarOpen: true,
|
||||
theme: 'light',
|
||||
activeTab: 'dashboard',
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
}),
|
||||
{
|
||||
name: 'ui-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Agent store
|
||||
interface AgentState {
|
||||
agents: Agent[];
|
||||
selectedAgent: Agent | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
setAgents: (agents: Agent[]) => void;
|
||||
setSelectedAgent: (agent: Agent | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
updateAgentStatus: (agentId: string, status: Agent['status'], lastCheckin: string) => void;
|
||||
addAgent: (agent: Agent) => void;
|
||||
removeAgent: (agentId: string) => void;
|
||||
}
|
||||
|
||||
export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
agents: [],
|
||||
selectedAgent: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
setAgents: (agents) => set({ agents }),
|
||||
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
updateAgentStatus: (agentId, status, lastCheckin) => {
|
||||
const { agents } = get();
|
||||
const updatedAgents = agents.map(agent =>
|
||||
agent.id === agentId
|
||||
? { ...agent, status, last_checkin: lastCheckin }
|
||||
: agent
|
||||
);
|
||||
set({ agents: updatedAgents });
|
||||
},
|
||||
|
||||
addAgent: (agent) => {
|
||||
const { agents } = get();
|
||||
set({ agents: [...agents, agent] });
|
||||
},
|
||||
|
||||
removeAgent: (agentId) => {
|
||||
const { agents } = get();
|
||||
set({ agents: agents.filter(agent => agent.id !== agentId) });
|
||||
},
|
||||
}));
|
||||
|
||||
// Updates store
|
||||
interface UpdateState {
|
||||
updates: UpdatePackage[];
|
||||
selectedUpdate: UpdatePackage | null;
|
||||
filters: FilterState;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
setUpdates: (updates: UpdatePackage[]) => void;
|
||||
setSelectedUpdate: (update: UpdatePackage | null) => void;
|
||||
setFilters: (filters: Partial<FilterState>) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
updateUpdateStatus: (updateId: string, status: UpdatePackage['status']) => void;
|
||||
bulkUpdateStatus: (updateIds: string[], status: UpdatePackage['status']) => void;
|
||||
}
|
||||
|
||||
export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
updates: [],
|
||||
selectedUpdate: null,
|
||||
filters: {
|
||||
status: [],
|
||||
severity: [],
|
||||
type: [],
|
||||
search: '',
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
setUpdates: (updates) => set({ updates }),
|
||||
setSelectedUpdate: (update) => set({ selectedUpdate: update }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
setFilters: (newFilters) => {
|
||||
const { filters } = get();
|
||||
set({ filters: { ...filters, ...newFilters } });
|
||||
},
|
||||
|
||||
updateUpdateStatus: (updateId, status) => {
|
||||
const { updates } = get();
|
||||
const updatedUpdates = updates.map(update =>
|
||||
update.id === updateId
|
||||
? { ...update, status, updated_at: new Date().toISOString() }
|
||||
: update
|
||||
);
|
||||
set({ updates: updatedUpdates });
|
||||
},
|
||||
|
||||
bulkUpdateStatus: (updateIds, status) => {
|
||||
const { updates } = get();
|
||||
const updatedUpdates = updates.map(update =>
|
||||
updateIds.includes(update.id)
|
||||
? { ...update, status, updated_at: new Date().toISOString() }
|
||||
: update
|
||||
);
|
||||
set({ updates: updatedUpdates });
|
||||
},
|
||||
}));
|
||||
|
||||
// Real-time updates store
|
||||
interface RealtimeState {
|
||||
isConnected: boolean;
|
||||
lastUpdate: string | null;
|
||||
notifications: Array<{
|
||||
id: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error';
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
}>;
|
||||
setConnected: (connected: boolean) => void;
|
||||
setLastUpdate: (timestamp: string) => void;
|
||||
addNotification: (notification: Omit<typeof RealtimeState.prototype.notifications[0], 'id' | 'timestamp' | 'read'>) => void;
|
||||
markNotificationRead: (id: string) => void;
|
||||
clearNotifications: () => void;
|
||||
}
|
||||
|
||||
export const useRealtimeStore = create<RealtimeState>((set, get) => ({
|
||||
isConnected: false,
|
||||
lastUpdate: null,
|
||||
notifications: [],
|
||||
|
||||
setConnected: (isConnected) => set({ isConnected }),
|
||||
setLastUpdate: (lastUpdate) => set({ lastUpdate }),
|
||||
|
||||
addNotification: (notification) => {
|
||||
const { notifications } = get();
|
||||
const newNotification = {
|
||||
...notification,
|
||||
id: Math.random().toString(36).substring(7),
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
};
|
||||
set({ notifications: [newNotification, ...notifications] });
|
||||
},
|
||||
|
||||
markNotificationRead: (id) => {
|
||||
const { notifications } = get();
|
||||
const updatedNotifications = notifications.map(notification =>
|
||||
notification.id === id ? { ...notification, read: true } : notification
|
||||
);
|
||||
set({ notifications: updatedNotifications });
|
||||
},
|
||||
|
||||
clearNotifications: () => set({ notifications: [] }),
|
||||
}));
|
||||
|
||||
// Settings store
|
||||
interface SettingsState {
|
||||
autoRefresh: boolean;
|
||||
refreshInterval: number;
|
||||
notificationsEnabled: boolean;
|
||||
compactView: boolean;
|
||||
setAutoRefresh: (enabled: boolean) => void;
|
||||
setRefreshInterval: (interval: number) => void;
|
||||
setNotificationsEnabled: (enabled: boolean) => void;
|
||||
setCompactView: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
autoRefresh: true,
|
||||
refreshInterval: 30000, // 30 seconds
|
||||
notificationsEnabled: true,
|
||||
compactView: false,
|
||||
|
||||
setAutoRefresh: (autoRefresh) => set({ autoRefresh }),
|
||||
setRefreshInterval: (refreshInterval) => set({ refreshInterval }),
|
||||
setNotificationsEnabled: (notificationsEnabled) => set({ notificationsEnabled }),
|
||||
setCompactView: (compactView) => set({ compactView }),
|
||||
}),
|
||||
{
|
||||
name: 'settings-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
247
aggregator-web/src/lib/utils.ts
Normal file
247
aggregator-web/src/lib/utils.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
// Utility function for combining class names
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Date formatting utilities
|
||||
export const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export const formatRelativeTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) {
|
||||
return 'Just now';
|
||||
} else if (diffMins < 60) {
|
||||
return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return formatDate(dateString);
|
||||
}
|
||||
};
|
||||
|
||||
export const isOnline = (lastCheckin: string): boolean => {
|
||||
const lastCheck = new Date(lastCheckin);
|
||||
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
|
||||
};
|
||||
|
||||
// Size formatting utilities
|
||||
export const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Version comparison utilities
|
||||
export const versionCompare = (v1: string, v2: string): number => {
|
||||
const parts1 = v1.split('.').map(Number);
|
||||
const parts2 = v2.split('.').map(Number);
|
||||
|
||||
const maxLength = Math.max(parts1.length, parts2.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const part1 = parts1[i] || 0;
|
||||
const part2 = parts2[i] || 0;
|
||||
|
||||
if (part1 > part2) return 1;
|
||||
if (part1 < part2) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Status and severity utilities
|
||||
export const getStatusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'text-success-600 bg-success-100';
|
||||
case 'offline':
|
||||
return 'text-danger-600 bg-danger-100';
|
||||
case 'pending':
|
||||
return 'text-warning-600 bg-warning-100';
|
||||
case 'approved':
|
||||
case 'scheduled':
|
||||
return 'text-blue-600 bg-blue-100';
|
||||
case 'installing':
|
||||
return 'text-indigo-600 bg-indigo-100';
|
||||
case 'installed':
|
||||
return 'text-success-600 bg-success-100';
|
||||
case 'failed':
|
||||
return 'text-danger-600 bg-danger-100';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
export const getSeverityColor = (severity: string): string => {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return 'text-danger-600 bg-danger-100';
|
||||
case 'high':
|
||||
return 'text-warning-600 bg-warning-100';
|
||||
case 'medium':
|
||||
return 'text-blue-600 bg-blue-100';
|
||||
case 'low':
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
export const getPackageTypeIcon = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'apt':
|
||||
return '📦';
|
||||
case 'docker':
|
||||
return '🐳';
|
||||
case 'yum':
|
||||
case 'dnf':
|
||||
return '🐧';
|
||||
case 'windows':
|
||||
return '🪟';
|
||||
case 'winget':
|
||||
return '📱';
|
||||
default:
|
||||
return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
// Filter and search utilities
|
||||
export const filterUpdates = (
|
||||
updates: any[],
|
||||
filters: {
|
||||
status: string[];
|
||||
severity: string[];
|
||||
type: string[];
|
||||
search: string;
|
||||
}
|
||||
): any[] => {
|
||||
return updates.filter(update => {
|
||||
// Status filter
|
||||
if (filters.status.length > 0 && !filters.status.includes(update.status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Severity filter
|
||||
if (filters.severity.length > 0 && !filters.severity.includes(update.severity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (filters.type.length > 0 && !filters.type.includes(update.package_type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (filters.search) {
|
||||
const searchLower = filters.search.toLowerCase();
|
||||
return (
|
||||
update.package_name.toLowerCase().includes(searchLower) ||
|
||||
update.current_version.toLowerCase().includes(searchLower) ||
|
||||
update.available_version.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// Error handling utilities
|
||||
export const getErrorMessage = (error: any): string => {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error?.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (error?.response?.data?.message) {
|
||||
return error.response.data.message;
|
||||
}
|
||||
|
||||
return 'An unexpected error occurred';
|
||||
};
|
||||
|
||||
// Debounce utility
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
};
|
||||
|
||||
// Local storage utilities
|
||||
export const storage = {
|
||||
get: (key: string): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
set: (key: string, value: string): void => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Silent fail for storage issues
|
||||
}
|
||||
},
|
||||
|
||||
remove: (key: string): void => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Silent fail for storage issues
|
||||
}
|
||||
},
|
||||
|
||||
getJSON: <T = any>(key: string): T | null => {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
setJSON: (key: string, value: any): void => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// Silent fail for storage issues
|
||||
}
|
||||
},
|
||||
};
|
||||
27
aggregator-web/src/main.tsx
Normal file
27
aggregator-web/src/main.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
491
aggregator-web/src/pages/Agents.tsx
Normal file
491
aggregator-web/src/pages/Agents.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Computer,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
Activity,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
Globe,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents } from '@/hooks/useAgents';
|
||||
import { Agent } from '@/types';
|
||||
import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Agents: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [osFilter, setOsFilter] = useState<string>('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
|
||||
|
||||
// Fetch agents list
|
||||
const { data: agentsData, isLoading, error } = useAgents({
|
||||
search: searchQuery || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
});
|
||||
|
||||
// Fetch single agent if ID is provided
|
||||
const { data: selectedAgentData } = useAgent(id || '', !!id);
|
||||
|
||||
const scanAgentMutation = useScanAgent();
|
||||
const scanMultipleMutation = useScanMultipleAgents();
|
||||
|
||||
const agents = agentsData?.agents || [];
|
||||
const selectedAgent = selectedAgentData || agents.find(a => a.id === id);
|
||||
|
||||
// Filter agents based on OS
|
||||
const filteredAgents = agents.filter(agent => {
|
||||
if (osFilter === 'all') return true;
|
||||
return agent.os_type.toLowerCase().includes(osFilter.toLowerCase());
|
||||
});
|
||||
|
||||
// Handle agent selection
|
||||
const handleSelectAgent = (agentId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedAgents([...selectedAgents, agentId]);
|
||||
} else {
|
||||
setSelectedAgents(selectedAgents.filter(id => id !== agentId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedAgents(filteredAgents.map(agent => agent.id));
|
||||
} else {
|
||||
setSelectedAgents([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle scan operations
|
||||
const handleScanAgent = async (agentId: string) => {
|
||||
try {
|
||||
await scanAgentMutation.mutateAsync(agentId);
|
||||
toast.success('Scan triggered successfully');
|
||||
} catch (error) {
|
||||
// Error handling is done in the hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleScanSelected = async () => {
|
||||
if (selectedAgents.length === 0) {
|
||||
toast.error('Please select at least one agent');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await scanMultipleMutation.mutateAsync({ agent_ids: selectedAgents });
|
||||
setSelectedAgents([]);
|
||||
toast.success(`Scan triggered for ${selectedAgents.length} 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))];
|
||||
|
||||
// Agent detail view
|
||||
if (id && selectedAgent) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => navigate('/agents')}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
← Back to Agents
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{selectedAgent.hostname}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Agent details and system information
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleScanAgent(selectedAgent.id)}
|
||||
disabled={scanAgentMutation.isLoading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{scanAgentMutation.isLoading ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Scan Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Agent info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* 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}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<Activity className="h-4 w-4" />
|
||||
<span>Last Check-in:</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatRelativeTime(selectedAgent.last_checkin)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Last Scan:</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.last_scan
|
||||
? formatRelativeTime(selectedAgent.last_scan)
|
||||
: 'Never'}
|
||||
</p>
|
||||
</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="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Operating System</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.os_type} {selectedAgent.os_version}
|
||||
</p>
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Agent Version</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.version}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Registered</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatRelativeTime(selectedAgent.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Agents list view
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Agents</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Monitor and manage your connected agents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search agents by hostname..."
|
||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center space-x-2 px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>Filters</span>
|
||||
{(statusFilter !== 'all' || osFilter !== 'all') && (
|
||||
<span className="bg-primary-100 text-primary-800 px-2 py-0.5 rounded-full text-xs">
|
||||
{[
|
||||
statusFilter !== 'all' ? statusFilter : null,
|
||||
osFilter !== 'all' ? osFilter : null,
|
||||
].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Bulk actions */}
|
||||
{selectedAgents.length > 0 && (
|
||||
<button
|
||||
onClick={handleScanSelected}
|
||||
disabled={scanMultipleMutation.isLoading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{scanMultipleMutation.isLoading ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Scan Selected ({selectedAgents.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="online">Online</option>
|
||||
<option value="offline">Offline</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Operating System
|
||||
</label>
|
||||
<select
|
||||
value={osFilter}
|
||||
onChange={(e) => setOsFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All OS</option>
|
||||
{osTypes.map(os => (
|
||||
<option key={os} value={os}>{os}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agents table */}
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="p-4 border-b border-gray-200">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 mb-2">Failed to load agents</div>
|
||||
<p className="text-sm text-gray-600">Please check your connection and try again.</p>
|
||||
</div>
|
||||
) : filteredAgents.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Computer className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No agents found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchQuery || statusFilter !== 'all' || osFilter !== 'all'
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'No agents have registered with the server yet.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="table-header">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAgents.length === filteredAgents.length}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header">Agent</th>
|
||||
<th className="table-header">Status</th>
|
||||
<th className="table-header">OS</th>
|
||||
<th className="table-header">Last Check-in</th>
|
||||
<th className="table-header">Last Scan</th>
|
||||
<th className="table-header">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredAgents.map((agent) => (
|
||||
<tr key={agent.id} className="hover:bg-gray-50">
|
||||
<td className="table-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAgents.includes(agent.id)}
|
||||
onChange={(e) => handleSelectAgent(agent.id, e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<Computer className="h-4 w-4 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${agent.id}`)}
|
||||
className="hover:text-primary-600"
|
||||
>
|
||||
{agent.hostname}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{agent.ip_address}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={cn('badge', getStatusColor(agent.status))}>
|
||||
{agent.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{agent.os_type}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{agent.architecture}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatRelativeTime(agent.last_checkin)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{isOnline(agent.last_checkin) ? 'Online' : 'Offline'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{agent.last_scan
|
||||
? formatRelativeTime(agent.last_scan)
|
||||
: 'Never'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleScanAgent(agent.id)}
|
||||
disabled={scanAgentMutation.isLoading}
|
||||
className="text-gray-400 hover:text-primary-600"
|
||||
title="Trigger scan"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${agent.id}`)}
|
||||
className="text-gray-400 hover:text-primary-600"
|
||||
title="View details"
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Agents;
|
||||
246
aggregator-web/src/pages/Dashboard.tsx
Normal file
246
aggregator-web/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Computer,
|
||||
Package,
|
||||
CheckCircle,
|
||||
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();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-8"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-32 bg-gray-200 rounded-lg"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center py-12">
|
||||
<XCircle className="mx-auto h-12 w-12 text-danger-500" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Failed to load dashboard</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Unable to fetch statistics from the server.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: 'Total Agents',
|
||||
value: stats.total_agents,
|
||||
icon: Computer,
|
||||
color: 'text-blue-600 bg-blue-100',
|
||||
link: '/agents',
|
||||
},
|
||||
{
|
||||
title: 'Online Agents',
|
||||
value: stats.online_agents,
|
||||
icon: CheckCircle,
|
||||
color: 'text-success-600 bg-success-100',
|
||||
link: '/agents?status=online',
|
||||
},
|
||||
{
|
||||
title: 'Pending Updates',
|
||||
value: stats.pending_updates,
|
||||
icon: Clock,
|
||||
color: 'text-warning-600 bg-warning-100',
|
||||
link: '/updates?status=pending',
|
||||
},
|
||||
{
|
||||
title: 'Failed Updates',
|
||||
value: stats.failed_updates,
|
||||
icon: XCircle,
|
||||
color: 'text-danger-600 bg-danger-100',
|
||||
link: '/updates?status=failed',
|
||||
},
|
||||
];
|
||||
|
||||
const severityBreakdown = [
|
||||
{ label: 'Critical', value: stats.critical_updates, color: 'bg-danger-600' },
|
||||
{ label: 'High', value: stats.high_updates, color: 'bg-warning-600' },
|
||||
{ label: 'Medium', value: stats.medium_updates, color: 'bg-blue-600' },
|
||||
{ label: 'Low', value: stats.low_updates, color: 'bg-gray-600' },
|
||||
];
|
||||
|
||||
const updateTypeBreakdown = Object.entries(stats.updates_by_type).map(([type, count]) => ({
|
||||
type: type.charAt(0).toUpperCase() + type.slice(1),
|
||||
value: count,
|
||||
icon: type === 'apt' ? '📦' : type === 'docker' ? '🐳' : '📋',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Page header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Overview of your infrastructure and update status
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{statCards.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Link
|
||||
key={stat.title}
|
||||
to={stat.link}
|
||||
className="group block p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600 group-hover:text-gray-900">
|
||||
{stat.title}
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-bold text-gray-900">
|
||||
{stat.value.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg ${stat.color}`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Severity breakdown */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">Update Severity</h2>
|
||||
<AlertTriangle className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
|
||||
{severityBreakdown.some(item => item.value > 0) ? (
|
||||
<div className="space-y-3">
|
||||
{severityBreakdown.map((severity) => (
|
||||
<div key={severity.label} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-3 h-3 rounded-full ${severity.color}`}></div>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{severity.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-900 font-semibold">
|
||||
{severity.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Visual bar chart */}
|
||||
<div className="mt-4 space-y-2">
|
||||
{severityBreakdown.map((severity) => (
|
||||
<div key={severity.label} className="relative">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-gray-600">{severity.label}</span>
|
||||
<span className="text-xs text-gray-900">{severity.value}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${severity.color}`}
|
||||
style={{
|
||||
width: `${stats.pending_updates > 0 ? (severity.value / stats.pending_updates) * 100 : 0}%`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="mx-auto h-8 w-8 text-success-500" />
|
||||
<p className="mt-2 text-sm text-gray-600">No pending updates</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update type breakdown */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">Updates by Type</h2>
|
||||
<Package className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
|
||||
{updateTypeBreakdown.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{updateTypeBreakdown.map((type) => (
|
||||
<div key={type.type} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-2xl">{type.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{type.type}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-900 font-semibold">
|
||||
{type.value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Package className="mx-auto h-8 w-8 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">No updates found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="mt-8 bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Link
|
||||
to="/agents"
|
||||
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Computer className="h-5 w-5 text-blue-600" />
|
||||
<span className="text-sm font-medium text-gray-700">View All Agents</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/updates"
|
||||
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Package className="h-5 w-5 text-warning-600" />
|
||||
<span className="text-sm font-medium text-gray-700">Manage Updates</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm font-medium text-gray-700">Refresh Data</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
132
aggregator-web/src/pages/Login.tsx
Normal file
132
aggregator-web/src/pages/Login.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Eye, EyeOff, Shield } from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { handleApiError } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setToken } = useAuthStore();
|
||||
const [token, setTokenInput] = useState('');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!token.trim()) {
|
||||
toast.error('Please enter your authentication token');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authApi.login({ token: token.trim() });
|
||||
setToken(response.token);
|
||||
localStorage.setItem('auth_token', response.token);
|
||||
toast.success('Login successful');
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
const apiError = handleApiError(error);
|
||||
toast.error(apiError.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 bg-primary-600 rounded-xl flex items-center justify-center">
|
||||
<span className="text-white font-bold text-2xl">🚩</span>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to RedFlag
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Enter your authentication token to access the dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="token" className="block text-sm font-medium text-gray-700">
|
||||
Authentication Token
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id="token"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Enter your JWT token"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Signing in...
|
||||
</div>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 border-t border-gray-200 pt-6">
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Shield className="h-4 w-4 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">How to get your token:</p>
|
||||
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
|
||||
<li>Check your RedFlag server configuration</li>
|
||||
<li>Look for the JWT secret in your server settings</li>
|
||||
<li>Generate a token using the server CLI</li>
|
||||
<li>Contact your administrator if you need access</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
RedFlag is a self-hosted update management platform
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
24
aggregator-web/src/pages/Logs.tsx
Normal file
24
aggregator-web/src/pages/Logs.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
const Logs: React.FC = () => {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Logs</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
View system logs and update history
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center">
|
||||
<div className="text-gray-400 mb-2">📋</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Coming Soon</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Logs and history tracking will be available in a future update.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logs;
|
||||
72
aggregator-web/src/pages/Settings.tsx
Normal file
72
aggregator-web/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { useSettingsStore } from '@/lib/store';
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const { autoRefresh, refreshInterval, setAutoRefresh, setRefreshInterval } = useSettingsStore();
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="p-6 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">
|
||||
Automatically refresh dashboard data at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
|
||||
autoRefresh ? 'bg-primary-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
autoRefresh ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Refresh Interval */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Refresh Interval</h3>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value={10000}>10 seconds</option>
|
||||
<option value={30000}>30 seconds</option>
|
||||
<option value={60000}>1 minute</option>
|
||||
<option value={300000}>5 minutes</option>
|
||||
<option value={600000}>10 minutes</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
How often to refresh dashboard data when auto-refresh is enabled
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
591
aggregator-web/src/pages/Updates.tsx
Normal file
591
aggregator-web/src/pages/Updates.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Package,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronDown as ChevronDownIcon,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
Computer,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates } from '@/hooks/useUpdates';
|
||||
import { 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();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Get filters from URL params
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||
const [severityFilter, setSeverityFilter] = useState(searchParams.get('severity') || '');
|
||||
const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || '');
|
||||
const [agentFilter, setAgentFilter] = useState(searchParams.get('agent') || '');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedUpdates, setSelectedUpdates] = useState<string[]>([]);
|
||||
|
||||
// Store filters in URL
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
if (severityFilter) params.set('severity', severityFilter);
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
if (agentFilter) params.set('agent', agentFilter);
|
||||
|
||||
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
|
||||
if (newUrl !== window.location.href) {
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
}, [searchQuery, statusFilter, severityFilter, typeFilter, agentFilter]);
|
||||
|
||||
// Fetch updates list
|
||||
const { data: updatesData, isLoading, error } = useUpdates({
|
||||
search: searchQuery || undefined,
|
||||
status: statusFilter || undefined,
|
||||
severity: severityFilter || undefined,
|
||||
type: typeFilter || undefined,
|
||||
agent_id: agentFilter || undefined,
|
||||
});
|
||||
|
||||
// Fetch single update if ID is provided
|
||||
const { data: selectedUpdateData } = useUpdate(id || '', !!id);
|
||||
|
||||
const approveMutation = useApproveUpdate();
|
||||
const rejectMutation = useRejectUpdate();
|
||||
const installMutation = useInstallUpdate();
|
||||
const bulkApproveMutation = useApproveMultipleUpdates();
|
||||
|
||||
const updates = updatesData?.updates || [];
|
||||
const selectedUpdate = selectedUpdateData || updates.find(u => u.id === id);
|
||||
|
||||
// Handle update selection
|
||||
const handleSelectUpdate = (updateId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUpdates([...selectedUpdates, updateId]);
|
||||
} else {
|
||||
setSelectedUpdates(selectedUpdates.filter(id => id !== updateId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUpdates(updates.map(update => update.id));
|
||||
} else {
|
||||
setSelectedUpdates([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle update actions
|
||||
const handleApproveUpdate = async (updateId: string) => {
|
||||
try {
|
||||
await approveMutation.mutateAsync({ id: updateId });
|
||||
} catch (error) {
|
||||
// Error handling is done in the hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectUpdate = async (updateId: string) => {
|
||||
try {
|
||||
await rejectMutation.mutateAsync(updateId);
|
||||
} catch (error) {
|
||||
// Error handling is done in the hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallUpdate = async (updateId: string) => {
|
||||
try {
|
||||
await installMutation.mutateAsync(updateId);
|
||||
} catch (error) {
|
||||
// Error handling is done in the hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
if (selectedUpdates.length === 0) {
|
||||
toast.error('Please select at least one update');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await bulkApproveMutation.mutateAsync({ update_ids: selectedUpdates });
|
||||
setSelectedUpdates([]);
|
||||
} catch (error) {
|
||||
// Error handling is done in the hook
|
||||
}
|
||||
};
|
||||
|
||||
// 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))];
|
||||
|
||||
// Update detail view
|
||||
if (id && selectedUpdate) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => navigate('/updates')}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
← Back to Updates
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<span className="text-2xl">{getPackageTypeIcon(selectedUpdate.package_type)}</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{selectedUpdate.package_name}
|
||||
</h1>
|
||||
<span className={cn('badge', getSeverityColor(selectedUpdate.severity))}>
|
||||
{selectedUpdate.severity}
|
||||
</span>
|
||||
<span className={cn('badge', getStatusColor(selectedUpdate.status))}>
|
||||
{selectedUpdate.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Update details and available actions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Update info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Version info */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Version Information</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Current Version</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedUpdate.current_version}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Available Version</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedUpdate.available_version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Additional Information</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Package Type</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedUpdate.package_type.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Severity</p>
|
||||
<span className={cn('badge', getSeverityColor(selectedUpdate.severity))}>
|
||||
{selectedUpdate.severity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Discovered</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatRelativeTime(selectedUpdate.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Last Updated</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatRelativeTime(selectedUpdate.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedUpdate.metadata && Object.keys(selectedUpdate.metadata).length > 0 && (
|
||||
<div className="mt-6">
|
||||
<p className="text-sm text-gray-600 mb-2">Metadata</p>
|
||||
<pre className="bg-gray-50 p-3 rounded-md text-xs overflow-x-auto">
|
||||
{JSON.stringify(selectedUpdate.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{selectedUpdate.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleApproveUpdate(selectedUpdate.id)}
|
||||
disabled={approveMutation.isLoading}
|
||||
className="w-full btn btn-success"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Approve Update
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleRejectUpdate(selectedUpdate.id)}
|
||||
disabled={rejectMutation.isLoading}
|
||||
className="w-full btn btn-secondary"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Reject Update
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedUpdate.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => handleInstallUpdate(selectedUpdate.id)}
|
||||
disabled={installMutation.isLoading}
|
||||
className="w-full btn btn-primary"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Install Now
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${selectedUpdate.agent_id}`)}
|
||||
className="w-full btn btn-ghost"
|
||||
>
|
||||
<Computer className="h-4 w-4 mr-2" />
|
||||
View Agent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Updates list view
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Updates</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Review and approve available updates for your agents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search updates by package name..."
|
||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center space-x-2 px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>Filters</span>
|
||||
{[statusFilter, severityFilter, typeFilter, agentFilter].filter(Boolean).length > 0 && (
|
||||
<span className="bg-primary-100 text-primary-800 px-2 py-0.5 rounded-full text-xs">
|
||||
{[statusFilter, severityFilter, typeFilter, agentFilter].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Bulk actions */}
|
||||
{selectedUpdates.length > 0 && (
|
||||
<button
|
||||
onClick={handleBulkApprove}
|
||||
disabled={bulkApproveMutation.isLoading}
|
||||
className="btn btn-success"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Approve Selected ({selectedUpdates.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
{statuses.map(status => (
|
||||
<option key={status} value={status}>{status}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Severity
|
||||
</label>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={(e) => setSeverityFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Severities</option>
|
||||
{severities.map(severity => (
|
||||
<option key={severity} value={severity}>{severity}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Package Type
|
||||
</label>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{types.map(type => (
|
||||
<option key={type} value={type}>{type.toUpperCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Agent
|
||||
</label>
|
||||
<select
|
||||
value={agentFilter}
|
||||
onChange={(e) => setAgentFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Agents</option>
|
||||
{agents.map(agentId => (
|
||||
<option key={agentId} value={agentId}>{agentId}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Updates table */}
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="p-4 border-b border-gray-200">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 mb-2">Failed to load updates</div>
|
||||
<p className="text-sm text-gray-600">Please check your connection and try again.</p>
|
||||
</div>
|
||||
) : updates.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Package className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No updates found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchQuery || statusFilter || severityFilter || typeFilter || agentFilter
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'All agents are up to date!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="table-header">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUpdates.length === updates.length}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header">Package</th>
|
||||
<th className="table-header">Type</th>
|
||||
<th className="table-header">Versions</th>
|
||||
<th className="table-header">Severity</th>
|
||||
<th className="table-header">Status</th>
|
||||
<th className="table-header">Agent</th>
|
||||
<th className="table-header">Discovered</th>
|
||||
<th className="table-header">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{updates.map((update) => (
|
||||
<tr key={update.id} className="hover:bg-gray-50">
|
||||
<td className="table-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUpdates.includes(update.id)}
|
||||
onChange={(e) => handleSelectUpdate(update.id, e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-xl">{getPackageTypeIcon(update.package_type)}</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
<button
|
||||
onClick={() => navigate(`/updates/${update.id}`)}
|
||||
className="hover:text-primary-600"
|
||||
>
|
||||
{update.package_name}
|
||||
</button>
|
||||
</div>
|
||||
{update.metadata?.size_bytes && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatBytes(update.metadata.size_bytes)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className="text-xs font-medium text-gray-900 bg-gray-100 px-2 py-1 rounded">
|
||||
{update.package_type.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm">
|
||||
<div className="text-gray-900">{update.current_version}</div>
|
||||
<div className="text-success-600">→ {update.available_version}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={cn('badge', getSeverityColor(update.severity))}>
|
||||
{update.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={cn('badge', getStatusColor(update.status))}>
|
||||
{update.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${update.agent_id}`)}
|
||||
className="text-sm text-gray-900 hover:text-primary-600"
|
||||
title="View agent"
|
||||
>
|
||||
{update.agent_id.substring(0, 8)}...
|
||||
</button>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatRelativeTime(update.created_at)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-2">
|
||||
{update.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleApproveUpdate(update.id)}
|
||||
disabled={approveMutation.isLoading}
|
||||
className="text-success-600 hover:text-success-800"
|
||||
title="Approve"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRejectUpdate(update.id)}
|
||||
disabled={rejectMutation.isLoading}
|
||||
className="text-gray-600 hover:text-gray-800"
|
||||
title="Reject"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{update.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => handleInstallUpdate(update.id)}
|
||||
disabled={installMutation.isLoading}
|
||||
className="text-primary-600 hover:text-primary-800"
|
||||
title="Install"
|
||||
>
|
||||
<Package className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/updates/${update.id}`)}
|
||||
className="text-gray-400 hover:text-primary-600"
|
||||
title="View details"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Updates;
|
||||
175
aggregator-web/src/types/index.ts
Normal file
175
aggregator-web/src/types/index.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// API Response types
|
||||
export interface ApiResponse<T = any> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Agent types
|
||||
export interface Agent {
|
||||
id: string;
|
||||
hostname: string;
|
||||
os_type: string;
|
||||
os_version: string;
|
||||
architecture: string;
|
||||
status: 'online' | 'offline';
|
||||
last_checkin: string;
|
||||
last_scan: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: string;
|
||||
ip_address: string;
|
||||
}
|
||||
|
||||
export interface AgentSpec {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
cpu_cores: number;
|
||||
memory_mb: number;
|
||||
disk_gb: number;
|
||||
docker_version: string | null;
|
||||
kernel_version: string;
|
||||
metadata: Record<string, any>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Update types
|
||||
export interface UpdatePackage {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
package_type: 'apt' | 'docker' | 'yum' | 'dnf' | 'windows' | 'winget';
|
||||
package_name: string;
|
||||
current_version: string;
|
||||
available_version: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
status: 'pending' | 'approved' | 'scheduled' | 'installing' | 'installed' | 'failed';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
approved_at: string | null;
|
||||
scheduled_at: string | null;
|
||||
installed_at: string | null;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
// Update specific types
|
||||
export interface DockerUpdateInfo {
|
||||
local_digest: string;
|
||||
remote_digest: string;
|
||||
image_name: string;
|
||||
tag: string;
|
||||
registry: string;
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
export interface AptUpdateInfo {
|
||||
package_name: string;
|
||||
current_version: string;
|
||||
new_version: string;
|
||||
section: string;
|
||||
priority: string;
|
||||
repository: string;
|
||||
size_bytes: number;
|
||||
cves: string[];
|
||||
}
|
||||
|
||||
// Command types
|
||||
export interface Command {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
command_type: 'scan' | 'install' | 'update' | 'reboot';
|
||||
payload: Record<string, any>;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
executed_at: string | null;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
// Log types
|
||||
export interface UpdateLog {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
update_package_id: string | null;
|
||||
command_id: string | null;
|
||||
level: 'info' | 'warn' | 'error' | 'debug';
|
||||
message: string;
|
||||
metadata: Record<string, any>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Dashboard stats
|
||||
export interface DashboardStats {
|
||||
total_agents: number;
|
||||
online_agents: number;
|
||||
offline_agents: number;
|
||||
pending_updates: number;
|
||||
approved_updates: number;
|
||||
installed_updates: number;
|
||||
failed_updates: number;
|
||||
critical_updates: number;
|
||||
high_updates: number;
|
||||
medium_updates: number;
|
||||
low_updates: number;
|
||||
updates_by_type: Record<string, number>;
|
||||
}
|
||||
|
||||
// API request/response types
|
||||
export interface AgentListResponse {
|
||||
agents: Agent[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UpdateListResponse {
|
||||
updates: UpdatePackage[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UpdateApprovalRequest {
|
||||
update_ids: string[];
|
||||
scheduled_at?: string;
|
||||
}
|
||||
|
||||
export interface ScanRequest {
|
||||
agent_ids?: string[];
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
// Query parameters
|
||||
export interface ListQueryParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
severity?: string;
|
||||
type?: string;
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// UI State types
|
||||
export interface FilterState {
|
||||
status: string[];
|
||||
severity: string[];
|
||||
type: string[];
|
||||
search: string;
|
||||
}
|
||||
|
||||
export interface PaginationState {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// WebSocket message types (for future real-time updates)
|
||||
export interface WebSocketMessage {
|
||||
type: 'agent_status' | 'update_discovered' | 'update_installed' | 'command_completed';
|
||||
data: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Error types
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code?: string;
|
||||
details?: any;
|
||||
}
|
||||
80
aggregator-web/tailwind.config.js
Normal file
80
aggregator-web/tailwind.config.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'Monaco', 'monospace'],
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
31
aggregator-web/tsconfig.json
Normal file
31
aggregator-web/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
aggregator-web/tsconfig.node.json
Normal file
10
aggregator-web/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
22
aggregator-web/vite.config.ts
Normal file
22
aggregator-web/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
aggregator-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: aggregator-db
|
||||
environment:
|
||||
POSTGRES_DB: aggregator
|
||||
POSTGRES_USER: aggregator
|
||||
POSTGRES_PASSWORD: aggregator
|
||||
volumes:
|
||||
- aggregator-db-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U aggregator"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
aggregator-db-data:
|
||||
Reference in New Issue
Block a user