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:
Fimeg
2025-10-13 16:46:31 -04:00
commit 55b7d03010
57 changed files with 7326 additions and 0 deletions

426
.gitignore vendored Normal file
View 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
View 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
View 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

Binary file not shown.

View 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
View 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
View 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
View 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 = ""
}

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

View 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 != ""
}

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

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

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

View 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)
}
}
}

View 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

View 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
View 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
View 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=

View 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})
}

View 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"})
}

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

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

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

View File

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

View File

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

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

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

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

View 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)
}

View 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"
)

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

View File

@@ -0,0 +1,5 @@
# API Configuration
VITE_API_URL=http://localhost:8080/api/v1
# Environment
VITE_NODE_ENV=development

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

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

112
aggregator-web/src/App.tsx Normal file
View 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;

View 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;

View 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;

View 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,
});
},
});
};

View 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));
},
});
};

View 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,
});
},
});
};

View 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;
}
}

View 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;

View 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),
}
)
);

View 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
}
},
};

View 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>,
)

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;
}

View 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: [],
}

View 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" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View 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
View 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: