Update installer system for update approval functionality

Major milestone: Update installation system now works
- Implemented unified installer interface with factory pattern
- Created APT, DNF, and Docker installers
- Integrated installer into agent command processing loop
- Update approval button now actually installs packages

Documentation updates:
- Updated claude.md with Session 7 implementation log
- Created clean, professional README.md for GitHub
- Added screenshots section with 4 dashboard views
- Preserved detailed development history in backup files

Repository ready for GitHub alpha release with working installer functionality.
This commit is contained in:
Fimeg
2025-10-16 09:06:12 -04:00
parent 552f14f99a
commit a7fad61de2
15 changed files with 1608 additions and 80 deletions

View File

@@ -1,6 +1,7 @@
package handlers
import (
"log"
"net/http"
"time"
@@ -88,6 +89,7 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
return
}
log.Printf("Updated last_seen for agent %s", agentID)
// Get pending commands
commands, err := h.commandQueries.GetPendingCommands(agentID)
@@ -116,24 +118,29 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// ListAgents returns all agents
// ListAgents returns all agents with last scan information
func (h *AgentHandler) ListAgents(c *gin.Context) {
status := c.Query("status")
osType := c.Query("os_type")
agents, err := h.agentQueries.ListAgents(status, osType)
agents, err := h.agentQueries.ListAgentsWithLastScan(status, osType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list agents"})
return
}
// Debug: Log what we're returning
for _, agent := range agents {
log.Printf("DEBUG: Returning agent %s: last_seen=%s, last_scan=%s", agent.Hostname, agent.LastSeen, agent.LastScan)
}
c.JSON(http.StatusOK, gin.H{
"agents": agents,
"total": len(agents),
})
}
// GetAgent returns a single agent by ID
// GetAgent returns a single agent by ID with last scan information
func (h *AgentHandler) GetAgent(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
@@ -142,7 +149,7 @@ func (h *AgentHandler) GetAgent(c *gin.Context) {
return
}
agent, err := h.agentQueries.GetAgentByID(id)
agent, err := h.agentQueries.GetAgentWithLastScan(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
@@ -176,3 +183,94 @@ func (h *AgentHandler) TriggerScan(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "scan triggered", "command_id": cmd.ID})
}
// TriggerUpdate creates an update command for an agent
func (h *AgentHandler) TriggerUpdate(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
}
var req struct {
PackageType string `json:"package_type"` // "system", "docker", or specific type
PackageName string `json:"package_name"` // optional specific package
Action string `json:"action"` // "update_all", "update_approved", or "update_package"
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request format"})
return
}
// Validate action
validActions := map[string]bool{
"update_all": true,
"update_approved": true,
"update_package": true,
}
if !validActions[req.Action] {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid action. Use: update_all, update_approved, or update_package"})
return
}
// Create parameters for the command
params := models.JSONB{
"action": req.Action,
"package_type": req.PackageType,
}
if req.PackageName != "" {
params["package_name"] = req.PackageName
}
// Create update command
cmd := &models.AgentCommand{
ID: uuid.New(),
AgentID: agentID,
CommandType: models.CommandTypeInstallUpdate,
Params: params,
Status: models.CommandStatusPending,
}
if err := h.commandQueries.CreateCommand(cmd); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create update command"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "update command sent to agent",
"command_id": cmd.ID,
"action": req.Action,
"package": req.PackageName,
})
}
// UnregisterAgent removes an agent from the system
func (h *AgentHandler) UnregisterAgent(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
}
// Check if agent exists
agent, err := h.agentQueries.GetAgentByID(agentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
// Delete the agent and all associated data
if err := h.agentQueries.DeleteAgent(agentID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete agent"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "agent unregistered successfully",
"agent_id": agentID,
"hostname": agent.Hostname,
})
}

View File

@@ -0,0 +1,131 @@
package handlers
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// AuthHandler handles authentication for the web dashboard
type AuthHandler struct {
jwtSecret string
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(jwtSecret string) *AuthHandler {
return &AuthHandler{
jwtSecret: jwtSecret,
}
}
// LoginRequest represents a login request
type LoginRequest struct {
Token string `json:"token" binding:"required"`
}
// LoginResponse represents a login response
type LoginResponse struct {
Token string `json:"token"`
}
// UserClaims represents JWT claims for web dashboard users
type UserClaims struct {
UserID uuid.UUID `json:"user_id"`
jwt.RegisteredClaims
}
// Login handles web dashboard login
func (h *AuthHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request format"})
return
}
// For development, accept any non-empty token
// In production, implement proper authentication
if req.Token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
// Create JWT token for web dashboard
claims := UserClaims{
UserID: uuid.New(), // Generate a user ID for this session
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(h.jwtSecret))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"})
return
}
c.JSON(http.StatusOK, LoginResponse{Token: tokenString})
}
// VerifyToken handles token verification
func (h *AuthHandler) VerifyToken(c *gin.Context) {
// This is handled by middleware, but we can add additional verification here
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"valid": false})
return
}
c.JSON(http.StatusOK, gin.H{
"valid": true,
"user_id": userID,
})
}
// Logout handles logout (client-side token removal)
func (h *AuthHandler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "logged out successfully"})
}
// WebAuthMiddleware validates JWT tokens from web dashboard
func (h *AuthHandler) WebAuthMiddleware() 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 := authHeader
// Remove "Bearer " prefix if present
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
tokenString = authHeader[7:]
}
token, err := jwt.ParseWithClaims(tokenString, &UserClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(h.jwtSecret), nil
})
if err != nil || !token.Valid {
// Debug: Log the JWT validation error (remove in production)
fmt.Printf("🔓 JWT validation failed: %v (secret: %s)\n", err, h.jwtSecret)
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
c.Abort()
return
}
if claims, ok := token.Claims.(*UserClaims); ok {
c.Set("user_id", claims.UserID)
c.Next()
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"})
c.Abort()
}
}
}

View File

@@ -0,0 +1,67 @@
package handlers
import (
"net/http"
"github.com/aggregator-project/aggregator-server/internal/services"
"github.com/gin-gonic/gin"
)
type SettingsHandler struct {
timezoneService *services.TimezoneService
}
func NewSettingsHandler(timezoneService *services.TimezoneService) *SettingsHandler {
return &SettingsHandler{
timezoneService: timezoneService,
}
}
// GetTimezones returns available timezone options
func (h *SettingsHandler) GetTimezones(c *gin.Context) {
timezones := h.timezoneService.GetAvailableTimezones()
c.JSON(http.StatusOK, gin.H{"timezones": timezones})
}
// GetTimezone returns the current timezone configuration
func (h *SettingsHandler) GetTimezone(c *gin.Context) {
// TODO: Get from user settings when implemented
// For now, return the server timezone
c.JSON(http.StatusOK, gin.H{
"timezone": "UTC",
"label": "UTC (Coordinated Universal Time)",
})
}
// UpdateTimezone updates the timezone configuration
func (h *SettingsHandler) UpdateTimezone(c *gin.Context) {
var req struct {
Timezone string `json:"timezone" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// TODO: Save to user settings when implemented
// For now, just validate it's a valid timezone
timezones := h.timezoneService.GetAvailableTimezones()
valid := false
for _, tz := range timezones {
if tz.Value == req.Timezone {
valid = true
break
}
}
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid timezone"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "timezone updated",
"timezone": req.Timezone,
})
}

View File

@@ -0,0 +1,80 @@
package handlers
import (
"net/http"
"time"
"github.com/aggregator-project/aggregator-server/internal/database/queries"
"github.com/gin-gonic/gin"
)
// StatsHandler handles statistics for the dashboard
type StatsHandler struct {
agentQueries *queries.AgentQueries
updateQueries *queries.UpdateQueries
}
// NewStatsHandler creates a new stats handler
func NewStatsHandler(agentQueries *queries.AgentQueries, updateQueries *queries.UpdateQueries) *StatsHandler {
return &StatsHandler{
agentQueries: agentQueries,
updateQueries: updateQueries,
}
}
// DashboardStats represents dashboard statistics
type DashboardStats struct {
TotalAgents int `json:"total_agents"`
OnlineAgents int `json:"online_agents"`
OfflineAgents int `json:"offline_agents"`
PendingUpdates int `json:"pending_updates"`
FailedUpdates int `json:"failed_updates"`
CriticalUpdates int `json:"critical_updates"`
ImportantUpdates int `json:"important_updates"`
ModerateUpdates int `json:"moderate_updates"`
LowUpdates int `json:"low_updates"`
UpdatesByType map[string]int `json:"updates_by_type"`
}
// GetDashboardStats returns dashboard statistics using the new state table
func (h *StatsHandler) GetDashboardStats(c *gin.Context) {
// Get all agents
agents, err := h.agentQueries.ListAgents("", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get agents"})
return
}
// Calculate stats
stats := DashboardStats{
TotalAgents: len(agents),
UpdatesByType: make(map[string]int),
}
// Count online/offline agents based on last_seen timestamp
for _, agent := range agents {
// Consider agent online if it has checked in within the last 10 minutes
if time.Since(agent.LastSeen) <= 10*time.Minute {
stats.OnlineAgents++
} else {
stats.OfflineAgents++
}
// Get update stats for each agent using the new state table
agentStats, err := h.updateQueries.GetUpdateStatsFromState(agent.ID)
if err != nil {
// Log error but continue with other agents
continue
}
// Aggregate stats across all agents
stats.PendingUpdates += agentStats.PendingUpdates
stats.FailedUpdates += agentStats.FailedUpdates
stats.CriticalUpdates += agentStats.CriticalUpdates
stats.ImportantUpdates += agentStats.ImportantUpdates
stats.ModerateUpdates += agentStats.ModerateUpdates
stats.LowUpdates += agentStats.LowUpdates
}
c.JSON(http.StatusOK, stats)
}

View File

@@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"time"
@@ -13,54 +14,65 @@ import (
type UpdateHandler struct {
updateQueries *queries.UpdateQueries
agentQueries *queries.AgentQueries
}
func NewUpdateHandler(uq *queries.UpdateQueries) *UpdateHandler {
return &UpdateHandler{updateQueries: uq}
func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries) *UpdateHandler {
return &UpdateHandler{
updateQueries: uq,
agentQueries: aq,
}
}
// ReportUpdates handles update reports from agents
// ReportUpdates handles update reports from agents using event sourcing
func (h *UpdateHandler) ReportUpdates(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
// Update last_seen timestamp
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
return
}
var req models.UpdateReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Process each update
// Convert update report items to events
events := make([]models.UpdateEvent, 0, len(req.Updates))
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,
event := models.UpdateEvent{
ID: uuid.New(),
AgentID: agentID,
PackageType: item.PackageType,
PackageName: item.PackageName,
VersionFrom: item.CurrentVersion,
VersionTo: item.AvailableVersion,
Severity: item.Severity,
RepositorySource: item.RepositorySource,
Metadata: item.Metadata,
EventType: "discovered",
CreatedAt: req.Timestamp,
}
events = append(events, event)
}
if err := h.updateQueries.UpsertUpdate(update); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save update"})
return
}
// Store events in batch with error isolation
if err := h.updateQueries.CreateUpdateEventsBatch(events); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record update events"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "updates recorded",
"count": len(req.Updates),
"message": "update events recorded",
"count": len(events),
"command_id": req.CommandID,
})
}
// ListUpdates retrieves updates with filtering
// ListUpdates retrieves updates with filtering using the new state table
func (h *UpdateHandler) ListUpdates(c *gin.Context) {
filters := &models.UpdateFilters{
Status: c.Query("status"),
@@ -72,7 +84,7 @@ func (h *UpdateHandler) ListUpdates(c *gin.Context) {
if agentIDStr := c.Query("agent_id"); agentIDStr != "" {
agentID, err := uuid.Parse(agentIDStr)
if err == nil {
filters.AgentID = &agentID
filters.AgentID = agentID
}
}
@@ -82,17 +94,26 @@ func (h *UpdateHandler) ListUpdates(c *gin.Context) {
filters.Page = page
filters.PageSize = pageSize
updates, total, err := h.updateQueries.ListUpdates(filters)
updates, total, err := h.updateQueries.ListUpdatesFromState(filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list updates"})
return
}
// Get overall statistics for the summary cards
stats, err := h.updateQueries.GetAllUpdateStats()
if err != nil {
// Don't fail the request if stats fail, just log and continue
// In production, we'd use proper logging
stats = &models.UpdateStats{}
}
c.JSON(http.StatusOK, gin.H{
"updates": updates,
"total": total,
"page": page,
"page_size": pageSize,
"stats": stats,
})
}
@@ -125,7 +146,8 @@ func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
// 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"})
fmt.Printf("DEBUG: ApproveUpdate failed for ID %s: %v\n", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to approve update: %v", err)})
return
}
@@ -136,6 +158,12 @@ func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
func (h *UpdateHandler) ReportLog(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
// Update last_seen timestamp
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
return
}
var req models.UpdateLogRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -161,3 +189,158 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "log recorded"})
}
// GetPackageHistory returns version history for a specific package
func (h *UpdateHandler) GetPackageHistory(c *gin.Context) {
agentIDStr := c.Param("agent_id")
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
packageType := c.Query("package_type")
packageName := c.Query("package_name")
if packageType == "" || packageName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "package_type and package_name are required"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
history, err := h.updateQueries.GetPackageHistory(agentID, packageType, packageName, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get package history"})
return
}
c.JSON(http.StatusOK, gin.H{
"history": history,
"package_type": packageType,
"package_name": packageName,
"count": len(history),
})
}
// GetBatchStatus returns recent batch processing status for an agent
func (h *UpdateHandler) GetBatchStatus(c *gin.Context) {
agentIDStr := c.Param("agent_id")
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
batches, err := h.updateQueries.GetBatchStatus(agentID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get batch status"})
return
}
c.JSON(http.StatusOK, gin.H{
"batches": batches,
"count": len(batches),
})
}
// UpdatePackageStatus updates the status of a package (for when updates are installed)
func (h *UpdateHandler) UpdatePackageStatus(c *gin.Context) {
agentIDStr := c.Param("agent_id")
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
var req struct {
PackageType string `json:"package_type" binding:"required"`
PackageName string `json:"package_name" binding:"required"`
Status string `json:"status" binding:"required"`
Metadata map[string]interface{} `json:"metadata"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.updateQueries.UpdatePackageStatus(agentID, req.PackageType, req.PackageName, req.Status, req.Metadata); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "package status updated"})
}
// ApproveUpdates handles bulk approval of updates
func (h *UpdateHandler) ApproveUpdates(c *gin.Context) {
var req struct {
UpdateIDs []string `json:"update_ids" binding:"required"`
ScheduledAt *string `json:"scheduled_at"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Convert string IDs to UUIDs
updateIDs := make([]uuid.UUID, 0, len(req.UpdateIDs))
for _, idStr := range req.UpdateIDs {
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID: " + idStr})
return
}
updateIDs = append(updateIDs, id)
}
// For now, use "admin" as approver. Will integrate with proper auth later
if err := h.updateQueries.BulkApproveUpdates(updateIDs, "admin"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to approve updates"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "updates approved",
"count": len(updateIDs),
})
}
// RejectUpdate rejects a single update
func (h *UpdateHandler) RejectUpdate(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 rejecter. Will integrate with proper auth later
if err := h.updateQueries.RejectUpdate(id, "admin"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject update"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "update rejected"})
}
// InstallUpdate marks an update as ready for installation
func (h *UpdateHandler) InstallUpdate(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
}
if err := h.updateQueries.InstallUpdate(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start update installation"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "update installation started"})
}

View File

@@ -0,0 +1,80 @@
-- Event sourcing table for all update events
CREATE TABLE IF NOT EXISTS update_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
package_type VARCHAR(50) NOT NULL,
package_name TEXT NOT NULL,
version_from TEXT,
version_to TEXT NOT NULL,
severity VARCHAR(20) NOT NULL CHECK (severity IN ('critical', 'important', 'moderate', 'low')),
repository_source TEXT,
metadata JSONB DEFAULT '{}',
event_type VARCHAR(20) NOT NULL CHECK (event_type IN ('discovered', 'updated', 'failed', 'ignored')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Current state table for optimized queries
CREATE TABLE IF NOT EXISTS current_package_state (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
package_type VARCHAR(50) NOT NULL,
package_name TEXT NOT NULL,
current_version TEXT NOT NULL,
available_version TEXT,
severity VARCHAR(20) NOT NULL CHECK (severity IN ('critical', 'important', 'moderate', 'low')),
repository_source TEXT,
metadata JSONB DEFAULT '{}',
last_discovered_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'updated', 'failed', 'ignored', 'installing')),
UNIQUE(agent_id, package_type, package_name)
);
-- Version history table for audit trails
CREATE TABLE IF NOT EXISTS update_version_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
package_type VARCHAR(50) NOT NULL,
package_name TEXT NOT NULL,
version_from TEXT NOT NULL,
version_to TEXT NOT NULL,
severity VARCHAR(20) NOT NULL CHECK (severity IN ('critical', 'important', 'moderate', 'low')),
repository_source TEXT,
metadata JSONB DEFAULT '{}',
update_initiated_at TIMESTAMP WITH TIME ZONE,
update_completed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
update_status VARCHAR(20) NOT NULL CHECK (update_status IN ('success', 'failed', 'rollback')),
failure_reason TEXT
);
-- Batch processing tracking
CREATE TABLE IF NOT EXISTS update_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
batch_size INTEGER NOT NULL,
processed_count INTEGER DEFAULT 0,
failed_count INTEGER DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'processing' CHECK (status IN ('processing', 'completed', 'failed', 'cancelled')),
error_details JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_agent_events ON update_events(agent_id);
CREATE INDEX IF NOT EXISTS idx_package_events ON update_events(package_name, package_type);
CREATE INDEX IF NOT EXISTS idx_severity_events ON update_events(severity);
CREATE INDEX IF NOT EXISTS idx_created_events ON update_events(created_at);
CREATE INDEX IF NOT EXISTS idx_agent_state ON current_package_state(agent_id);
CREATE INDEX IF NOT EXISTS idx_package_state ON current_package_state(package_name, package_type);
CREATE INDEX IF NOT EXISTS idx_severity_state ON current_package_state(severity);
CREATE INDEX IF NOT EXISTS idx_status_state ON current_package_state(status);
CREATE INDEX IF NOT EXISTS idx_agent_history ON update_version_history(agent_id);
CREATE INDEX IF NOT EXISTS idx_package_history ON update_version_history(package_name, package_type);
CREATE INDEX IF NOT EXISTS idx_completed_history ON update_version_history(update_completed_at);
CREATE INDEX IF NOT EXISTS idx_agent_batches ON update_batches(agent_id);
CREATE INDEX IF NOT EXISTS idx_batch_status ON update_batches(status);
CREATE INDEX IF NOT EXISTS idx_created_batches ON update_batches(created_at);

View File

@@ -0,0 +1,58 @@
package services
import (
"time"
"github.com/aggregator-project/aggregator-server/internal/config"
)
type TimezoneService struct {
config *config.Config
}
func NewTimezoneService(config *config.Config) *TimezoneService {
return &TimezoneService{
config: config,
}
}
// GetTimezoneLocation returns the configured timezone as a time.Location
func (s *TimezoneService) GetTimezoneLocation() (*time.Location, error) {
return time.LoadLocation(s.config.Timezone)
}
// FormatTimeForTimezone formats a time.Time according to the configured timezone
func (s *TimezoneService) FormatTimeForTimezone(t time.Time) (time.Time, error) {
loc, err := s.GetTimezoneLocation()
if err != nil {
return t, err
}
return t.In(loc), nil
}
// GetNowInTimezone returns the current time in the configured timezone
func (s *TimezoneService) GetNowInTimezone() (time.Time, error) {
return s.FormatTimeForTimezone(time.Now())
}
// GetAvailableTimezones returns a list of common timezones
func (s *TimezoneService) GetAvailableTimezones() []TimezoneOption {
return []TimezoneOption{
{Value: "UTC", Label: "UTC (Coordinated Universal Time)"},
{Value: "America/New_York", Label: "Eastern Time (ET)"},
{Value: "America/Chicago", Label: "Central Time (CT)"},
{Value: "America/Denver", Label: "Mountain Time (MT)"},
{Value: "America/Los_Angeles", Label: "Pacific Time (PT)"},
{Value: "Europe/London", Label: "London (GMT)"},
{Value: "Europe/Paris", Label: "Paris (CET)"},
{Value: "Europe/Berlin", Label: "Berlin (CET)"},
{Value: "Asia/Tokyo", Label: "Tokyo (JST)"},
{Value: "Asia/Shanghai", Label: "Shanghai (CST)"},
{Value: "Australia/Sydney", Label: "Sydney (AEDT)"},
}
}
type TimezoneOption struct {
Value string `json:"value"`
Label string `json:"label"`
}