Add configurable backup polling interval

This commit is contained in:
Pulse Automation Bot 2025-10-18 13:06:41 +00:00
parent d15ad1d0b4
commit 0b4e4f9c59
12 changed files with 547 additions and 116 deletions

View file

@ -314,6 +314,8 @@ These env vars override system.json values. When set, the UI will show a warning
- `CONNECTION_TIMEOUT` - API timeout in seconds (default: 10)
- `ALLOWED_ORIGINS` - CORS origins (default: same-origin only)
- `LOG_LEVEL` - Log verbosity: debug/info/warn/error (default: info)
- `ENABLE_BACKUP_POLLING` - Set to `false` to disable polling of Proxmox backup/snapshot APIs (default: true)
- `BACKUP_POLLING_INTERVAL` - Override the backup polling cadence. Accepts Go duration syntax (e.g. `30m`, `6h`) or seconds. Use `0` for Pulse's default (~90s) cadence.
- `PULSE_PUBLIC_URL` - Full URL to access Pulse (e.g., `http://192.168.1.100:7655`)
- **Auto-detected** if not set (except inside Docker where detection is disabled)
- Used in webhook notifications for "View in Pulse" links

View file

@ -7,6 +7,8 @@ export interface SystemSettings {
autoUpdateEnabled: boolean;
autoUpdateCheckInterval?: number;
autoUpdateTime?: string;
backupPollingInterval?: number;
backupPollingEnabled?: boolean;
// apiToken removed - now handled via security API
}

View file

@ -257,6 +257,18 @@ const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: st
},
};
const BACKUP_INTERVAL_OPTIONS = [
{ label: 'Default (~90 seconds)', value: 0 },
{ label: '15 minutes', value: 15 * 60 },
{ label: '30 minutes', value: 30 * 60 },
{ label: '1 hour', value: 60 * 60 },
{ label: '6 hours', value: 6 * 60 * 60 },
{ label: '12 hours', value: 12 * 60 * 60 },
{ label: '24 hours', value: 24 * 60 * 60 },
];
const BACKUP_INTERVAL_MAX_MINUTES = 7 * 24 * 60; // 7 days
// Node with UI-specific fields
type NodeConfigWithStatus = NodeConfig & {
hasPassword?: boolean;
@ -363,6 +375,36 @@ const Settings: Component<SettingsProps> = (props) => {
const [autoUpdateEnabled, setAutoUpdateEnabled] = createSignal(false);
const [autoUpdateCheckInterval, setAutoUpdateCheckInterval] = createSignal(24);
const [autoUpdateTime, setAutoUpdateTime] = createSignal('03:00');
const [backupPollingEnabled, setBackupPollingEnabled] = createSignal(true);
const [backupPollingInterval, setBackupPollingInterval] = createSignal(0);
const [backupPollingCustomMinutes, setBackupPollingCustomMinutes] = createSignal(60);
const backupPollingEnvLocked = () =>
Boolean(envOverrides()['ENABLE_BACKUP_POLLING'] || envOverrides()['BACKUP_POLLING_INTERVAL']);
const backupIntervalSelectValue = () => {
const seconds = backupPollingInterval();
return BACKUP_INTERVAL_OPTIONS.some((option) => option.value === seconds)
? String(seconds)
: 'custom';
};
const backupIntervalSummary = () => {
if (!backupPollingEnabled()) {
return 'Backup polling is disabled.';
}
const seconds = backupPollingInterval();
if (seconds <= 0) {
return 'Pulse checks backups and snapshots at the default cadence (~every 90 seconds).';
}
if (seconds % 86400 === 0) {
const days = seconds / 86400;
return `Pulse checks backups every ${days === 1 ? 'day' : `${days} days`}.`;
}
if (seconds % 3600 === 0) {
const hours = seconds / 3600;
return `Pulse checks backups every ${hours === 1 ? 'hour' : `${hours} hours`}.`;
}
const minutes = Math.max(1, Math.round(seconds / 60));
return `Pulse checks backups every ${minutes === 1 ? 'minute' : `${minutes} minutes`}.`;
};
// Diagnostics
const [diagnosticsData, setDiagnosticsData] = createSignal<DiagnosticsData | null>(null);
@ -988,6 +1030,20 @@ const Settings: Component<SettingsProps> = (props) => {
// Load embedding settings
setAllowEmbedding(systemSettings.allowEmbedding ?? false);
setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || '');
// Backup polling controls
if (typeof systemSettings.backupPollingEnabled === 'boolean') {
setBackupPollingEnabled(systemSettings.backupPollingEnabled);
} else {
setBackupPollingEnabled(true);
}
const intervalSeconds =
typeof systemSettings.backupPollingInterval === 'number'
? Math.max(0, Math.floor(systemSettings.backupPollingInterval))
: 0;
setBackupPollingInterval(intervalSeconds);
if (intervalSeconds > 0) {
setBackupPollingCustomMinutes(Math.max(1, Math.round(intervalSeconds / 60)));
}
// Load auto-update settings
setAutoUpdateEnabled(systemSettings.autoUpdateEnabled || false);
setAutoUpdateCheckInterval(systemSettings.autoUpdateCheckInterval || 24);
@ -1077,6 +1133,8 @@ const Settings: Component<SettingsProps> = (props) => {
autoUpdateEnabled: autoUpdateEnabled(),
autoUpdateCheckInterval: autoUpdateCheckInterval(),
autoUpdateTime: autoUpdateTime(),
backupPollingEnabled: backupPollingEnabled(),
backupPollingInterval: backupPollingInterval(),
allowEmbedding: allowEmbedding(),
allowedEmbedOrigins: allowedEmbedOrigins(),
});
@ -3418,12 +3476,160 @@ const Settings: Component<SettingsProps> = (props) => {
border={false}
class="border border-gray-200 dark:border-gray-700"
>
<SectionHeader
title="Backup & restore"
description="Backup your node configurations and credentials or restore from a previous backup."
size="md"
class="mb-4"
/>
<section class="space-y-3">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="9" stroke-width="2" />
<path d="M12 7v5l3 3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Backup polling
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Control how often Pulse queries Proxmox backup tasks, datastore contents, and guest snapshots.
Longer intervals reduce disk activity and API load.
</p>
<div class="space-y-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
Enable backup polling
</p>
<p class="text-xs text-gray-600 dark:text-gray-400">
Required for dashboard backup status, storage snapshots, and alerting.
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
class="sr-only peer"
checked={backupPollingEnabled()}
disabled={backupPollingEnvLocked()}
onChange={(e) => {
setBackupPollingEnabled(e.currentTarget.checked);
if (!backupPollingEnvLocked()) {
setHasUnsavedChanges(true);
}
}}
/>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-50"></div>
</label>
</div>
<Show when={backupPollingEnabled()}>
<div class="space-y-3 rounded-md border border-gray-200 dark:border-gray-600 p-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Polling interval
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
{backupIntervalSummary()}
</p>
</div>
<select
value={backupIntervalSelectValue()}
disabled={backupPollingEnvLocked()}
onChange={(e) => {
const value = e.currentTarget.value;
if (value === 'custom') {
const minutes = Math.max(1, backupPollingCustomMinutes());
setBackupPollingInterval(minutes * 60);
} else {
const seconds = parseInt(value, 10);
if (!Number.isNaN(seconds)) {
setBackupPollingInterval(seconds);
if (seconds > 0) {
setBackupPollingCustomMinutes(Math.max(1, Math.round(seconds / 60)));
}
}
}
if (!backupPollingEnvLocked()) {
setHasUnsavedChanges(true);
}
}}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 disabled:opacity-50"
>
<For each={BACKUP_INTERVAL_OPTIONS}>
{(option) => (
<option value={String(option.value)}>{option.label}</option>
)}
</For>
<option value="custom">Custom interval</option>
</select>
</div>
<Show when={backupIntervalSelectValue() === 'custom'}>
<div class="space-y-2">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
Custom interval (minutes)
</label>
<div class="flex items-center gap-3">
<input
type="number"
min="1"
max={BACKUP_INTERVAL_MAX_MINUTES}
value={backupPollingCustomMinutes()}
disabled={backupPollingEnvLocked()}
onInput={(e) => {
const value = Number(e.currentTarget.value);
if (Number.isNaN(value)) {
return;
}
const clamped = Math.max(
1,
Math.min(BACKUP_INTERVAL_MAX_MINUTES, Math.floor(value)),
);
setBackupPollingCustomMinutes(clamped);
setBackupPollingInterval(clamped * 60);
if (!backupPollingEnvLocked()) {
setHasUnsavedChanges(true);
}
}}
class="w-24 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 disabled:opacity-50"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">
1 {BACKUP_INTERVAL_MAX_MINUTES} minutes (7 days max)
</span>
</div>
</div>
</Show>
</div>
</Show>
<Show when={backupPollingEnvLocked()}>
<div class="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20 p-3 text-xs text-amber-700 dark:text-amber-200">
<svg
class="w-4 h-4 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div>
<p class="font-medium">Environment override detected</p>
<p class="mt-1">
The <code class="font-mono">ENABLE_BACKUP_POLLING</code> or{' '}
<code class="font-mono">BACKUP_POLLING_INTERVAL</code> environment
variables are set. Remove them and restart Pulse to manage backup polling here.
</p>
</div>
</div>
</Show>
</div>
</section>
<SectionHeader
title="Backup & restore"
description="Backup your node configurations and credentials or restore from a previous backup."
size="md"
class="mb-4"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Export Section */}

View file

@ -621,11 +621,17 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
: 'text-green-600 dark:text-green-400';
const temp = node!.temperature;
const hasMinMax = temp && temp.cpuMin && temp.cpuMin > 0 && temp.cpuMaxRecord && temp.cpuMaxRecord > 0;
const cpuMin = temp?.cpuMin;
const cpuMax = temp?.cpuMaxRecord;
const hasMinMax =
typeof cpuMin === 'number' &&
cpuMin > 0 &&
typeof cpuMax === 'number' &&
cpuMax > 0;
if (hasMinMax) {
const min = Math.round(temp.cpuMin);
const max = Math.round(temp.cpuMaxRecord);
const min = Math.round(cpuMin);
const max = Math.round(cpuMax);
const getTooltipColor = (temp: number) => {
if (temp >= 80) return 'text-red-400';

View file

@ -34,6 +34,8 @@ export interface SystemConfig {
updateChannel?: string; // Update channel: 'stable' | 'rc' | 'beta'
autoUpdateCheckInterval?: number; // Hours between update checks
autoUpdateTime?: string; // Time for updates (HH:MM format)
backupPollingInterval?: number; // Backup polling interval in seconds (0 = default cadence)
backupPollingEnabled?: boolean; // Enable backup polling of PVE/PBS data
allowedOrigins?: string; // CORS allowed origins
backendPort?: number; // Backend API port (default: 7655)
frontendPort?: number; // Frontend UI port (default: 7655)
@ -147,6 +149,8 @@ export const DEFAULT_CONFIG: {
updateChannel: 'stable',
autoUpdateCheckInterval: 24,
autoUpdateTime: '03:00',
backupPollingEnabled: true,
backupPollingInterval: 0,
allowedOrigins: '',
backendPort: 7655,
frontendPort: 7655,

View file

@ -15,6 +15,8 @@ export interface MonitoringSettings {
// Note: PVE polling is hardcoded to 10s server-side
concurrentPolling: boolean;
backupPollingCycles: number;
backupPollingIntervalMs: number;
backupPollingEnabled: boolean;
metricsRetentionDays: number;
}

View file

@ -2523,6 +2523,7 @@ func (h *ConfigHandlers) HandleGetSystemSettings(w http.ResponseWriter, r *http.
settings := config.SystemSettings{
// Note: PVE polling is hardcoded to 10s
PBSPollingInterval: int(h.config.PBSPollingInterval.Seconds()),
BackupPollingInterval: int(h.config.BackupPollingInterval.Seconds()),
BackendPort: h.config.BackendPort,
FrontendPort: h.config.FrontendPort,
AllowedOrigins: h.config.AllowedOrigins,
@ -2538,6 +2539,8 @@ func (h *ConfigHandlers) HandleGetSystemSettings(w http.ResponseWriter, r *http.
DiscoveryEnabled: persistedSettings.DiscoveryEnabled, // Include discoveryEnabled from persisted settings
DiscoverySubnet: persistedSettings.DiscoverySubnet, // Include discoverySubnet from persisted settings
}
backupEnabled := h.config.EnableBackupPolling
settings.BackupPollingEnabled = &backupEnabled
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(settings)
@ -2670,6 +2673,10 @@ func (h *ConfigHandlers) HandleUpdateSystemSettingsOLD(w http.ResponseWriter, r
http.Error(w, "PBS polling interval must be positive", http.StatusBadRequest)
return
}
if settings.BackupPollingInterval < 0 {
http.Error(w, "Backup polling interval cannot be negative", http.StatusBadRequest)
return
}
// Update polling intervals
needsReload := false
@ -2679,6 +2686,14 @@ func (h *ConfigHandlers) HandleUpdateSystemSettingsOLD(w http.ResponseWriter, r
h.config.PBSPollingInterval = time.Duration(settings.PBSPollingInterval) * time.Second
needsReload = true
}
if settings.BackupPollingInterval > 0 || (settings.BackupPollingInterval == 0 && h.config.BackupPollingInterval != 0) {
h.config.BackupPollingInterval = time.Duration(settings.BackupPollingInterval) * time.Second
needsReload = true
}
if settings.BackupPollingEnabled != nil {
h.config.EnableBackupPolling = *settings.BackupPollingEnabled
needsReload = true
}
// Trigger a monitor reload if intervals changed
if needsReload && h.reloadFunc != nil {

View file

@ -88,6 +88,22 @@ func validateSystemSettings(settings *config.SystemSettings, rawRequest map[stri
}
}
if val, ok := rawRequest["backupPollingInterval"]; ok {
if interval, ok := val.(float64); ok {
if interval < 0 {
return fmt.Errorf("Backup polling interval cannot be negative")
}
if interval > 0 && interval < 10 {
return fmt.Errorf("Backup polling interval must be at least 10 seconds")
}
if interval > 604800 {
return fmt.Errorf("Backup polling interval cannot exceed 604800 seconds (7 days)")
}
} else {
return fmt.Errorf("Backup polling interval must be a number")
}
}
// Validate boolean fields have correct type
if val, ok := rawRequest["autoUpdateEnabled"]; ok {
if _, ok := val.(bool); !ok {
@ -107,6 +123,12 @@ func validateSystemSettings(settings *config.SystemSettings, rawRequest map[stri
}
}
if val, ok := rawRequest["backupPollingEnabled"]; ok {
if _, ok := val.(bool); !ok {
return fmt.Errorf("backupPollingEnabled 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 {
@ -184,6 +206,11 @@ func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r
log.Debug().
Str("theme", settings.Theme).
Msg("Loaded system settings for API response")
// Always expose effective backup polling configuration
settings.BackupPollingInterval = int(h.config.BackupPollingInterval.Seconds())
enabled := h.config.EnableBackupPolling
settings.BackupPollingEnabled = &enabled
}
// Include env override information
@ -279,6 +306,9 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
if _, ok := rawRequest["pmgPollingInterval"]; ok {
settings.PMGPollingInterval = updates.PMGPollingInterval
}
if _, ok := rawRequest["backupPollingInterval"]; ok {
settings.BackupPollingInterval = updates.BackupPollingInterval
}
if updates.AllowedOrigins != "" {
settings.AllowedOrigins = updates.AllowedOrigins
}
@ -315,6 +345,9 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
if _, ok := rawRequest["allowEmbedding"]; ok {
settings.AllowEmbedding = updates.AllowEmbedding
}
if _, ok := rawRequest["backupPollingEnabled"]; ok {
settings.BackupPollingEnabled = updates.BackupPollingEnabled
}
// Update the config
// Note: PVE polling is hardcoded to 10s
@ -327,6 +360,16 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
if settings.PMGPollingInterval > 0 {
h.config.PMGPollingInterval = time.Duration(settings.PMGPollingInterval) * time.Second
}
if _, ok := rawRequest["backupPollingInterval"]; ok {
if settings.BackupPollingInterval <= 0 {
h.config.BackupPollingInterval = 0
} else {
h.config.BackupPollingInterval = time.Duration(settings.BackupPollingInterval) * time.Second
}
}
if settings.BackupPollingEnabled != nil {
h.config.EnableBackupPolling = *settings.BackupPollingEnabled
}
if settings.UpdateChannel != "" {
h.config.UpdateChannel = settings.UpdateChannel
}

View file

@ -74,13 +74,15 @@ type Config struct {
// Monitoring settings
// Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s
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"`
ConnectionTimeout time.Duration `envconfig:"CONNECTION_TIMEOUT" default:"45s"` // Increased for slow storage operations
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"`
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"`
ConnectionTimeout time.Duration `envconfig:"CONNECTION_TIMEOUT" default:"45s"` // Increased for slow storage operations
MetricsRetentionDays int `envconfig:"METRICS_RETENTION_DAYS" default:"7"`
BackupPollingCycles int `envconfig:"BACKUP_POLLING_CYCLES" default:"10"`
BackupPollingInterval time.Duration `envconfig:"BACKUP_POLLING_INTERVAL"`
EnableBackupPolling bool `envconfig:"ENABLE_BACKUP_POLLING" default:"true"`
WebhookBatchDelay time.Duration `envconfig:"WEBHOOK_BATCH_DELAY" default:"10s"`
// Logging settings
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
@ -247,29 +249,31 @@ func Load() (*Config, error) {
// 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: 60 * time.Second,
MetricsRetentionDays: 7,
BackupPollingCycles: 10,
WebhookBatchDelay: 10 * time.Second,
LogLevel: "info",
LogMaxSize: 100,
LogMaxAge: 30,
LogCompress: true,
AllowedOrigins: "", // Empty means no CORS headers (same-origin only)
IframeEmbeddingAllow: "SAMEORIGIN",
PBSPollingInterval: 60 * time.Second, // Default PBS polling (slower)
PMGPollingInterval: 60 * time.Second, // Default PMG polling (aggregated stats)
DiscoveryEnabled: true,
DiscoverySubnet: "auto",
EnvOverrides: make(map[string]bool),
OIDC: NewOIDCConfig(),
BackendHost: "0.0.0.0",
BackendPort: 3000,
FrontendHost: "0.0.0.0",
FrontendPort: 7655,
ConfigPath: dataDir,
DataPath: dataDir,
ConcurrentPolling: true,
ConnectionTimeout: 60 * time.Second,
MetricsRetentionDays: 7,
BackupPollingCycles: 10,
BackupPollingInterval: 0,
EnableBackupPolling: true,
WebhookBatchDelay: 10 * time.Second,
LogLevel: "info",
LogMaxSize: 100,
LogMaxAge: 30,
LogCompress: true,
AllowedOrigins: "", // Empty means no CORS headers (same-origin only)
IframeEmbeddingAllow: "SAMEORIGIN",
PBSPollingInterval: 60 * time.Second, // Default PBS polling (slower)
PMGPollingInterval: 60 * time.Second, // Default PMG polling (aggregated stats)
DiscoveryEnabled: true,
DiscoverySubnet: "auto",
EnvOverrides: make(map[string]bool),
OIDC: NewOIDCConfig(),
}
// Initialize persistence
@ -301,6 +305,15 @@ func Load() (*Config, error) {
cfg.PMGPollingInterval = time.Duration(systemSettings.PMGPollingInterval) * time.Second
}
if systemSettings.BackupPollingInterval > 0 {
cfg.BackupPollingInterval = time.Duration(systemSettings.BackupPollingInterval) * time.Second
} else if systemSettings.BackupPollingInterval == 0 {
cfg.BackupPollingInterval = 0
}
if systemSettings.BackupPollingEnabled != nil {
cfg.EnableBackupPolling = *systemSettings.BackupPollingEnabled
}
if systemSettings.UpdateChannel != "" {
cfg.UpdateChannel = systemSettings.UpdateChannel
}
@ -370,6 +383,53 @@ func Load() (*Config, error) {
// Limited environment variable support
// NOTE: Node configuration is NOT done via env vars - use the web UI instead
if cyclesStr := strings.TrimSpace(os.Getenv("BACKUP_POLLING_CYCLES")); cyclesStr != "" {
if cycles, err := strconv.Atoi(cyclesStr); err == nil {
if cycles < 0 {
log.Warn().Str("value", cyclesStr).Msg("Ignoring negative BACKUP_POLLING_CYCLES from environment")
} else {
cfg.BackupPollingCycles = cycles
cfg.EnvOverrides["BACKUP_POLLING_CYCLES"] = true
log.Info().Int("cycles", cycles).Msg("Overriding backup polling cycles from environment")
}
} else {
log.Warn().Str("value", cyclesStr).Msg("Invalid BACKUP_POLLING_CYCLES value, ignoring")
}
}
if intervalStr := strings.TrimSpace(os.Getenv("BACKUP_POLLING_INTERVAL")); intervalStr != "" {
if dur, err := time.ParseDuration(intervalStr); err == nil {
if dur < 0 {
log.Warn().Str("value", intervalStr).Msg("Ignoring negative BACKUP_POLLING_INTERVAL from environment")
} else {
cfg.BackupPollingInterval = dur
cfg.EnvOverrides["BACKUP_POLLING_INTERVAL"] = true
log.Info().Dur("interval", dur).Msg("Overriding backup polling interval from environment")
}
} else if seconds, err := strconv.Atoi(intervalStr); err == nil {
if seconds < 0 {
log.Warn().Str("value", intervalStr).Msg("Ignoring negative BACKUP_POLLING_INTERVAL (seconds) from environment")
} else {
cfg.BackupPollingInterval = time.Duration(seconds) * time.Second
cfg.EnvOverrides["BACKUP_POLLING_INTERVAL"] = true
log.Info().Int("seconds", seconds).Msg("Overriding backup polling interval (seconds) from environment")
}
} else {
log.Warn().Str("value", intervalStr).Msg("Invalid BACKUP_POLLING_INTERVAL value, expected duration or seconds")
}
}
if enabledStr := strings.TrimSpace(os.Getenv("ENABLE_BACKUP_POLLING")); enabledStr != "" {
switch strings.ToLower(enabledStr) {
case "0", "false", "no", "off":
cfg.EnableBackupPolling = false
default:
cfg.EnableBackupPolling = true
}
cfg.EnvOverrides["ENABLE_BACKUP_POLLING"] = true
log.Info().Bool("enabled", cfg.EnableBackupPolling).Msg("Overriding backup polling enabled flag from environment")
}
// Support both FRONTEND_PORT (preferred) and PORT (legacy) env vars
if frontendPort := os.Getenv("FRONTEND_PORT"); frontendPort != "" {
if p, err := strconv.Atoi(frontendPort); err == nil {

View file

@ -541,6 +541,8 @@ 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"`
BackendPort int `json:"backendPort,omitempty"`
FrontendPort int `json:"frontendPort,omitempty"`
AllowedOrigins string `json:"allowedOrigins,omitempty"`

View file

@ -28,10 +28,12 @@ type PortSettings struct {
// MonitoringSettings contains monitoring-related configuration
type MonitoringSettings struct {
PollingInterval int `json:"pollingInterval" yaml:"pollingInterval" mapstructure:"pollingInterval"` // milliseconds
ConcurrentPolling bool `json:"concurrentPolling" yaml:"concurrentPolling" mapstructure:"concurrentPolling"`
BackupPollingCycles int `json:"backupPollingCycles" yaml:"backupPollingCycles" mapstructure:"backupPollingCycles"` // How often to poll backups
MetricsRetentionDays int `json:"metricsRetentionDays" yaml:"metricsRetentionDays" mapstructure:"metricsRetentionDays"`
PollingInterval int `json:"pollingInterval" yaml:"pollingInterval" mapstructure:"pollingInterval"` // milliseconds
ConcurrentPolling bool `json:"concurrentPolling" yaml:"concurrentPolling" mapstructure:"concurrentPolling"`
BackupPollingCycles int `json:"backupPollingCycles" yaml:"backupPollingCycles" mapstructure:"backupPollingCycles"` // How often to poll backups
BackupPollingIntervalMs int `json:"backupPollingIntervalMs" yaml:"backupPollingIntervalMs" mapstructure:"backupPollingIntervalMs"` // 0 = use cycle-based scheduling
BackupPollingEnabled bool `json:"backupPollingEnabled" yaml:"backupPollingEnabled" mapstructure:"backupPollingEnabled"`
MetricsRetentionDays int `json:"metricsRetentionDays" yaml:"metricsRetentionDays" mapstructure:"metricsRetentionDays"`
}
// LoggingSettings contains logging configuration
@ -66,10 +68,12 @@ func DefaultSettings() *Settings {
},
},
Monitoring: MonitoringSettings{
PollingInterval: 5000, // 5 seconds
ConcurrentPolling: true,
BackupPollingCycles: 10, // Poll backups every 10 cycles
MetricsRetentionDays: 7,
PollingInterval: 5000, // 5 seconds
ConcurrentPolling: true,
BackupPollingCycles: 10, // Poll backups every 10 cycles
BackupPollingIntervalMs: 0,
BackupPollingEnabled: true,
MetricsRetentionDays: 7,
},
Logging: LoggingSettings{
Level: "info",
@ -118,8 +122,12 @@ func (s *Settings) Validate() error {
return fmt.Errorf("polling interval must be at least 1000ms (1 second)")
}
if s.Monitoring.BackupPollingCycles < 1 {
return fmt.Errorf("backup polling cycles must be at least 1")
if s.Monitoring.BackupPollingCycles < 0 {
return fmt.Errorf("backup polling cycles cannot be negative")
}
if s.Monitoring.BackupPollingIntervalMs < 0 {
return fmt.Errorf("backup polling interval cannot be negative")
}
// Validate logging level

View file

@ -269,6 +269,8 @@ type Monitor struct {
lastAuthAttempt map[string]time.Time // Track last auth attempt time
lastClusterCheck map[string]time.Time // Track last cluster check for standalone nodes
lastPhysicalDiskPoll map[string]time.Time // Track last physical disk poll time per instance
lastPVEBackupPoll map[string]time.Time // Track last PVE backup poll per instance
lastPBSBackupPoll map[string]time.Time // Track last PBS backup poll per instance
persistence *config.ConfigPersistence // Add persistence for saving updated configs
pbsBackupPollers map[string]bool // Track PBS backup polling goroutines per instance
runtimeCtx context.Context // Context used while monitor is running
@ -318,6 +320,42 @@ func safeFloat(val float64) float64 {
return val
}
// shouldRunBackupPoll determines whether a backup polling cycle should execute.
// Returns whether polling should run, a human-readable skip reason, and the timestamp to record.
func (m *Monitor) shouldRunBackupPoll(last time.Time, now time.Time) (bool, string, time.Time) {
if m == nil || m.config == nil {
return false, "configuration unavailable", last
}
if !m.config.EnableBackupPolling {
return false, "backup polling globally disabled", last
}
interval := m.config.BackupPollingInterval
if interval > 0 {
if !last.IsZero() && now.Sub(last) < interval {
next := last.Add(interval)
return false, fmt.Sprintf("next run scheduled for %s", next.Format(time.RFC3339)), last
}
return true, "", now
}
backupCycles := m.config.BackupPollingCycles
if backupCycles <= 0 {
backupCycles = 10
}
if m.pollCounter%int64(backupCycles) == 0 || m.pollCounter == 1 {
return true, "", now
}
remaining := int64(backupCycles) - (m.pollCounter % int64(backupCycles))
if remaining <= 0 {
remaining = int64(backupCycles)
}
return false, fmt.Sprintf("next run in %d polling cycles", remaining), last
}
const (
dockerConnectionPrefix = "docker-"
dockerOfflineGraceMultiplier = 4
@ -1286,6 +1324,8 @@ func New(cfg *config.Config) (*Monitor, error) {
lastAuthAttempt: make(map[string]time.Time),
lastClusterCheck: make(map[string]time.Time),
lastPhysicalDiskPoll: make(map[string]time.Time),
lastPVEBackupPoll: make(map[string]time.Time),
lastPBSBackupPoll: make(map[string]time.Time),
persistence: config.NewConfigPersistence(cfg.DataPath),
pbsBackupPollers: make(map[string]bool),
nodeSnapshots: make(map[string]NodeMemorySnapshot),
@ -3062,44 +3102,70 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
}
}
// Poll backups if enabled - using configurable cycle count
// This prevents slow backup/snapshot queries from blocking real-time stats
// Also poll on first cycle (pollCounter == 1) to ensure data loads quickly
backupCycles := 10 // default
if m.config.BackupPollingCycles > 0 {
backupCycles = m.config.BackupPollingCycles
}
if instanceCfg.MonitorBackups && (m.pollCounter%int64(backupCycles) == 0 || m.pollCounter == 1) {
select {
case <-ctx.Done():
return
default:
// Run backup polling in a separate goroutine to not block main polling
go func() {
timeout := m.calculateBackupOperationTimeout(instanceName)
startTime := time.Now()
log.Info().
Str("instance", instanceName).
Dur("timeout", timeout).
Msg("Starting background backup/snapshot polling")
// Create a separate context with longer timeout for backup operations
backupCtx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Poll backups if enabled - respect configured interval or cycle gating
if instanceCfg.MonitorBackups {
if !m.config.EnableBackupPolling {
log.Debug().
Str("instance", instanceName).
Msg("Skipping backup polling - globally disabled")
} else {
now := time.Now()
// Poll backup tasks
m.pollBackupTasks(backupCtx, instanceName, client)
m.mu.RLock()
lastPoll := m.lastPVEBackupPoll[instanceName]
m.mu.RUnlock()
// Poll storage backups - pass nodes to avoid duplicate API calls
m.pollStorageBackupsWithNodes(backupCtx, instanceName, client, nodes)
shouldPoll, reason, newLast := m.shouldRunBackupPoll(lastPoll, now)
if !shouldPoll {
if reason != "" {
log.Debug().
Str("instance", instanceName).
Str("reason", reason).
Msg("Skipping PVE backup polling this cycle")
}
} else {
select {
case <-ctx.Done():
return
default:
m.mu.Lock()
m.lastPVEBackupPoll[instanceName] = newLast
m.mu.Unlock()
// Poll guest snapshots
m.pollGuestSnapshots(backupCtx, instanceName, client)
// Run backup polling in a separate goroutine to avoid blocking real-time stats
go func(startTime time.Time, inst string, pveClient PVEClientInterface) {
timeout := m.calculateBackupOperationTimeout(inst)
log.Info().
Str("instance", inst).
Dur("timeout", timeout).
Msg("Starting background backup/snapshot polling")
log.Info().
Str("instance", instanceName).
Dur("duration", time.Since(startTime)).
Msg("Completed background backup/snapshot polling")
}()
// Create a separate context with longer timeout for backup operations
backupCtx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Poll backup tasks
m.pollBackupTasks(backupCtx, inst, pveClient)
// Poll storage backups - pass nodes to avoid duplicate API calls
m.pollStorageBackupsWithNodes(backupCtx, inst, pveClient, nodes)
// Poll guest snapshots
m.pollGuestSnapshots(backupCtx, inst, pveClient)
duration := time.Since(startTime)
log.Info().
Str("instance", inst).
Dur("duration", duration).
Msg("Completed background backup/snapshot polling")
// Record actual completion time for interval scheduling
m.mu.Lock()
m.lastPVEBackupPoll[inst] = time.Now()
m.mu.Unlock()
}(now, instanceName, client)
}
}
}
}
}
@ -4963,49 +5029,64 @@ func (m *Monitor) pollPBSInstance(ctx context.Context, instanceName string, clie
Str("instance", instanceName).
Msg("No PBS datastores available for backup polling")
} else {
backupCycles := 10
if m.config.BackupPollingCycles > 0 {
backupCycles = m.config.BackupPollingCycles
}
shouldPoll := m.pollCounter%int64(backupCycles) == 0 || m.pollCounter == 1
if !shouldPoll {
if !m.config.EnableBackupPolling {
log.Debug().
Str("instance", instanceName).
Int64("cycle", m.pollCounter).
Int("backupCycles", backupCycles).
Msg("Skipping PBS backup polling this cycle")
Msg("Skipping PBS backup polling - globally disabled")
} else {
m.mu.Lock()
if m.pbsBackupPollers[instanceName] {
m.mu.Unlock()
log.Debug().
Str("instance", instanceName).
Msg("PBS backup polling already in progress")
} else {
m.pbsBackupPollers[instanceName] = true
m.mu.Unlock()
now := time.Now()
m.mu.RLock()
lastPoll := m.lastPBSBackupPoll[instanceName]
m.mu.RUnlock()
shouldPoll, reason, newLast := m.shouldRunBackupPoll(lastPoll, now)
if !shouldPoll {
if reason != "" {
log.Debug().
Str("instance", instanceName).
Str("reason", reason).
Msg("Skipping PBS backup polling this cycle")
}
} else {
datastoreSnapshot := make([]models.PBSDatastore, len(pbsInst.Datastores))
copy(datastoreSnapshot, pbsInst.Datastores)
go func(ds []models.PBSDatastore) {
defer func() {
m.mu.Lock()
delete(m.pbsBackupPollers, instanceName)
m.mu.Unlock()
}()
log.Info().
m.mu.Lock()
if m.pbsBackupPollers[instanceName] {
m.mu.Unlock()
log.Debug().
Str("instance", instanceName).
Int("datastores", len(ds)).
Msg("Starting background PBS backup polling")
Msg("PBS backup polling already in progress")
} else {
m.pbsBackupPollers[instanceName] = true
m.lastPBSBackupPoll[instanceName] = newLast
m.mu.Unlock()
backupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
go func(ds []models.PBSDatastore, inst string, start time.Time, pbsClient *pbs.Client) {
defer func() {
m.mu.Lock()
delete(m.pbsBackupPollers, inst)
m.lastPBSBackupPoll[inst] = time.Now()
m.mu.Unlock()
}()
m.pollPBSBackups(backupCtx, instanceName, client, ds)
}(datastoreSnapshot)
log.Info().
Str("instance", inst).
Int("datastores", len(ds)).
Msg("Starting background PBS backup polling")
backupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
m.pollPBSBackups(backupCtx, inst, pbsClient, ds)
log.Info().
Str("instance", inst).
Dur("duration", time.Since(start)).
Msg("Completed background PBS backup polling")
}(datastoreSnapshot, instanceName, now, client)
}
}
}
}