feat: make PVE polling interval configurable (related to #467)

This commit is contained in:
rcourtman 2025-11-18 21:30:04 +00:00
parent 274c45799f
commit 3f46d35a81
13 changed files with 357 additions and 69 deletions

View file

@ -396,7 +396,7 @@ The UI detects your deployment type and reminds you which command sequence to ru
Quick start - most settings are in the web UI:
- **Settings → Nodes**: Add/remove Proxmox instances
- **Settings → System**: Polling intervals, timeouts, update settings
- **Settings → System**: Monitor cadence (PVE polling), timeouts, update settings
- **Settings → Security**: Authentication and API tokens
- **Alerts**: Thresholds and notifications

View file

@ -252,22 +252,14 @@ func runServer() {
configWatcher.ReloadConfig()
}
// Reload system.json
persistence := config.NewConfigPersistence(cfg.DataPath)
if persistence != nil {
if _, err := persistence.LoadSystemSettings(); err == nil {
// Note: Polling interval is now hardcoded to 10s, no longer configurable
// Could reload other system.json settings here
log.Info().Msg("Reloaded system configuration")
if reloadFunc != nil {
if err := reloadFunc(); err != nil {
log.Error().Err(err).Msg("Failed to reload monitor after SIGHUP")
} else {
log.Error().Err(err).Msg("Failed to reload system.json")
log.Info().Msg("Runtime configuration reloaded")
}
}
// Could reload other configs here (alerts.json, webhooks.json, etc.)
log.Info().Msg("Configuration reload complete")
case <-sigChan:
log.Info().Msg("Shutting down server...")
goto shutdown

View file

@ -107,7 +107,8 @@ PROXY_AUTH_LOGOUT_URL=/logout # URL for SSO logout
**Contents:**
```json
{
"pbsPollingInterval": 60, // Seconds between PBS refreshes (PVE polling fixed at 10s)
"pvePollingInterval": 10, // Seconds between PVE refreshes (10s default, 103600 allowed)
"pbsPollingInterval": 60, // Seconds between PBS refreshes
"pmgPollingInterval": 60, // Seconds between PMG refreshes (mail analytics and health)
"connectionTimeout": 60, // Seconds before node connection timeout
"autoUpdateEnabled": false, // Systemd timer toggle for automatic updates
@ -142,6 +143,7 @@ PROXY_AUTH_LOGOUT_URL=/logout # URL for SSO logout
- Can be safely backed up without exposing secrets
- Missing file results in defaults being used
- Changes take effect immediately (no restart required)
- `pvePollingInterval` controls how often Pulse polls all Proxmox VE nodes. Configure it in **Settings → System → General → Monitoring cadence** (103600 seconds).
- API tokens are no longer managed in system.json (moved to .env in v4.3.9+)
- **Adaptive polling controls** (`adaptivePollingEnabled`, `adaptivePolling*Interval`) map directly to the Scheduler Health API and adjust queue/backoff behaviour in real time.
- **Runtime logging controls** (`logLevel`, `logFormat`, `logFile`, `logMaxSize`, `logMaxAge`, `logCompress`) can be tuned from the UI or system.json; updates are applied immediately so you can raise verbosity, switch to structured JSON, or stream logs to disk without restarting Pulse.

View file

@ -406,6 +406,17 @@ const BACKUP_INTERVAL_OPTIONS = [
const BACKUP_INTERVAL_MAX_MINUTES = 7 * 24 * 60; // 7 days
const PVE_POLLING_MIN_SECONDS = 10;
const PVE_POLLING_MAX_SECONDS = 3600;
const PVE_POLLING_PRESETS = [
{ label: '10 seconds (default)', value: 10 },
{ label: '15 seconds', value: 15 },
{ label: '30 seconds', value: 30 },
{ label: '60 seconds', value: 60 },
{ label: '2 minutes', value: 120 },
{ label: '5 minutes', value: 300 },
];
// Node with UI-specific fields
type NodeConfigWithStatus = NodeConfig & {
hasPassword?: boolean;
@ -610,7 +621,11 @@ const Settings: Component<SettingsProps> = (props) => {
const pmgNodes = createMemo(() => nodes().filter((n) => n.type === 'pmg'));
// System settings
// PBS polling interval removed - fixed at 10 seconds
const [pvePollingInterval, setPVEPollingInterval] = createSignal<number>(PVE_POLLING_MIN_SECONDS);
const [pvePollingSelection, setPVEPollingSelection] = createSignal<number | 'custom'>(
PVE_POLLING_MIN_SECONDS,
);
const [pvePollingCustomSeconds, setPVEPollingCustomSeconds] = createSignal(30);
const [allowedOrigins, setAllowedOrigins] = createSignal('*');
const [discoveryEnabled, setDiscoveryEnabled] = createSignal(false);
const [discoverySubnet, setDiscoverySubnet] = createSignal('auto');
@ -627,6 +642,8 @@ const Settings: Component<SettingsProps> = (props) => {
Boolean(
envOverrides().temperatureMonitoringEnabled || envOverrides()['ENABLE_TEMPERATURE_MONITORING'],
);
const pvePollingEnvLocked = () =>
Boolean(envOverrides().pvePollingInterval || envOverrides().PVE_POLLING_INTERVAL);
let discoverySubnetInputRef: HTMLInputElement | undefined;
const parseSubnetList = (value: string) => {
@ -1912,7 +1929,22 @@ const Settings: Component<SettingsProps> = (props) => {
// Load system settings
try {
const systemSettings = await SettingsAPI.getSystemSettings();
// PBS polling interval is now fixed at 10 seconds
const rawPVESecs =
typeof systemSettings.pvePollingInterval === 'number'
? Math.round(systemSettings.pvePollingInterval)
: PVE_POLLING_MIN_SECONDS;
const clampedPVESecs = Math.min(
PVE_POLLING_MAX_SECONDS,
Math.max(PVE_POLLING_MIN_SECONDS, rawPVESecs),
);
setPVEPollingInterval(clampedPVESecs);
const presetMatch = PVE_POLLING_PRESETS.find((opt) => opt.value === clampedPVESecs);
if (presetMatch) {
setPVEPollingSelection(presetMatch.value);
} else {
setPVEPollingSelection('custom');
setPVEPollingCustomSeconds(clampedPVESecs);
}
setAllowedOrigins(systemSettings.allowedOrigins || '*');
// Connection timeout is backend-only
// Load discovery settings (default to false when unset)
@ -2027,7 +2059,7 @@ const Settings: Component<SettingsProps> = (props) => {
) {
// Save system settings using typed API
await SettingsAPI.updateSystemSettings({
// PBS polling interval is now fixed at 10 seconds
pvePollingInterval: pvePollingInterval(),
allowedOrigins: allowedOrigins(),
// Connection timeout is backend-only
// Discovery settings are saved immediately on toggle
@ -3523,6 +3555,133 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
</div>
</Card>
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
<Activity class="w-5 h-5 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<SectionHeader
title="Monitoring cadence"
description="Control how frequently Pulse polls Proxmox VE nodes."
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-6 space-y-5">
<div class="space-y-2">
<p class="text-sm text-gray-600 dark:text-gray-400">
Shorter intervals provide near-real-time updates at the cost of higher API
and CPU usage on each node. Set a longer interval to reduce load on busy
clusters.
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Current cadence: {pvePollingInterval()} seconds (
{pvePollingInterval() >= 60
? `${(pvePollingInterval() / 60).toFixed(
pvePollingInterval() % 60 === 0 ? 0 : 1,
)} minute${pvePollingInterval() / 60 === 1 ? '' : 's'}`
: 'under a minute'}
).
</p>
</div>
<div class="space-y-4">
<div class="grid gap-2 sm:grid-cols-3">
<For each={PVE_POLLING_PRESETS}>
{(option) => (
<button
type="button"
class={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
pvePollingSelection() === option.value
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-100'
: 'border-gray-200 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-900/50 dark:text-gray-200'
} ${pvePollingEnvLocked() ? 'opacity-60 cursor-not-allowed' : ''}`}
disabled={pvePollingEnvLocked()}
onClick={() => {
if (pvePollingEnvLocked()) return;
setPVEPollingSelection(option.value);
setPVEPollingInterval(option.value);
setHasUnsavedChanges(true);
}}
>
{option.label}
</button>
)}
</For>
<button
type="button"
class={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
pvePollingSelection() === 'custom'
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-100'
: 'border-gray-200 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-900/50 dark:text-gray-200'
} ${pvePollingEnvLocked() ? 'opacity-60 cursor-not-allowed' : ''}`}
disabled={pvePollingEnvLocked()}
onClick={() => {
if (pvePollingEnvLocked()) return;
setPVEPollingSelection('custom');
setPVEPollingInterval(pvePollingCustomSeconds());
setHasUnsavedChanges(true);
}}
>
Custom interval
</button>
</div>
<Show when={pvePollingSelection() === 'custom'}>
<div class="space-y-2 rounded-md border border-dashed border-gray-300 p-4 dark:border-gray-600">
<label class="text-xs font-medium text-gray-700 dark:text-gray-200">
Custom polling interval (103600 seconds)
</label>
<input
type="number"
min={PVE_POLLING_MIN_SECONDS}
max={PVE_POLLING_MAX_SECONDS}
value={pvePollingCustomSeconds()}
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-900/60"
disabled={pvePollingEnvLocked()}
onInput={(e) => {
if (pvePollingEnvLocked()) return;
const parsed = Math.floor(Number(e.currentTarget.value));
if (Number.isNaN(parsed)) {
return;
}
const clamped = Math.min(
PVE_POLLING_MAX_SECONDS,
Math.max(PVE_POLLING_MIN_SECONDS, parsed),
);
setPVEPollingCustomSeconds(clamped);
setPVEPollingInterval(clamped);
setHasUnsavedChanges(true);
}}
/>
<p class="text-[0.68rem] text-gray-500 dark:text-gray-400">
Applies to all PVE clusters and standalone nodes.
</p>
</div>
</Show>
<Show when={pvePollingEnvLocked()}>
<div class="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
<svg
class="h-4 w-4 flex-shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<circle cx="12" cy="16" r="0.5" />
</svg>
<span>Managed via environment variable PVE_POLLING_INTERVAL.</span>
</div>
</Show>
</div>
</div>
</Card>
</div>
</Show>

View file

@ -27,7 +27,7 @@ export interface AuthConfig {
* These are application behavior settings
*/
export interface SystemConfig {
// Note: PVE polling is hardcoded to 10s (Proxmox cluster/resources updates every 10s)
pvePollingInterval?: number; // PVE polling interval in seconds
pbsPollingInterval?: number; // PBS polling interval in seconds
connectionTimeout?: number; // Seconds before timeout (default: 10)
autoUpdateEnabled: boolean; // Enable auto-updates

View file

@ -2876,6 +2876,7 @@ func (h *ConfigHandlers) HandleGetSystemSettings(w http.ResponseWriter, r *http.
// Get current values from running config
settings := *persistedSettings
settings.PVEPollingInterval = int(h.config.PVEPollingInterval.Seconds())
settings.PBSPollingInterval = int(h.config.PBSPollingInterval.Seconds())
settings.BackupPollingInterval = int(h.config.BackupPollingInterval.Seconds())
settings.BackendPort = h.config.BackendPort

View file

@ -977,7 +977,7 @@ func (r *Router) setupRoutes() {
}))
// System settings and API token management
r.systemSettingsHandler = NewSystemSettingsHandler(r.config, r.persistence, r.wsHub, r.monitor, r.reloadSystemSettings)
r.systemSettingsHandler = NewSystemSettingsHandler(r.config, r.persistence, r.wsHub, r.monitor, r.reloadSystemSettings, r.reloadFunc)
r.mux.HandleFunc("/api/system/settings", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.systemSettingsHandler.HandleGetSystemSettings)))
r.mux.HandleFunc("/api/system/settings/update", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.systemSettingsHandler.HandleUpdateSystemSettings)))
r.mux.HandleFunc("/api/system/ssh-config", r.handleSSHConfig)

View file

@ -28,6 +28,7 @@ type SystemSettingsHandler struct {
persistence *config.ConfigPersistence
wsHub *websocket.Hub
reloadSystemSettingsFunc func() // Function to reload cached system settings
reloadMonitorFunc func() error
monitor interface {
GetDiscoveryService() *discovery.Service
StartDiscoveryService(ctx context.Context, wsHub *websocket.Hub, subnet string)
@ -48,13 +49,14 @@ func NewSystemSettingsHandler(cfg *config.Config, persistence *config.ConfigPers
DisableTemperatureMonitoring()
GetNotificationManager() *notifications.NotificationManager
HasSocketTemperatureProxy() bool
}, reloadSystemSettingsFunc func()) *SystemSettingsHandler {
}, reloadSystemSettingsFunc func(), reloadMonitorFunc func() error) *SystemSettingsHandler {
return &SystemSettingsHandler{
config: cfg,
persistence: persistence,
wsHub: wsHub,
monitor: monitor,
reloadSystemSettingsFunc: reloadSystemSettingsFunc,
reloadMonitorFunc: reloadMonitorFunc,
}
}
@ -118,8 +120,21 @@ func discoveryConfigMap(raw map[string]interface{}) (map[string]interface{}, boo
// 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["pvePollingInterval"]; ok {
if interval, ok := val.(float64); ok {
if interval <= 0 {
return fmt.Errorf("PVE polling interval must be positive (minimum 10 seconds)")
}
if interval < 10 {
return fmt.Errorf("PVE polling interval must be at least 10 seconds")
}
if interval > 3600 {
return fmt.Errorf("PVE polling interval cannot exceed 3600 seconds (1 hour)")
}
} else {
return fmt.Errorf("PVE polling interval must be a number")
}
}
if val, ok := rawRequest["pbsPollingInterval"]; ok {
if interval, ok := val.(float64); ok {
@ -382,6 +397,7 @@ func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r
Msg("Loaded system settings for API response")
// Always expose effective backup polling configuration
settings.PVEPollingInterval = int(h.config.PVEPollingInterval.Seconds())
settings.BackupPollingInterval = int(h.config.BackupPollingInterval.Seconds())
enabled := h.config.EnableBackupPolling
settings.BackupPollingEnabled = &enabled
@ -485,9 +501,12 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
discoveryConfigUpdated := false
prevTempEnabled := h.config.TemperatureMonitoringEnabled
tempToggleRequested := false
pveIntervalChanged := false
// Only update fields that were provided in the request
// Note: PVE polling is hardcoded to 10s, legacy polling fields are ignored
if _, ok := rawRequest["pvePollingInterval"]; ok {
settings.PVEPollingInterval = updates.PVEPollingInterval
}
if _, ok := rawRequest["pbsPollingInterval"]; ok {
settings.PBSPollingInterval = updates.PBSPollingInterval
}
@ -608,7 +627,16 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
}
// Update the config
// Note: PVE polling is hardcoded to 10s
if _, ok := rawRequest["pvePollingInterval"]; ok && settings.PVEPollingInterval > 0 {
newInterval := time.Duration(settings.PVEPollingInterval) * time.Second
if newInterval < 10*time.Second {
newInterval = 10 * time.Second
}
if h.config.PVEPollingInterval != newInterval {
pveIntervalChanged = true
}
h.config.PVEPollingInterval = newInterval
}
if settings.AllowedOrigins != "" {
h.config.AllowedOrigins = settings.AllowedOrigins
}
@ -718,6 +746,14 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
h.reloadSystemSettingsFunc()
}
if pveIntervalChanged && h.reloadMonitorFunc != nil {
if err := h.reloadMonitorFunc(); err != nil {
log.Error().Err(err).Msg("Failed to reload monitor after PVE polling interval change")
http.Error(w, "Configuration saved but failed to reload monitor", http.StatusInternalServerError)
return
}
}
log.Info().Msg("System settings updated")
// Broadcast theme change to all connected clients if theme was updated

View file

@ -4,7 +4,9 @@ import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
@ -14,7 +16,7 @@ func TestHandleUpdateSystemSettingsRejectsTempsWithoutTransport(t *testing.T) {
t.Setenv("PULSE_DOCKER", "true")
cfg := &config.Config{DataPath: tempDir, ConfigPath: tempDir, PVEInstances: []config.PVEInstance{{Name: "pve-a"}}}
persistence := config.NewConfigPersistence(tempDir)
handler := NewSystemSettingsHandler(cfg, persistence, nil, nil, nil)
handler := NewSystemSettingsHandler(cfg, persistence, nil, nil, nil, nil)
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", bytes.NewBufferString(`{"temperatureMonitoringEnabled":true}`))
rec := httptest.NewRecorder()
@ -25,3 +27,53 @@ func TestHandleUpdateSystemSettingsRejectsTempsWithoutTransport(t *testing.T) {
t.Fatalf("expected status 400, got %d", rec.Code)
}
}
func TestHandleUpdateSystemSettingsRejectsInvalidPVEPollingInterval(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
PVEPollingInterval: 10 * time.Second,
}
persistence := config.NewConfigPersistence(tempDir)
handler := NewSystemSettingsHandler(cfg, persistence, nil, nil, nil, nil)
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", strings.NewReader(`{"pvePollingInterval":5}`))
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
}
func TestHandleUpdateSystemSettingsUpdatesPVEPollingInterval(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
PVEPollingInterval: 10 * time.Second,
}
persistence := config.NewConfigPersistence(tempDir)
reloaded := false
handler := NewSystemSettingsHandler(cfg, persistence, nil, nil, nil, func() error {
reloaded = true
return nil
})
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", strings.NewReader(`{"pvePollingInterval":30}`))
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
if cfg.PVEPollingInterval != 30*time.Second {
t.Fatalf("expected interval to be 30s, got %v", cfg.PVEPollingInterval)
}
if !reloaded {
t.Fatal("expected reload to be triggered")
}
}

View file

@ -83,7 +83,7 @@ type Config struct {
PMGInstances []PMGInstance
// Monitoring settings
// Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s
PVEPollingInterval time.Duration `envconfig:"PVE_POLLING_INTERVAL"` // PVE polling interval (10s default)
PBSPollingInterval time.Duration `envconfig:"PBS_POLLING_INTERVAL"` // PBS polling interval (60s default)
PMGPollingInterval time.Duration `envconfig:"PMG_POLLING_INTERVAL"` // PMG polling interval (60s default)
ConcurrentPolling bool `envconfig:"CONCURRENT_POLLING" default:"true"`
@ -529,6 +529,7 @@ func Load() (*Config, error) {
BackupPollingCycles: 10,
BackupPollingInterval: 0,
EnableBackupPolling: true,
PVEPollingInterval: 10 * time.Second,
WebhookBatchDelay: 10 * time.Second,
AdaptivePollingEnabled: false,
AdaptivePollingBaseInterval: 10 * time.Second,
@ -578,7 +579,10 @@ func Load() (*Config, error) {
// Load system configuration
if systemSettings, err := persistence.LoadSystemSettings(); err == nil && systemSettings != nil {
// Load PBS polling interval if configured
// Load polling intervals if configured
if systemSettings.PVEPollingInterval > 0 {
cfg.PVEPollingInterval = time.Duration(systemSettings.PVEPollingInterval) * time.Second
}
if systemSettings.PBSPollingInterval > 0 {
cfg.PBSPollingInterval = time.Duration(systemSettings.PBSPollingInterval) * time.Second
}
@ -675,8 +679,13 @@ func Load() (*Config, error) {
log.Warn().Err(err).Msg("Failed to load API tokens from persistence")
}
// Ensure PBS polling interval has default if not set
// Note: PVE polling is hardcoded to 10s in monitor.go
// Ensure polling intervals have sane defaults if not set
if cfg.PVEPollingInterval <= 0 {
cfg.PVEPollingInterval = 10 * time.Second
}
if cfg.PVEPollingInterval > time.Hour {
cfg.PVEPollingInterval = time.Hour
}
if cfg.PBSPollingInterval == 0 {
cfg.PBSPollingInterval = 60 * time.Second
}
@ -723,6 +732,28 @@ func Load() (*Config, error) {
}
}
if intervalStr := utils.GetenvTrim("PVE_POLLING_INTERVAL"); intervalStr != "" {
if dur, err := time.ParseDuration(intervalStr); err == nil {
if dur < 10*time.Second {
log.Warn().Dur("interval", dur).Msg("Ignoring PVE_POLLING_INTERVAL below 10s from environment")
} else {
cfg.PVEPollingInterval = dur
cfg.EnvOverrides["PVE_POLLING_INTERVAL"] = true
log.Info().Dur("interval", dur).Msg("Overriding PVE polling interval from environment")
}
} else if seconds, err := strconv.Atoi(intervalStr); err == nil {
if seconds < 10 {
log.Warn().Int("seconds", seconds).Msg("Ignoring PVE_POLLING_INTERVAL below 10s from environment")
} else {
cfg.PVEPollingInterval = time.Duration(seconds) * time.Second
cfg.EnvOverrides["PVE_POLLING_INTERVAL"] = true
log.Info().Int("seconds", seconds).Msg("Overriding PVE polling interval (seconds) from environment")
}
} else {
log.Warn().Str("value", intervalStr).Msg("Invalid PVE_POLLING_INTERVAL value, expected duration or seconds")
}
}
if enabledStr := utils.GetenvTrim("ENABLE_TEMPERATURE_MONITORING"); enabledStr != "" {
if enabled, err := strconv.ParseBool(enabledStr); err == nil {
cfg.TemperatureMonitoringEnabled = enabled
@ -1295,7 +1326,7 @@ func SaveConfig(cfg *Config) error {
// Save system configuration
adaptiveEnabled := cfg.AdaptivePollingEnabled
systemSettings := SystemSettings{
// Note: PVE polling is hardcoded to 10s
PVEPollingInterval: int(cfg.PVEPollingInterval / time.Second),
UpdateChannel: cfg.UpdateChannel,
AutoUpdateEnabled: cfg.AutoUpdateEnabled,
AutoUpdateCheckInterval: int(cfg.AutoUpdateCheckInterval.Hours()),
@ -1349,7 +1380,12 @@ func (c *Config) Validate() error {
}
// Validate monitoring settings
// Note: PVE polling is hardcoded to 10s
if c.PVEPollingInterval < 10*time.Second {
return fmt.Errorf("PVE polling interval must be at least 10 seconds")
}
if c.PVEPollingInterval > time.Hour {
return fmt.Errorf("PVE polling interval cannot exceed 1 hour")
}
if c.ConnectionTimeout < time.Second {
return fmt.Errorf("connection timeout must be at least 1 second")
}

View file

@ -803,33 +803,33 @@ type NodesConfig struct {
// SystemSettings represents system configuration settings
type SystemSettings struct {
// Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s
PBSPollingInterval int `json:"pbsPollingInterval"` // PBS polling interval in seconds
PMGPollingInterval int `json:"pmgPollingInterval"` // PMG polling interval in seconds
BackupPollingInterval int `json:"backupPollingInterval,omitempty"`
BackupPollingEnabled *bool `json:"backupPollingEnabled,omitempty"`
AdaptivePollingEnabled *bool `json:"adaptivePollingEnabled,omitempty"`
AdaptivePollingBaseInterval int `json:"adaptivePollingBaseInterval,omitempty"`
AdaptivePollingMinInterval int `json:"adaptivePollingMinInterval,omitempty"`
AdaptivePollingMaxInterval int `json:"adaptivePollingMaxInterval,omitempty"`
BackendPort int `json:"backendPort,omitempty"`
FrontendPort int `json:"frontendPort,omitempty"`
AllowedOrigins string `json:"allowedOrigins,omitempty"`
ConnectionTimeout int `json:"connectionTimeout,omitempty"`
UpdateChannel string `json:"updateChannel,omitempty"`
AutoUpdateEnabled bool `json:"autoUpdateEnabled"` // Removed omitempty so false is saved
AutoUpdateCheckInterval int `json:"autoUpdateCheckInterval,omitempty"`
AutoUpdateTime string `json:"autoUpdateTime,omitempty"`
LogLevel string `json:"logLevel,omitempty"`
DiscoveryEnabled bool `json:"discoveryEnabled"`
DiscoverySubnet string `json:"discoverySubnet,omitempty"`
DiscoveryConfig DiscoveryConfig `json:"discoveryConfig"`
Theme string `json:"theme,omitempty"` // User theme preference: "light", "dark", or empty for system default
PVEPollingInterval int `json:"pvePollingInterval"` // PVE polling interval in seconds
PBSPollingInterval int `json:"pbsPollingInterval"` // PBS polling interval in seconds
PMGPollingInterval int `json:"pmgPollingInterval"` // PMG polling interval in seconds
BackupPollingInterval int `json:"backupPollingInterval,omitempty"`
BackupPollingEnabled *bool `json:"backupPollingEnabled,omitempty"`
AdaptivePollingEnabled *bool `json:"adaptivePollingEnabled,omitempty"`
AdaptivePollingBaseInterval int `json:"adaptivePollingBaseInterval,omitempty"`
AdaptivePollingMinInterval int `json:"adaptivePollingMinInterval,omitempty"`
AdaptivePollingMaxInterval int `json:"adaptivePollingMaxInterval,omitempty"`
BackendPort int `json:"backendPort,omitempty"`
FrontendPort int `json:"frontendPort,omitempty"`
AllowedOrigins string `json:"allowedOrigins,omitempty"`
ConnectionTimeout int `json:"connectionTimeout,omitempty"`
UpdateChannel string `json:"updateChannel,omitempty"`
AutoUpdateEnabled bool `json:"autoUpdateEnabled"` // Removed omitempty so false is saved
AutoUpdateCheckInterval int `json:"autoUpdateCheckInterval,omitempty"`
AutoUpdateTime string `json:"autoUpdateTime,omitempty"`
LogLevel string `json:"logLevel,omitempty"`
DiscoveryEnabled bool `json:"discoveryEnabled"`
DiscoverySubnet string `json:"discoverySubnet,omitempty"`
DiscoveryConfig DiscoveryConfig `json:"discoveryConfig"`
Theme string `json:"theme,omitempty"` // User theme preference: "light", "dark", or empty for system default
AllowEmbedding bool `json:"allowEmbedding"` // Allow iframe embedding
AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
DNSCacheTimeout int `json:"dnsCacheTimeout,omitempty"` // DNS cache timeout in seconds (0 = default 5 minutes)
SSHPort int `json:"sshPort,omitempty"` // Default SSH port for temperature monitoring (0 = use 22)
DNSCacheTimeout int `json:"dnsCacheTimeout,omitempty"` // DNS cache timeout in seconds (0 = default 5 minutes)
SSHPort int `json:"sshPort,omitempty"` // Default SSH port for temperature monitoring (0 = use 22)
WebhookAllowedPrivateCIDRs string `json:"webhookAllowedPrivateCIDRs,omitempty"` // Comma-separated list of private CIDR ranges allowed for webhooks (e.g., "192.168.1.0/24,10.0.0.0/8")
// APIToken removed - now handled via .env file only
}
@ -838,6 +838,7 @@ type SystemSettings struct {
func DefaultSystemSettings() *SystemSettings {
defaultDiscovery := DefaultDiscoveryConfig()
return &SystemSettings{
PVEPollingInterval: 10,
PBSPollingInterval: 60,
PMGPollingInterval: 60,
AutoUpdateEnabled: false,
@ -1361,9 +1362,8 @@ func (c *ConfigPersistence) updateEnvFile(envFile string, settings SystemSetting
for scanner.Scan() {
line := scanner.Text()
// Skip POLLING_INTERVAL lines - deprecated
// Skip legacy POLLING_INTERVAL lines - replaced by system.json PVE_POLLING_INTERVAL
if strings.HasPrefix(line, "POLLING_INTERVAL=") {
// Skip this line, polling interval is now hardcoded
continue
} else if strings.HasPrefix(line, "UPDATE_CHANNEL=") && settings.UpdateChannel != "" {
lines = append(lines, fmt.Sprintf("UPDATE_CHANNEL=%s", settings.UpdateChannel))
@ -1381,7 +1381,7 @@ func (c *ConfigPersistence) updateEnvFile(envFile string, settings SystemSetting
return err
}
// Note: POLLING_INTERVAL is deprecated and no longer written
// Note: legacy POLLING_INTERVAL is deprecated and no longer written
// Build the new content
content := strings.Join(lines, "\n")

View file

@ -4019,10 +4019,28 @@ func (m *Monitor) getExecutor() PollExecutor {
return exec
}
func (m *Monitor) effectivePVEPollingInterval() time.Duration {
const minInterval = 10 * time.Second
const maxInterval = time.Hour
interval := minInterval
if m != nil && m.config != nil && m.config.PVEPollingInterval > 0 {
interval = m.config.PVEPollingInterval
}
if interval < minInterval {
interval = minInterval
}
if interval > maxInterval {
interval = maxInterval
}
return interval
}
// Start begins the monitoring loop
func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) {
pollingInterval := m.effectivePVEPollingInterval()
log.Info().
Dur("pollingInterval", 10*time.Second).
Dur("pollingInterval", pollingInterval).
Msg("Starting monitoring loop")
m.mu.Lock()
@ -4113,9 +4131,7 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) {
wsHub.BroadcastAlert(alert)
})
// Create separate tickers for polling and broadcasting
// Hardcoded to 10 seconds since Proxmox updates cluster/resources every 10 seconds
const pollingInterval = 10 * time.Second
// Create separate tickers for polling and broadcasting using the configured cadence
workerCount := len(m.pveClients) + len(m.pbsClients) + len(m.pmgClients)
m.startTaskWorkers(ctx, workerCount)

View file

@ -90,9 +90,7 @@ func (rm *ReloadableMonitor) doReload() error {
return err
}
// Note: Polling interval is now hardcoded to 10s, no special handling needed
// For other changes, do a full reload
// Polling interval changes and other settings require a full reload
log.Info().Msg("Performing full monitor reload")
// Cancel current monitor
@ -123,8 +121,6 @@ func (rm *ReloadableMonitor) doReload() error {
return nil
}
// Note: Polling interval change detection removed - now hardcoded to 10s
// GetMonitor returns the current monitor instance
func (rm *ReloadableMonitor) GetMonitor() *Monitor {
rm.mu.RLock()
@ -160,5 +156,3 @@ func (rm *ReloadableMonitor) Stop() {
rm.monitor.Stop()
}
}
// Note: UpdatePollingInterval removed - polling interval is now hardcoded to 10s