Files
pulse/internal/api/system_settings.go
T
rcourtman 30fa3fd810 feat: add complete Proxmox Mail Gateway (PMG) monitoring support
Add comprehensive PMG monitoring with mail statistics, queue depth tracking,
spam distribution analysis, and quarantine monitoring. Includes full discovery
support and UI consistency improvements across all Proxmox products.

Backend:
- Add pkg/pmg package with complete API client for PMG operations
- Implement mail statistics collection (inbound/outbound, spam, virus, bounces)
- Add queue depth monitoring (active, deferred, hold, incoming queues)
- Support spam score distribution and quarantine totals
- Add PMG-specific discovery logic to differentiate from PVE on port 8006
- Extend mock data generator with realistic PMG instances and metrics
- Add PMG node configuration support in config system

Frontend:
- Create MailGateway.tsx component with detailed PMG dashboard
- Display mail flow statistics with time-series charts
- Show queue depth with color-coded warnings (>50 messages or >30min age)
- Add spam distribution histogram and quarantine status
- Support cluster node status with individual queue monitoring
- Add PMG to network discovery with purple branding and mail icon
- Implement conditional navigation (hide PMG tab when no instances configured)
- Standardize discovery UI controls across PVE/PBS/PMG settings pages

API:
- Add /api/config/pmg endpoints for node configuration
- Support PMG-specific monitoring toggles (mail stats, queues, quarantine)
- Extend system settings with PMG configuration options

Discovery:
- Detect PMG vs PVE on shared port 8006 using /api2/json/statistics/mail endpoint
- Return 'pmg' type for mail gateway servers in discovery results
- Update DiscoveryModal to display PMG servers with appropriate styling

This completes ecosystem monitoring support for all three Proxmox products:
Proxmox VE, Proxmox Backup Server, and Proxmox Mail Gateway.
2025-10-10 14:30:51 +00:00

400 lines
12 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/discovery"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
"github.com/rs/zerolog/log"
)
// SystemSettingsHandler handles system settings
type SystemSettingsHandler struct {
config *config.Config
persistence *config.ConfigPersistence
wsHub *websocket.Hub
monitor interface {
GetDiscoveryService() *discovery.Service
StartDiscoveryService(ctx context.Context, wsHub *websocket.Hub, subnet string)
StopDiscoveryService()
}
}
// NewSystemSettingsHandler creates a new system settings handler
func NewSystemSettingsHandler(cfg *config.Config, persistence *config.ConfigPersistence, wsHub *websocket.Hub, monitor interface {
GetDiscoveryService() *discovery.Service
StartDiscoveryService(ctx context.Context, wsHub *websocket.Hub, subnet string)
StopDiscoveryService()
}) *SystemSettingsHandler {
return &SystemSettingsHandler{
config: cfg,
persistence: persistence,
wsHub: wsHub,
monitor: monitor,
}
}
// SetMonitor updates the monitor reference used by the handler at runtime.
func (h *SystemSettingsHandler) SetMonitor(m interface {
GetDiscoveryService() *discovery.Service
StartDiscoveryService(ctx context.Context, wsHub *websocket.Hub, subnet string)
StopDiscoveryService()
}) {
h.monitor = m
}
// validateSystemSettings validates settings before applying them
func validateSystemSettings(settings *config.SystemSettings, rawRequest map[string]interface{}) error {
// Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s
// Legacy polling interval fields are ignored if provided
if val, ok := rawRequest["pbsPollingInterval"]; ok {
if interval, ok := val.(float64); ok {
if interval <= 0 {
return fmt.Errorf("PBS polling interval must be positive (minimum 10 seconds)")
}
if interval < 10 {
return fmt.Errorf("PBS polling interval must be at least 10 seconds")
}
if interval > 3600 {
return fmt.Errorf("PBS polling interval cannot exceed 3600 seconds (1 hour)")
}
} else {
return fmt.Errorf("PBS polling interval must be a number")
}
}
if val, ok := rawRequest["pmgPollingInterval"]; ok {
if interval, ok := val.(float64); ok {
if interval <= 0 {
return fmt.Errorf("PMG polling interval must be positive (minimum 10 seconds)")
}
if interval < 10 {
return fmt.Errorf("PMG polling interval must be at least 10 seconds")
}
if interval > 3600 {
return fmt.Errorf("PMG polling interval cannot exceed 3600 seconds (1 hour)")
}
} else {
return fmt.Errorf("PMG polling interval must be a number")
}
}
// Validate boolean fields have correct type
if val, ok := rawRequest["autoUpdateEnabled"]; ok {
if _, ok := val.(bool); !ok {
return fmt.Errorf("autoUpdateEnabled must be a boolean")
}
}
if val, ok := rawRequest["discoveryEnabled"]; ok {
if _, ok := val.(bool); !ok {
return fmt.Errorf("discoveryEnabled must be a boolean")
}
}
if val, ok := rawRequest["allowEmbedding"]; ok {
if _, ok := val.(bool); !ok {
return fmt.Errorf("allowEmbedding must be a boolean")
}
}
// Validate auto-update check interval (min 1 hour, max 7 days)
if val, ok := rawRequest["autoUpdateCheckInterval"]; ok {
if interval, ok := val.(float64); ok {
if interval < 0 {
return fmt.Errorf("auto-update check interval cannot be negative")
}
if interval > 0 && interval < 1 {
return fmt.Errorf("auto-update check interval must be at least 1 hour")
}
if interval > 168 {
return fmt.Errorf("auto-update check interval cannot exceed 168 hours (7 days)")
}
} else {
return fmt.Errorf("auto-update check interval must be a number")
}
}
// Validate connection timeout (min 1 second, max 5 minutes)
if val, ok := rawRequest["connectionTimeout"]; ok {
if timeout, ok := val.(float64); ok {
if timeout < 0 {
return fmt.Errorf("connection timeout cannot be negative")
}
if timeout > 0 && timeout < 1 {
return fmt.Errorf("connection timeout must be at least 1 second")
}
if timeout > 300 {
return fmt.Errorf("connection timeout cannot exceed 300 seconds (5 minutes)")
}
} else {
return fmt.Errorf("connection timeout must be a number")
}
}
// Validate theme
if val, ok := rawRequest["theme"]; ok {
if theme, ok := val.(string); ok {
if theme != "" && theme != "light" && theme != "dark" {
return fmt.Errorf("theme must be 'light', 'dark', or empty")
}
} else {
return fmt.Errorf("theme must be a string")
}
}
// Validate update channel
if val, ok := rawRequest["updateChannel"]; ok {
if channel, ok := val.(string); ok {
if channel != "" && channel != "stable" && channel != "rc" {
return fmt.Errorf("update channel must be 'stable' or 'rc'")
}
} else {
return fmt.Errorf("update channel must be a string")
}
}
return nil
}
// HandleGetSystemSettings returns the current system settings
func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
settings, err := h.persistence.LoadSystemSettings()
if err != nil {
log.Error().Err(err).Msg("Failed to load system settings")
settings = &config.SystemSettings{}
}
// Log loaded settings for debugging
if settings != nil {
log.Debug().
Str("theme", settings.Theme).
Msg("Loaded system settings for API response")
}
// Include env override information
response := struct {
*config.SystemSettings
EnvOverrides map[string]bool `json:"envOverrides,omitempty"`
}{
SystemSettings: settings,
EnvOverrides: h.config.EnvOverrides,
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write system settings response")
}
}
// HandleUpdateSystemSettings updates the system settings
func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Require authentication
if !CheckAuth(h.config, w, r) {
return
}
// Check if using proxy auth and if so, verify admin status
if h.config.ProxyAuthSecret != "" {
if valid, username, isAdmin := CheckProxyAuth(h.config, r); valid {
if !isAdmin {
// User is authenticated but not an admin
log.Warn().
Str("ip", r.RemoteAddr).
Str("path", r.URL.Path).
Str("method", r.Method).
Str("username", username).
Msg("Non-admin user attempted to update system settings")
// Return forbidden error
utils.WriteJSONError(w, "Admin privileges required", http.StatusForbidden)
return
}
}
}
// Load existing settings first to preserve fields not in the request
existingSettings, err := h.persistence.LoadSystemSettings()
if err != nil {
log.Error().Err(err).Msg("Failed to load existing settings")
existingSettings = &config.SystemSettings{}
}
if existingSettings == nil {
existingSettings = &config.SystemSettings{}
}
// Read the request body into a map to check which fields were provided
var rawRequest map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&rawRequest); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Convert the map back to JSON for decoding into struct
jsonBytes, err := json.Marshal(rawRequest)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Decode into updates struct
var updates config.SystemSettings
if err := json.Unmarshal(jsonBytes, &updates); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate the settings
if err := validateSystemSettings(&updates, rawRequest); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Start with existing settings
settings := *existingSettings
// Only update fields that were provided in the request
// Note: PVE polling is hardcoded to 10s, legacy polling fields are ignored
if _, ok := rawRequest["pbsPollingInterval"]; ok {
settings.PBSPollingInterval = updates.PBSPollingInterval
}
if _, ok := rawRequest["pmgPollingInterval"]; ok {
settings.PMGPollingInterval = updates.PMGPollingInterval
}
if updates.AllowedOrigins != "" {
settings.AllowedOrigins = updates.AllowedOrigins
}
if _, ok := rawRequest["connectionTimeout"]; ok {
settings.ConnectionTimeout = updates.ConnectionTimeout
}
if updates.UpdateChannel != "" {
settings.UpdateChannel = updates.UpdateChannel
}
if _, ok := rawRequest["autoUpdateCheckInterval"]; ok {
settings.AutoUpdateCheckInterval = updates.AutoUpdateCheckInterval
}
if updates.AutoUpdateTime != "" {
settings.AutoUpdateTime = updates.AutoUpdateTime
}
if updates.Theme != "" {
settings.Theme = updates.Theme
}
if updates.DiscoverySubnet != "" {
settings.DiscoverySubnet = updates.DiscoverySubnet
}
// Allow clearing of AllowedEmbedOrigins by setting to empty string
if _, ok := rawRequest["allowedEmbedOrigins"]; ok {
settings.AllowedEmbedOrigins = updates.AllowedEmbedOrigins
}
// Boolean fields need special handling since false is a valid value
if _, ok := rawRequest["autoUpdateEnabled"]; ok {
settings.AutoUpdateEnabled = updates.AutoUpdateEnabled
}
if _, ok := rawRequest["discoveryEnabled"]; ok {
settings.DiscoveryEnabled = updates.DiscoveryEnabled
}
if _, ok := rawRequest["allowEmbedding"]; ok {
settings.AllowEmbedding = updates.AllowEmbedding
}
// Update the config
// Note: PVE polling is hardcoded to 10s
if settings.AllowedOrigins != "" {
h.config.AllowedOrigins = settings.AllowedOrigins
}
if settings.ConnectionTimeout > 0 {
h.config.ConnectionTimeout = time.Duration(settings.ConnectionTimeout) * time.Second
}
if settings.PMGPollingInterval > 0 {
h.config.PMGPollingInterval = time.Duration(settings.PMGPollingInterval) * time.Second
}
if settings.UpdateChannel != "" {
h.config.UpdateChannel = settings.UpdateChannel
}
// Update auto-update settings
h.config.AutoUpdateEnabled = settings.AutoUpdateEnabled
if settings.AutoUpdateCheckInterval > 0 {
h.config.AutoUpdateCheckInterval = time.Duration(settings.AutoUpdateCheckInterval) * time.Hour
}
if settings.AutoUpdateTime != "" {
h.config.AutoUpdateTime = settings.AutoUpdateTime
}
// Validate theme if provided
if settings.Theme != "" && settings.Theme != "light" && settings.Theme != "dark" {
http.Error(w, "Invalid theme value. Must be 'light', 'dark', or empty", http.StatusBadRequest)
return
}
// Update discovery settings and manage the service
prevDiscoveryEnabled := h.config.DiscoveryEnabled
h.config.DiscoveryEnabled = settings.DiscoveryEnabled
if settings.DiscoverySubnet != "" {
h.config.DiscoverySubnet = settings.DiscoverySubnet
}
// Start or stop discovery service based on setting change
if h.monitor != nil {
if settings.DiscoveryEnabled && !prevDiscoveryEnabled {
// Discovery was just enabled, start the service
subnet := h.config.DiscoverySubnet
if subnet == "" {
subnet = "auto"
}
h.monitor.StartDiscoveryService(context.Background(), h.wsHub, subnet)
log.Info().Msg("Discovery service started via settings update")
} else if !settings.DiscoveryEnabled && prevDiscoveryEnabled {
// Discovery was just disabled, stop the service
h.monitor.StopDiscoveryService()
log.Info().Msg("Discovery service stopped via settings update")
} else if settings.DiscoveryEnabled && settings.DiscoverySubnet != "" {
// Subnet changed while discovery is enabled, update it
if svc := h.monitor.GetDiscoveryService(); svc != nil {
svc.SetSubnet(settings.DiscoverySubnet)
}
}
}
// Save to persistence
if err := h.persistence.SaveSystemSettings(settings); err != nil {
log.Error().Err(err).Msg("Failed to save system settings")
http.Error(w, "Failed to save settings", http.StatusInternalServerError)
return
}
log.Info().Msg("System settings updated")
// Broadcast theme change to all connected clients if theme was updated
if settings.Theme != "" && h.wsHub != nil {
h.wsHub.BroadcastMessage(websocket.Message{
Type: "settingsUpdate",
Data: map[string]interface{}{
"theme": settings.Theme,
},
})
log.Debug().Str("theme", settings.Theme).Msg("Broadcasting theme change to WebSocket clients")
}
if err := utils.WriteJSONResponse(w, map[string]bool{"success": true}); err != nil {
log.Error().Err(err).Msg("Failed to write system settings update response")
}
}