mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-13 06:56:06 +00:00
Frontend fixes: - Fixed VM thresholds incorrectly showing as 'CT' in alerts UI (issue #295) - Fixed threshold slider not reaching 0% and 100% edges (issue #295) - Fixed PBS form auto-filling with PVE settings (issue #296) - Fixed email config property mapping (server/port instead of smtpHost/smtpPort) Backend fixes: - Updated EmailConfig struct to use 'server' and 'port' JSON tags for consistency - Added 'provider' and 'startTLS' fields to EmailConfig - Fixed PORT env var to correctly set FrontendPort instead of BackendPort - Changed default log level from Debug to Info All changes tested and verified working correctly.
394 lines
No EOL
14 KiB
Go
394 lines
No EOL
14 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/joho/godotenv"
|
|
"github.com/rs/zerolog"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// Config holds all application configuration
|
|
// NOTE: The envconfig tags are legacy and not used - configuration is loaded from encrypted JSON files
|
|
type Config struct {
|
|
// Server settings
|
|
BackendHost string `envconfig:"BACKEND_HOST" default:"0.0.0.0"`
|
|
BackendPort int `envconfig:"BACKEND_PORT" default:"3000"`
|
|
FrontendHost string `envconfig:"FRONTEND_HOST" default:"0.0.0.0"`
|
|
FrontendPort int `envconfig:"FRONTEND_PORT" default:"7655"`
|
|
ConfigPath string `envconfig:"CONFIG_PATH" default:"/etc/pulse"`
|
|
DataPath string `envconfig:"DATA_PATH" default:"/var/lib/pulse"`
|
|
|
|
// Proxmox VE connections
|
|
PVEInstances []PVEInstance
|
|
|
|
// Proxmox Backup Server connections
|
|
PBSInstances []PBSInstance
|
|
|
|
// Monitoring settings
|
|
PollingInterval time.Duration `envconfig:"POLLING_INTERVAL"` // Loaded from system.json
|
|
ConcurrentPolling bool `envconfig:"CONCURRENT_POLLING" default:"true"`
|
|
ConnectionTimeout time.Duration `envconfig:"CONNECTION_TIMEOUT" default:"10s"`
|
|
MetricsRetentionDays int `envconfig:"METRICS_RETENTION_DAYS" default:"7"`
|
|
BackupPollingCycles int `envconfig:"BACKUP_POLLING_CYCLES" default:"10"`
|
|
WebhookBatchDelay time.Duration `envconfig:"WEBHOOK_BATCH_DELAY" default:"10s"`
|
|
|
|
// Logging settings
|
|
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
|
|
LogFile string `envconfig:"LOG_FILE" default:""`
|
|
LogMaxSize int `envconfig:"LOG_MAX_SIZE" default:"100"` // MB
|
|
LogMaxAge int `envconfig:"LOG_MAX_AGE" default:"30"` // days
|
|
LogCompress bool `envconfig:"LOG_COMPRESS" default:"true"`
|
|
|
|
// Security settings
|
|
APIToken string `envconfig:"API_TOKEN"`
|
|
AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"`
|
|
IframeEmbeddingAllow string `envconfig:"IFRAME_EMBEDDING_ALLOW" default:"SAMEORIGIN"`
|
|
|
|
// Update settings
|
|
UpdateChannel string `envconfig:"UPDATE_CHANNEL" default:"stable"`
|
|
AutoUpdateEnabled bool `envconfig:"AUTO_UPDATE_ENABLED" default:"false"`
|
|
AutoUpdateCheckInterval time.Duration `envconfig:"AUTO_UPDATE_CHECK_INTERVAL" default:"24h"`
|
|
AutoUpdateTime string `envconfig:"AUTO_UPDATE_TIME" default:"03:00"`
|
|
|
|
// Discovery settings
|
|
DiscoverySubnet string `envconfig:"DISCOVERY_SUBNET" default:"auto"`
|
|
|
|
// Deprecated - for backward compatibility
|
|
Port int `envconfig:"PORT"` // Maps to BackendPort
|
|
Debug bool `envconfig:"DEBUG" default:"false"`
|
|
}
|
|
|
|
// PVEInstance represents a Proxmox VE connection
|
|
type PVEInstance struct {
|
|
Name string
|
|
Host string // Primary endpoint (user-provided)
|
|
User string
|
|
Password string
|
|
TokenName string
|
|
TokenValue string
|
|
Fingerprint string
|
|
VerifySSL bool
|
|
MonitorVMs bool
|
|
MonitorContainers bool
|
|
MonitorStorage bool
|
|
MonitorBackups bool
|
|
|
|
// Cluster support
|
|
IsCluster bool // True if this is a cluster
|
|
ClusterName string // Cluster name if applicable
|
|
ClusterEndpoints []ClusterEndpoint // All discovered cluster nodes
|
|
}
|
|
|
|
// ClusterEndpoint represents a single node in a cluster
|
|
type ClusterEndpoint struct {
|
|
NodeID string // Node ID in cluster
|
|
NodeName string // Node name
|
|
Host string // Full URL (e.g., https://node1.lan:8006)
|
|
IP string // IP address
|
|
Online bool // Current online status
|
|
LastSeen time.Time // Last successful connection
|
|
}
|
|
|
|
// PBSInstance represents a Proxmox Backup Server connection
|
|
type PBSInstance struct {
|
|
Name string
|
|
Host string
|
|
User string
|
|
Password string
|
|
TokenName string
|
|
TokenValue string
|
|
Fingerprint string
|
|
VerifySSL bool
|
|
MonitorBackups bool
|
|
MonitorDatastores bool
|
|
MonitorSyncJobs bool
|
|
MonitorVerifyJobs bool
|
|
MonitorPruneJobs bool
|
|
MonitorGarbageJobs bool
|
|
}
|
|
|
|
// Global persistence instance for saving
|
|
var globalPersistence *ConfigPersistence
|
|
|
|
// Load reads configuration from encrypted persistence files
|
|
func Load() (*Config, error) {
|
|
// Get data directory from environment
|
|
dataDir := "/etc/pulse"
|
|
if dir := os.Getenv("PULSE_DATA_DIR"); dir != "" {
|
|
dataDir = dir
|
|
}
|
|
|
|
// Load .env file if it exists (for deployment overrides)
|
|
envFile := filepath.Join(dataDir, ".env")
|
|
if _, err := os.Stat(envFile); err == nil {
|
|
if err := godotenv.Load(envFile); err != nil {
|
|
log.Warn().Err(err).Str("file", envFile).Msg("Failed to load .env file")
|
|
} else {
|
|
log.Info().Str("file", envFile).Msg("Loaded .env file for deployment overrides")
|
|
}
|
|
}
|
|
|
|
// Also try loading from current directory for development
|
|
if err := godotenv.Load(); err == nil {
|
|
log.Info().Msg("Loaded configuration from .env in current directory")
|
|
}
|
|
|
|
// Initialize config with defaults
|
|
cfg := &Config{
|
|
BackendHost: "0.0.0.0",
|
|
BackendPort: 3000,
|
|
FrontendHost: "0.0.0.0",
|
|
FrontendPort: 7655,
|
|
ConfigPath: dataDir,
|
|
DataPath: dataDir,
|
|
ConcurrentPolling: true,
|
|
ConnectionTimeout: 10 * time.Second,
|
|
MetricsRetentionDays: 7,
|
|
BackupPollingCycles: 10,
|
|
WebhookBatchDelay: 10 * time.Second,
|
|
LogLevel: "info",
|
|
LogMaxSize: 100,
|
|
LogMaxAge: 30,
|
|
LogCompress: true,
|
|
AllowedOrigins: "*",
|
|
IframeEmbeddingAllow: "SAMEORIGIN",
|
|
PollingInterval: 3 * time.Second,
|
|
DiscoverySubnet: "auto",
|
|
}
|
|
|
|
// Initialize persistence
|
|
persistence := NewConfigPersistence(dataDir)
|
|
hasSystemConfig := false
|
|
if persistence != nil {
|
|
// Store global persistence for saving
|
|
globalPersistence = persistence
|
|
// Load nodes configuration
|
|
if nodesConfig, err := persistence.LoadNodesConfig(); err == nil && nodesConfig != nil {
|
|
cfg.PVEInstances = nodesConfig.PVEInstances
|
|
cfg.PBSInstances = nodesConfig.PBSInstances
|
|
log.Info().
|
|
Int("pve", len(cfg.PVEInstances)).
|
|
Int("pbs", len(cfg.PBSInstances)).
|
|
Msg("Loaded nodes configuration")
|
|
}
|
|
|
|
// Load system configuration
|
|
if systemSettings, err := persistence.LoadSystemSettings(); err == nil && systemSettings != nil {
|
|
hasSystemConfig = true
|
|
if systemSettings.PollingInterval > 0 {
|
|
cfg.PollingInterval = time.Duration(systemSettings.PollingInterval) * time.Second
|
|
}
|
|
if systemSettings.UpdateChannel != "" {
|
|
cfg.UpdateChannel = systemSettings.UpdateChannel
|
|
}
|
|
cfg.AutoUpdateEnabled = systemSettings.AutoUpdateEnabled
|
|
if systemSettings.AutoUpdateCheckInterval > 0 {
|
|
cfg.AutoUpdateCheckInterval = time.Duration(systemSettings.AutoUpdateCheckInterval) * time.Hour
|
|
}
|
|
if systemSettings.AutoUpdateTime != "" {
|
|
cfg.AutoUpdateTime = systemSettings.AutoUpdateTime
|
|
}
|
|
if systemSettings.AllowedOrigins != "" {
|
|
cfg.AllowedOrigins = systemSettings.AllowedOrigins
|
|
}
|
|
if systemSettings.ConnectionTimeout > 0 {
|
|
cfg.ConnectionTimeout = time.Duration(systemSettings.ConnectionTimeout) * time.Second
|
|
}
|
|
log.Info().
|
|
Dur("interval", cfg.PollingInterval).
|
|
Str("updateChannel", cfg.UpdateChannel).
|
|
Msg("Loaded system configuration")
|
|
}
|
|
}
|
|
|
|
// Limited environment variable support
|
|
// NOTE: Node configuration is NOT done via env vars - use the web UI instead
|
|
if port := os.Getenv("PORT"); port != "" {
|
|
if p, err := strconv.Atoi(port); err == nil {
|
|
cfg.FrontendPort = p // Fixed: PORT should set FrontendPort (the actual listening port)
|
|
log.Info().Int("port", p).Msg("Overriding frontend port from PORT env var")
|
|
}
|
|
}
|
|
if apiToken := os.Getenv("API_TOKEN"); apiToken != "" {
|
|
cfg.APIToken = apiToken
|
|
log.Info().Msg("Overriding API token from env var")
|
|
}
|
|
if updateChannel := os.Getenv("UPDATE_CHANNEL"); updateChannel != "" {
|
|
cfg.UpdateChannel = updateChannel
|
|
log.Info().Str("channel", updateChannel).Msg("Overriding update channel from env var")
|
|
} else if updateChannel := os.Getenv("PULSE_UPDATE_CHANNEL"); updateChannel != "" {
|
|
cfg.UpdateChannel = updateChannel
|
|
log.Info().Str("channel", updateChannel).Msg("Overriding update channel from PULSE_ env var")
|
|
}
|
|
|
|
// Auto-update settings from env vars
|
|
if autoUpdateEnabled := os.Getenv("AUTO_UPDATE_ENABLED"); autoUpdateEnabled != "" {
|
|
cfg.AutoUpdateEnabled = autoUpdateEnabled == "true" || autoUpdateEnabled == "1"
|
|
log.Info().Bool("enabled", cfg.AutoUpdateEnabled).Msg("Overriding auto-update enabled from env var")
|
|
}
|
|
if interval := os.Getenv("AUTO_UPDATE_CHECK_INTERVAL"); interval != "" {
|
|
if i, err := strconv.Atoi(interval); err == nil && i > 0 {
|
|
cfg.AutoUpdateCheckInterval = time.Duration(i) * time.Hour
|
|
log.Info().Int("hours", i).Msg("Overriding auto-update check interval from env var")
|
|
}
|
|
}
|
|
if updateTime := os.Getenv("AUTO_UPDATE_TIME"); updateTime != "" {
|
|
cfg.AutoUpdateTime = updateTime
|
|
log.Info().Str("time", updateTime).Msg("Overriding auto-update time from env var")
|
|
}
|
|
|
|
// Other settings from env vars - only use if not already set from system.json
|
|
if pollingInterval := os.Getenv("POLLING_INTERVAL"); pollingInterval != "" {
|
|
// Only use env var if system.json doesn't exist (for backwards compatibility)
|
|
if !hasSystemConfig {
|
|
if i, err := strconv.Atoi(pollingInterval); err == nil && i > 0 {
|
|
cfg.PollingInterval = time.Duration(i) * time.Second
|
|
log.Info().Int("seconds", i).Msg("Using polling interval from env var (no system.json exists)")
|
|
}
|
|
} else {
|
|
log.Debug().Str("env_value", pollingInterval).Msg("Ignoring POLLING_INTERVAL env var - using system.json value")
|
|
}
|
|
}
|
|
if connectionTimeout := os.Getenv("CONNECTION_TIMEOUT"); connectionTimeout != "" {
|
|
if i, err := strconv.Atoi(connectionTimeout); err == nil && i > 0 {
|
|
cfg.ConnectionTimeout = time.Duration(i) * time.Second
|
|
log.Info().Int("seconds", i).Msg("Overriding connection timeout from env var")
|
|
}
|
|
}
|
|
if allowedOrigins := os.Getenv("ALLOWED_ORIGINS"); allowedOrigins != "" {
|
|
cfg.AllowedOrigins = allowedOrigins
|
|
log.Info().Str("origins", allowedOrigins).Msg("Overriding allowed origins from env var")
|
|
}
|
|
if logLevel := os.Getenv("LOG_LEVEL"); logLevel != "" {
|
|
cfg.LogLevel = logLevel
|
|
log.Info().Str("level", logLevel).Msg("Overriding log level from env var")
|
|
}
|
|
|
|
// Discovery settings from env vars
|
|
if discoverySubnet := os.Getenv("DISCOVERY_SUBNET"); discoverySubnet != "" {
|
|
cfg.DiscoverySubnet = discoverySubnet
|
|
log.Info().Str("subnet", discoverySubnet).Msg("Overriding discovery subnet from env var")
|
|
}
|
|
|
|
// Set log level
|
|
switch cfg.LogLevel {
|
|
case "debug":
|
|
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
|
case "warn":
|
|
zerolog.SetGlobalLevel(zerolog.WarnLevel)
|
|
case "error":
|
|
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
|
default:
|
|
zerolog.SetGlobalLevel(zerolog.InfoLevel) // Default to info level
|
|
}
|
|
|
|
// Validate configuration
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid configuration: %w", err)
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// SaveConfig saves the configuration back to encrypted files
|
|
func SaveConfig(cfg *Config) error {
|
|
if globalPersistence == nil {
|
|
return fmt.Errorf("config persistence not initialized")
|
|
}
|
|
|
|
// Save nodes configuration
|
|
if err := globalPersistence.SaveNodesConfig(cfg.PVEInstances, cfg.PBSInstances); err != nil {
|
|
return fmt.Errorf("failed to save nodes config: %w", err)
|
|
}
|
|
|
|
// Save system configuration
|
|
systemSettings := SystemSettings{
|
|
PollingInterval: int(cfg.PollingInterval.Seconds()),
|
|
UpdateChannel: cfg.UpdateChannel,
|
|
AutoUpdateEnabled: cfg.AutoUpdateEnabled,
|
|
AutoUpdateCheckInterval: int(cfg.AutoUpdateCheckInterval.Hours()),
|
|
AutoUpdateTime: cfg.AutoUpdateTime,
|
|
AllowedOrigins: cfg.AllowedOrigins,
|
|
ConnectionTimeout: int(cfg.ConnectionTimeout.Seconds()),
|
|
}
|
|
if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil {
|
|
return fmt.Errorf("failed to save system config: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdatePollingInterval updates just the polling interval
|
|
func UpdatePollingInterval(interval int) error {
|
|
if globalPersistence == nil {
|
|
return fmt.Errorf("config persistence not initialized")
|
|
}
|
|
|
|
systemSettings := SystemSettings{
|
|
PollingInterval: interval,
|
|
}
|
|
return globalPersistence.SaveSystemSettings(systemSettings)
|
|
}
|
|
|
|
|
|
// Validate checks if the configuration is valid
|
|
func (c *Config) Validate() error {
|
|
// Validate server settings
|
|
if c.BackendPort <= 0 || c.BackendPort > 65535 {
|
|
return fmt.Errorf("invalid backend port: %d", c.BackendPort)
|
|
}
|
|
if c.FrontendPort <= 0 || c.FrontendPort > 65535 {
|
|
return fmt.Errorf("invalid frontend port: %d", c.FrontendPort)
|
|
}
|
|
|
|
// Validate monitoring settings
|
|
if c.PollingInterval < time.Second {
|
|
return fmt.Errorf("polling interval must be at least 1 second")
|
|
}
|
|
if c.ConnectionTimeout < time.Second {
|
|
return fmt.Errorf("connection timeout must be at least 1 second")
|
|
}
|
|
|
|
// Validate PVE instances
|
|
for i, pve := range c.PVEInstances {
|
|
if pve.Host == "" {
|
|
return fmt.Errorf("PVE instance %d: host is required", i+1)
|
|
}
|
|
if !strings.HasPrefix(pve.Host, "http://") && !strings.HasPrefix(pve.Host, "https://") {
|
|
return fmt.Errorf("PVE instance %d: host must start with http:// or https://", i+1)
|
|
}
|
|
// Must have either password or token
|
|
if pve.Password == "" && (pve.TokenName == "" || pve.TokenValue == "") {
|
|
return fmt.Errorf("PVE instance %d: either password or token authentication is required", i+1)
|
|
}
|
|
}
|
|
|
|
// Validate and auto-fix PBS instances
|
|
validPBS := []PBSInstance{}
|
|
for i, pbs := range c.PBSInstances {
|
|
if pbs.Host == "" {
|
|
log.Warn().Int("instance", i+1).Msg("PBS instance missing host, skipping")
|
|
continue
|
|
}
|
|
// Auto-fix missing protocol
|
|
if !strings.HasPrefix(pbs.Host, "http://") && !strings.HasPrefix(pbs.Host, "https://") {
|
|
pbs.Host = "https://" + pbs.Host
|
|
log.Info().Str("host", pbs.Host).Msg("PBS host auto-corrected to include https://")
|
|
}
|
|
// Check authentication
|
|
if pbs.Password == "" && (pbs.TokenName == "" || pbs.TokenValue == "") {
|
|
log.Warn().Int("instance", i+1).Str("host", pbs.Host).Msg("PBS instance missing authentication, skipping")
|
|
continue
|
|
}
|
|
validPBS = append(validPBS, pbs)
|
|
}
|
|
c.PBSInstances = validPBS
|
|
|
|
return nil
|
|
} |