Ctrl K
ComunidadGuías…

database.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"database/sql"
	"os"
	"path/filepath"

	"github.com/rs/zerolog/log"
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
	_ "modernc.org/sqlite"
)

// initDB initializes the database connection and runs migrations
func initDB() {
	var err error
	// Use pure Go SQLite driver (no CGO required)
	// Database path can be set via DB_PATH env var, defaults to ./nanostatus.db
	dbPath := os.Getenv("DB_PATH")
	if dbPath == "" {
		dbPath = "./nanostatus.db"
	}
	
	// Ensure the directory exists (for Docker volumes)
	if dir := filepath.Dir(dbPath); dir != "." && dir != "" {
		if err := os.MkdirAll(dir, 0755); err != nil {
			log.Warn().Err(err).Str("directory", dir).Msg("Could not create database directory")
		}
	}
	
	// Enable WAL mode and configure connection pool for better concurrency
	// WAL mode allows multiple readers and one writer simultaneously
	// _busy_timeout sets how long to wait for locks (in milliseconds)
	// Performance pragmas:
	// - _synchronous=NORMAL: Balance safety and performance (faster than FULL)
	// - _cache_size=-64000: 64MB cache (negative = KB)
	// - _temp_store=MEMORY: Store temp tables in memory
	// - _mmap_size=268435456: 256MB memory-mapped I/O
	dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=1&_synchronous=NORMAL&_cache_size=-64000&_temp_store=MEMORY&_mmap_size=268435456"
	
	// Open database connection with modernc.org/sqlite
	sqlDB, err := sql.Open("sqlite", dsn)
	if err != nil {
		log.Fatal().Err(err).Msg("Failed to open database")
	}

	// Configure connection pool for SQLite (WAL mode supports concurrent readers)
	sqlDB.SetMaxOpenConns(15)
	sqlDB.SetMaxIdleConns(5)

	// Create GORM instance
	db, err = gorm.Open(sqlite.Dialector{Conn: sqlDB}, &gorm.Config{})
	if err != nil {
		log.Fatal().Err(err).Msg("Failed to connect to database")
	}

	// Auto-migrate schemas
	if err := db.AutoMigrate(&Monitor{}, &CheckHistory{}, &CheckHistoryBucket{}); err != nil {
		log.Fatal().Err(err).Msg("Failed to migrate database")
	}

	// Create partial index for active monitors (SQLite doesn't support partial indexes in GORM tags)
	if err := db.Exec(`
		CREATE INDEX IF NOT EXISTS idx_monitors_active 
		ON monitors(status, uptime) 
		WHERE paused = 0
	`).Error; err != nil {
		log.Warn().Err(err).Msg("Failed to create partial index for active monitors")
	}

	// Create unique index for check_history_buckets to ensure one bucket per monitor per hour
	if err := db.Exec(`
		CREATE UNIQUE INDEX IF NOT EXISTS idx_bucket_unique 
		ON check_history_buckets(monitor_id, bucket_hour)
	`).Error; err != nil {
		log.Warn().Err(err).Msg("Failed to create unique index for check_history_buckets")
	}

	// Create aggregation views
	if err := createAggregationViews(db); err != nil {
		log.Fatal().Err(err).Msg("Failed to create aggregation views")
	}

	log.Info().Str("path", dbPath).Msg("✅ Database initialized")

	// Always sync YAML config on startup (creates if empty, updates if changed)
	syncYAMLConfig(dbPath)
}

// syncYAMLConfig synchronizes monitors from YAML config with the database
// Looks for monitors.yaml in the same directory as the database
// Compares hashes to detect changes and updates monitors accordingly
func syncYAMLConfig(dbPath string) {
	// Look for monitors.yaml in the same directory as the database
	dbDir := filepath.Dir(dbPath)
	configPath := filepath.Join(dbDir, "monitors.yaml")
	
	// Try to load from YAML config file
	yamlMonitors, yamlHashes, err := loadMonitorsFromYAML(configPath)
	if err != nil {
		log.Warn().Err(err).Str("config_path", configPath).Msg("[Config] Failed to load YAML config")
	}
	
	// Get all existing monitors from database
	var existingMonitors []Monitor
	db.Find(&existingMonitors)
	
	// Create a map of existing monitors by config hash (only YAML-managed monitors)
	existingByHash := make(map[string]*Monitor)
	existingByID := make(map[uint]*Monitor)
	for i := range existingMonitors {
		existingByID[existingMonitors[i].ID] = &existingMonitors[i]
		if existingMonitors[i].ConfigHash != "" {
			existingByHash[existingMonitors[i].ConfigHash] = &existingMonitors[i]
		}
	}
	
	// If no YAML config found and database is empty, use defaults
	if len(yamlMonitors) == 0 {
		var count int64
		db.Model(&Monitor{}).Count(&count)
		if count == 0 {
			log.Info().Msg("[Config] No YAML config found and database is empty, seeding defaults...")
			defaultMonitors := []Monitor{
				{
					Name:         "Example.com",
					URL:          "https://example.com",
					Status:       "up",
					ResponseTime: 229,
					LastCheck:    "5s ago",
					CheckInterval: 60,
				},
				{
					Name:         "Google",
					URL:          "https://google.com",
					Status:       "up",
					ResponseTime: 2097,
					LastCheck:    "1s ago",
					IsThirdParty: true,
					CheckInterval: 60,
				},
			}
			for _, monitor := range defaultMonitors {
				if err := db.Create(&monitor).Error; err != nil {
					log.Error().Err(err).Str("monitor", monitor.Name).Msg("[Config] Failed to seed default monitor")
				} else {
					log.Info().Str("name", monitor.Name).Str("url", monitor.URL).Msg("[Config] Created default monitor")
					go checkService(&monitor)
				}
			}
		}
		return
	}
	
	log.Info().Int("count", len(yamlMonitors)).Msg("[Config] Syncing monitors from YAML configuration")
	
	// Track which YAML hashes we've processed
	processedHashes := make(map[string]bool)
	
	// Process each monitor from YAML
	for i, monitor := range yamlMonitors {
		hash := yamlHashes[i]
		processedHashes[hash] = true
		
		// Check if a monitor with this hash already exists
		if _, exists := existingByHash[hash]; exists {
			// Monitor exists with same hash - no changes needed
			log.Debug().Str("name", monitor.Name).Str("url", monitor.URL).Str("hash", hash[:8]).Msg("[Config] Monitor unchanged")
			continue
		}
		
		// Check if a monitor with same name/URL exists
		var existingMonitor Monitor
		result := db.Where("name = ? AND url = ?", monitor.Name, monitor.URL).First(&existingMonitor)
		
		// Check if record exists (ignore "record not found" error as it's expected)
		if result.Error == nil {
			// Monitor with same name/URL exists
			if existingMonitor.ConfigHash == "" {
				// Monitor was created via UI/API (no ConfigHash) - skip YAML version to avoid duplicates
				log.Debug().Str("name", monitor.Name).Str("url", monitor.URL).Msg("[Config] Skipping monitor - already exists (created via UI/API)")
				continue
			}
			
			// Monitor exists and is YAML-managed - check if hash changed
			if existingMonitor.ConfigHash == hash {
				// Hash matches - no update needed (shouldn't reach here due to existingByHash check, but just in case)
				continue
			}
			
			// Monitor exists but hash changed - update it
			log.Info().Str("name", monitor.Name).Str("url", monitor.URL).
				Str("old_hash", existingMonitor.ConfigHash[:8]).Str("new_hash", hash[:8]).
				Msg("[Config] Updating monitor - config changed")
			
			// Preserve runtime data (status, uptime, response time, last check)
			monitor.ID = existingMonitor.ID
			monitor.Status = existingMonitor.Status
			monitor.Uptime = existingMonitor.Uptime
			monitor.ResponseTime = existingMonitor.ResponseTime
			monitor.LastCheck = existingMonitor.LastCheck
			monitor.CreatedAt = existingMonitor.CreatedAt
			
			if err := db.Save(&monitor).Error; err != nil {
				log.Error().Err(err).Str("name", monitor.Name).Msg("[Config] Failed to update monitor")
			} else {
				log.Info().Str("name", monitor.Name).Str("url", monitor.URL).Msg("[Config] Updated monitor")
				broadcastUpdate("monitor_update", monitor)
				// Re-check if not paused
				if !monitor.Paused {
					go checkService(&monitor)
				}
			}
		} else {
			// New monitor - create it
			if err := db.Create(&monitor).Error; err != nil {
				log.Error().Err(err).Str("name", monitor.Name).Msg("[Config] Failed to create monitor")
			} else {
				log.Info().Str("name", monitor.Name).Str("url", monitor.URL).Str("hash", hash[:8]).Msg("[Config] Created monitor")
				broadcastUpdate("monitor_added", monitor)
				// Immediately check the monitor
				go checkService(&monitor)
			}
		}
	}
	
	// Remove monitors that were in YAML but are no longer present
	// Only remove monitors that have a config_hash (were created from YAML)
	for hash, existing := range existingByHash {
		if !processedHashes[hash] {
			log.Info().Str("name", existing.Name).Str("url", existing.URL).Str("hash", hash[:8]).
				Msg("[Config] Removing monitor - no longer in YAML config")
			
			monitorID := existing.ID
			if err := db.Delete(&Monitor{}, monitorID).Error; err != nil {
				log.Error().Err(err).Str("name", existing.Name).Msg("[Config] Failed to delete monitor")
			} else {
				log.Info().Str("name", existing.Name).Str("url", existing.URL).Msg("[Config] Deleted monitor")
				broadcastUpdate("monitor_deleted", map[string]interface{}{"id": monitorID})
			}
		}
	}
	
	broadcastStatsIfChanged()
	log.Info().Msg("[Config] ✅ YAML configuration synchronized")
}

// createAggregationViews creates SQL views for common aggregations
func createAggregationViews(db *gorm.DB) error {
	// View 1: monitor_stats_24h - Pre-aggregates 24-hour uptime stats per monitor
	view1SQL := `
		CREATE VIEW IF NOT EXISTS monitor_stats_24h AS
		SELECT 
			monitor_id,
			COUNT(*) as total_checks,
			SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as up_checks,
			CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / COUNT(*) * 100.0 as uptime_percent,
			AVG(CASE WHEN status = 'up' AND response_time > 0 THEN response_time ELSE NULL END) as avg_response_time
		FROM check_histories
		WHERE created_at > datetime('now', '-24 hours')
		GROUP BY monitor_id
	`

	if err := db.Exec(view1SQL).Error; err != nil {
		return err
	}

	log.Info().Msg("✅ Created aggregation views")
	return nil
}