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