Add configurable backup polling interval
This commit is contained in:
parent
d15ad1d0b4
commit
0b4e4f9c59
12 changed files with 547 additions and 116 deletions
|
|
@ -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)
|
- `CONNECTION_TIMEOUT` - API timeout in seconds (default: 10)
|
||||||
- `ALLOWED_ORIGINS` - CORS origins (default: same-origin only)
|
- `ALLOWED_ORIGINS` - CORS origins (default: same-origin only)
|
||||||
- `LOG_LEVEL` - Log verbosity: debug/info/warn/error (default: info)
|
- `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`)
|
- `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)
|
- **Auto-detected** if not set (except inside Docker where detection is disabled)
|
||||||
- Used in webhook notifications for "View in Pulse" links
|
- Used in webhook notifications for "View in Pulse" links
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ export interface SystemSettings {
|
||||||
autoUpdateEnabled: boolean;
|
autoUpdateEnabled: boolean;
|
||||||
autoUpdateCheckInterval?: number;
|
autoUpdateCheckInterval?: number;
|
||||||
autoUpdateTime?: string;
|
autoUpdateTime?: string;
|
||||||
|
backupPollingInterval?: number;
|
||||||
|
backupPollingEnabled?: boolean;
|
||||||
// apiToken removed - now handled via security API
|
// apiToken removed - now handled via security API
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Node with UI-specific fields
|
||||||
type NodeConfigWithStatus = NodeConfig & {
|
type NodeConfigWithStatus = NodeConfig & {
|
||||||
hasPassword?: boolean;
|
hasPassword?: boolean;
|
||||||
|
|
@ -363,6 +375,36 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = createSignal(false);
|
const [autoUpdateEnabled, setAutoUpdateEnabled] = createSignal(false);
|
||||||
const [autoUpdateCheckInterval, setAutoUpdateCheckInterval] = createSignal(24);
|
const [autoUpdateCheckInterval, setAutoUpdateCheckInterval] = createSignal(24);
|
||||||
const [autoUpdateTime, setAutoUpdateTime] = createSignal('03:00');
|
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
|
// Diagnostics
|
||||||
const [diagnosticsData, setDiagnosticsData] = createSignal<DiagnosticsData | null>(null);
|
const [diagnosticsData, setDiagnosticsData] = createSignal<DiagnosticsData | null>(null);
|
||||||
|
|
@ -988,6 +1030,20 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
// Load embedding settings
|
// Load embedding settings
|
||||||
setAllowEmbedding(systemSettings.allowEmbedding ?? false);
|
setAllowEmbedding(systemSettings.allowEmbedding ?? false);
|
||||||
setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || '');
|
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
|
// Load auto-update settings
|
||||||
setAutoUpdateEnabled(systemSettings.autoUpdateEnabled || false);
|
setAutoUpdateEnabled(systemSettings.autoUpdateEnabled || false);
|
||||||
setAutoUpdateCheckInterval(systemSettings.autoUpdateCheckInterval || 24);
|
setAutoUpdateCheckInterval(systemSettings.autoUpdateCheckInterval || 24);
|
||||||
|
|
@ -1077,6 +1133,8 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
autoUpdateEnabled: autoUpdateEnabled(),
|
autoUpdateEnabled: autoUpdateEnabled(),
|
||||||
autoUpdateCheckInterval: autoUpdateCheckInterval(),
|
autoUpdateCheckInterval: autoUpdateCheckInterval(),
|
||||||
autoUpdateTime: autoUpdateTime(),
|
autoUpdateTime: autoUpdateTime(),
|
||||||
|
backupPollingEnabled: backupPollingEnabled(),
|
||||||
|
backupPollingInterval: backupPollingInterval(),
|
||||||
allowEmbedding: allowEmbedding(),
|
allowEmbedding: allowEmbedding(),
|
||||||
allowedEmbedOrigins: allowedEmbedOrigins(),
|
allowedEmbedOrigins: allowedEmbedOrigins(),
|
||||||
});
|
});
|
||||||
|
|
@ -3418,12 +3476,160 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
border={false}
|
border={false}
|
||||||
class="border border-gray-200 dark:border-gray-700"
|
class="border border-gray-200 dark:border-gray-700"
|
||||||
>
|
>
|
||||||
<SectionHeader
|
<section class="space-y-3">
|
||||||
title="Backup & restore"
|
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
description="Backup your node configurations and credentials or restore from a previous backup."
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
size="md"
|
<circle cx="12" cy="12" r="9" stroke-width="2" />
|
||||||
class="mb-4"
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Export Section */}
|
{/* Export Section */}
|
||||||
|
|
|
||||||
|
|
@ -621,11 +621,17 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
: 'text-green-600 dark:text-green-400';
|
: 'text-green-600 dark:text-green-400';
|
||||||
|
|
||||||
const temp = node!.temperature;
|
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) {
|
if (hasMinMax) {
|
||||||
const min = Math.round(temp.cpuMin);
|
const min = Math.round(cpuMin);
|
||||||
const max = Math.round(temp.cpuMaxRecord);
|
const max = Math.round(cpuMax);
|
||||||
|
|
||||||
const getTooltipColor = (temp: number) => {
|
const getTooltipColor = (temp: number) => {
|
||||||
if (temp >= 80) return 'text-red-400';
|
if (temp >= 80) return 'text-red-400';
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ export interface SystemConfig {
|
||||||
updateChannel?: string; // Update channel: 'stable' | 'rc' | 'beta'
|
updateChannel?: string; // Update channel: 'stable' | 'rc' | 'beta'
|
||||||
autoUpdateCheckInterval?: number; // Hours between update checks
|
autoUpdateCheckInterval?: number; // Hours between update checks
|
||||||
autoUpdateTime?: string; // Time for updates (HH:MM format)
|
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
|
allowedOrigins?: string; // CORS allowed origins
|
||||||
backendPort?: number; // Backend API port (default: 7655)
|
backendPort?: number; // Backend API port (default: 7655)
|
||||||
frontendPort?: number; // Frontend UI port (default: 7655)
|
frontendPort?: number; // Frontend UI port (default: 7655)
|
||||||
|
|
@ -147,6 +149,8 @@ export const DEFAULT_CONFIG: {
|
||||||
updateChannel: 'stable',
|
updateChannel: 'stable',
|
||||||
autoUpdateCheckInterval: 24,
|
autoUpdateCheckInterval: 24,
|
||||||
autoUpdateTime: '03:00',
|
autoUpdateTime: '03:00',
|
||||||
|
backupPollingEnabled: true,
|
||||||
|
backupPollingInterval: 0,
|
||||||
allowedOrigins: '',
|
allowedOrigins: '',
|
||||||
backendPort: 7655,
|
backendPort: 7655,
|
||||||
frontendPort: 7655,
|
frontendPort: 7655,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ export interface MonitoringSettings {
|
||||||
// Note: PVE polling is hardcoded to 10s server-side
|
// Note: PVE polling is hardcoded to 10s server-side
|
||||||
concurrentPolling: boolean;
|
concurrentPolling: boolean;
|
||||||
backupPollingCycles: number;
|
backupPollingCycles: number;
|
||||||
|
backupPollingIntervalMs: number;
|
||||||
|
backupPollingEnabled: boolean;
|
||||||
metricsRetentionDays: number;
|
metricsRetentionDays: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2523,6 +2523,7 @@ func (h *ConfigHandlers) HandleGetSystemSettings(w http.ResponseWriter, r *http.
|
||||||
settings := config.SystemSettings{
|
settings := config.SystemSettings{
|
||||||
// Note: PVE polling is hardcoded to 10s
|
// Note: PVE polling is hardcoded to 10s
|
||||||
PBSPollingInterval: int(h.config.PBSPollingInterval.Seconds()),
|
PBSPollingInterval: int(h.config.PBSPollingInterval.Seconds()),
|
||||||
|
BackupPollingInterval: int(h.config.BackupPollingInterval.Seconds()),
|
||||||
BackendPort: h.config.BackendPort,
|
BackendPort: h.config.BackendPort,
|
||||||
FrontendPort: h.config.FrontendPort,
|
FrontendPort: h.config.FrontendPort,
|
||||||
AllowedOrigins: h.config.AllowedOrigins,
|
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
|
DiscoveryEnabled: persistedSettings.DiscoveryEnabled, // Include discoveryEnabled from persisted settings
|
||||||
DiscoverySubnet: persistedSettings.DiscoverySubnet, // Include discoverySubnet 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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(settings)
|
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)
|
http.Error(w, "PBS polling interval must be positive", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if settings.BackupPollingInterval < 0 {
|
||||||
|
http.Error(w, "Backup polling interval cannot be negative", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Update polling intervals
|
// Update polling intervals
|
||||||
needsReload := false
|
needsReload := false
|
||||||
|
|
@ -2679,6 +2686,14 @@ func (h *ConfigHandlers) HandleUpdateSystemSettingsOLD(w http.ResponseWriter, r
|
||||||
h.config.PBSPollingInterval = time.Duration(settings.PBSPollingInterval) * time.Second
|
h.config.PBSPollingInterval = time.Duration(settings.PBSPollingInterval) * time.Second
|
||||||
needsReload = true
|
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
|
// Trigger a monitor reload if intervals changed
|
||||||
if needsReload && h.reloadFunc != nil {
|
if needsReload && h.reloadFunc != nil {
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Validate boolean fields have correct type
|
||||||
if val, ok := rawRequest["autoUpdateEnabled"]; ok {
|
if val, ok := rawRequest["autoUpdateEnabled"]; ok {
|
||||||
if _, ok := val.(bool); !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)
|
// Validate auto-update check interval (min 1 hour, max 7 days)
|
||||||
if val, ok := rawRequest["autoUpdateCheckInterval"]; ok {
|
if val, ok := rawRequest["autoUpdateCheckInterval"]; ok {
|
||||||
if interval, ok := val.(float64); ok {
|
if interval, ok := val.(float64); ok {
|
||||||
|
|
@ -184,6 +206,11 @@ func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r
|
||||||
log.Debug().
|
log.Debug().
|
||||||
Str("theme", settings.Theme).
|
Str("theme", settings.Theme).
|
||||||
Msg("Loaded system settings for API response")
|
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
|
// Include env override information
|
||||||
|
|
@ -279,6 +306,9 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
|
||||||
if _, ok := rawRequest["pmgPollingInterval"]; ok {
|
if _, ok := rawRequest["pmgPollingInterval"]; ok {
|
||||||
settings.PMGPollingInterval = updates.PMGPollingInterval
|
settings.PMGPollingInterval = updates.PMGPollingInterval
|
||||||
}
|
}
|
||||||
|
if _, ok := rawRequest["backupPollingInterval"]; ok {
|
||||||
|
settings.BackupPollingInterval = updates.BackupPollingInterval
|
||||||
|
}
|
||||||
if updates.AllowedOrigins != "" {
|
if updates.AllowedOrigins != "" {
|
||||||
settings.AllowedOrigins = updates.AllowedOrigins
|
settings.AllowedOrigins = updates.AllowedOrigins
|
||||||
}
|
}
|
||||||
|
|
@ -315,6 +345,9 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
|
||||||
if _, ok := rawRequest["allowEmbedding"]; ok {
|
if _, ok := rawRequest["allowEmbedding"]; ok {
|
||||||
settings.AllowEmbedding = updates.AllowEmbedding
|
settings.AllowEmbedding = updates.AllowEmbedding
|
||||||
}
|
}
|
||||||
|
if _, ok := rawRequest["backupPollingEnabled"]; ok {
|
||||||
|
settings.BackupPollingEnabled = updates.BackupPollingEnabled
|
||||||
|
}
|
||||||
|
|
||||||
// Update the config
|
// Update the config
|
||||||
// Note: PVE polling is hardcoded to 10s
|
// Note: PVE polling is hardcoded to 10s
|
||||||
|
|
@ -327,6 +360,16 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
|
||||||
if settings.PMGPollingInterval > 0 {
|
if settings.PMGPollingInterval > 0 {
|
||||||
h.config.PMGPollingInterval = time.Duration(settings.PMGPollingInterval) * time.Second
|
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 != "" {
|
if settings.UpdateChannel != "" {
|
||||||
h.config.UpdateChannel = settings.UpdateChannel
|
h.config.UpdateChannel = settings.UpdateChannel
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,13 +74,15 @@ type Config struct {
|
||||||
|
|
||||||
// Monitoring settings
|
// Monitoring settings
|
||||||
// Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s
|
// 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)
|
PBSPollingInterval time.Duration `envconfig:"PBS_POLLING_INTERVAL"` // PBS polling interval (60s default)
|
||||||
PMGPollingInterval time.Duration `envconfig:"PMG_POLLING_INTERVAL"` // PMG polling interval (60s default)
|
PMGPollingInterval time.Duration `envconfig:"PMG_POLLING_INTERVAL"` // PMG polling interval (60s default)
|
||||||
ConcurrentPolling bool `envconfig:"CONCURRENT_POLLING" default:"true"`
|
ConcurrentPolling bool `envconfig:"CONCURRENT_POLLING" default:"true"`
|
||||||
ConnectionTimeout time.Duration `envconfig:"CONNECTION_TIMEOUT" default:"45s"` // Increased for slow storage operations
|
ConnectionTimeout time.Duration `envconfig:"CONNECTION_TIMEOUT" default:"45s"` // Increased for slow storage operations
|
||||||
MetricsRetentionDays int `envconfig:"METRICS_RETENTION_DAYS" default:"7"`
|
MetricsRetentionDays int `envconfig:"METRICS_RETENTION_DAYS" default:"7"`
|
||||||
BackupPollingCycles int `envconfig:"BACKUP_POLLING_CYCLES" default:"10"`
|
BackupPollingCycles int `envconfig:"BACKUP_POLLING_CYCLES" default:"10"`
|
||||||
WebhookBatchDelay time.Duration `envconfig:"WEBHOOK_BATCH_DELAY" default:"10s"`
|
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
|
// Logging settings
|
||||||
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
|
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
|
||||||
|
|
@ -247,29 +249,31 @@ func Load() (*Config, error) {
|
||||||
|
|
||||||
// Initialize config with defaults
|
// Initialize config with defaults
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
BackendHost: "0.0.0.0",
|
BackendHost: "0.0.0.0",
|
||||||
BackendPort: 3000,
|
BackendPort: 3000,
|
||||||
FrontendHost: "0.0.0.0",
|
FrontendHost: "0.0.0.0",
|
||||||
FrontendPort: 7655,
|
FrontendPort: 7655,
|
||||||
ConfigPath: dataDir,
|
ConfigPath: dataDir,
|
||||||
DataPath: dataDir,
|
DataPath: dataDir,
|
||||||
ConcurrentPolling: true,
|
ConcurrentPolling: true,
|
||||||
ConnectionTimeout: 60 * time.Second,
|
ConnectionTimeout: 60 * time.Second,
|
||||||
MetricsRetentionDays: 7,
|
MetricsRetentionDays: 7,
|
||||||
BackupPollingCycles: 10,
|
BackupPollingCycles: 10,
|
||||||
WebhookBatchDelay: 10 * time.Second,
|
BackupPollingInterval: 0,
|
||||||
LogLevel: "info",
|
EnableBackupPolling: true,
|
||||||
LogMaxSize: 100,
|
WebhookBatchDelay: 10 * time.Second,
|
||||||
LogMaxAge: 30,
|
LogLevel: "info",
|
||||||
LogCompress: true,
|
LogMaxSize: 100,
|
||||||
AllowedOrigins: "", // Empty means no CORS headers (same-origin only)
|
LogMaxAge: 30,
|
||||||
IframeEmbeddingAllow: "SAMEORIGIN",
|
LogCompress: true,
|
||||||
PBSPollingInterval: 60 * time.Second, // Default PBS polling (slower)
|
AllowedOrigins: "", // Empty means no CORS headers (same-origin only)
|
||||||
PMGPollingInterval: 60 * time.Second, // Default PMG polling (aggregated stats)
|
IframeEmbeddingAllow: "SAMEORIGIN",
|
||||||
DiscoveryEnabled: true,
|
PBSPollingInterval: 60 * time.Second, // Default PBS polling (slower)
|
||||||
DiscoverySubnet: "auto",
|
PMGPollingInterval: 60 * time.Second, // Default PMG polling (aggregated stats)
|
||||||
EnvOverrides: make(map[string]bool),
|
DiscoveryEnabled: true,
|
||||||
OIDC: NewOIDCConfig(),
|
DiscoverySubnet: "auto",
|
||||||
|
EnvOverrides: make(map[string]bool),
|
||||||
|
OIDC: NewOIDCConfig(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize persistence
|
// Initialize persistence
|
||||||
|
|
@ -301,6 +305,15 @@ func Load() (*Config, error) {
|
||||||
cfg.PMGPollingInterval = time.Duration(systemSettings.PMGPollingInterval) * time.Second
|
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 != "" {
|
if systemSettings.UpdateChannel != "" {
|
||||||
cfg.UpdateChannel = systemSettings.UpdateChannel
|
cfg.UpdateChannel = systemSettings.UpdateChannel
|
||||||
}
|
}
|
||||||
|
|
@ -370,6 +383,53 @@ func Load() (*Config, error) {
|
||||||
// Limited environment variable support
|
// Limited environment variable support
|
||||||
// NOTE: Node configuration is NOT done via env vars - use the web UI instead
|
// 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
|
// Support both FRONTEND_PORT (preferred) and PORT (legacy) env vars
|
||||||
if frontendPort := os.Getenv("FRONTEND_PORT"); frontendPort != "" {
|
if frontendPort := os.Getenv("FRONTEND_PORT"); frontendPort != "" {
|
||||||
if p, err := strconv.Atoi(frontendPort); err == nil {
|
if p, err := strconv.Atoi(frontendPort); err == nil {
|
||||||
|
|
|
||||||
|
|
@ -541,6 +541,8 @@ type SystemSettings struct {
|
||||||
// Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s
|
// 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
|
PBSPollingInterval int `json:"pbsPollingInterval"` // PBS polling interval in seconds
|
||||||
PMGPollingInterval int `json:"pmgPollingInterval"` // PMG 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"`
|
BackendPort int `json:"backendPort,omitempty"`
|
||||||
FrontendPort int `json:"frontendPort,omitempty"`
|
FrontendPort int `json:"frontendPort,omitempty"`
|
||||||
AllowedOrigins string `json:"allowedOrigins,omitempty"`
|
AllowedOrigins string `json:"allowedOrigins,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,12 @@ type PortSettings struct {
|
||||||
|
|
||||||
// MonitoringSettings contains monitoring-related configuration
|
// MonitoringSettings contains monitoring-related configuration
|
||||||
type MonitoringSettings struct {
|
type MonitoringSettings struct {
|
||||||
PollingInterval int `json:"pollingInterval" yaml:"pollingInterval" mapstructure:"pollingInterval"` // milliseconds
|
PollingInterval int `json:"pollingInterval" yaml:"pollingInterval" mapstructure:"pollingInterval"` // milliseconds
|
||||||
ConcurrentPolling bool `json:"concurrentPolling" yaml:"concurrentPolling" mapstructure:"concurrentPolling"`
|
ConcurrentPolling bool `json:"concurrentPolling" yaml:"concurrentPolling" mapstructure:"concurrentPolling"`
|
||||||
BackupPollingCycles int `json:"backupPollingCycles" yaml:"backupPollingCycles" mapstructure:"backupPollingCycles"` // How often to poll backups
|
BackupPollingCycles int `json:"backupPollingCycles" yaml:"backupPollingCycles" mapstructure:"backupPollingCycles"` // How often to poll backups
|
||||||
MetricsRetentionDays int `json:"metricsRetentionDays" yaml:"metricsRetentionDays" mapstructure:"metricsRetentionDays"`
|
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
|
// LoggingSettings contains logging configuration
|
||||||
|
|
@ -66,10 +68,12 @@ func DefaultSettings() *Settings {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Monitoring: MonitoringSettings{
|
Monitoring: MonitoringSettings{
|
||||||
PollingInterval: 5000, // 5 seconds
|
PollingInterval: 5000, // 5 seconds
|
||||||
ConcurrentPolling: true,
|
ConcurrentPolling: true,
|
||||||
BackupPollingCycles: 10, // Poll backups every 10 cycles
|
BackupPollingCycles: 10, // Poll backups every 10 cycles
|
||||||
MetricsRetentionDays: 7,
|
BackupPollingIntervalMs: 0,
|
||||||
|
BackupPollingEnabled: true,
|
||||||
|
MetricsRetentionDays: 7,
|
||||||
},
|
},
|
||||||
Logging: LoggingSettings{
|
Logging: LoggingSettings{
|
||||||
Level: "info",
|
Level: "info",
|
||||||
|
|
@ -118,8 +122,12 @@ func (s *Settings) Validate() error {
|
||||||
return fmt.Errorf("polling interval must be at least 1000ms (1 second)")
|
return fmt.Errorf("polling interval must be at least 1000ms (1 second)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.Monitoring.BackupPollingCycles < 1 {
|
if s.Monitoring.BackupPollingCycles < 0 {
|
||||||
return fmt.Errorf("backup polling cycles must be at least 1")
|
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
|
// Validate logging level
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,8 @@ type Monitor struct {
|
||||||
lastAuthAttempt map[string]time.Time // Track last auth attempt time
|
lastAuthAttempt map[string]time.Time // Track last auth attempt time
|
||||||
lastClusterCheck map[string]time.Time // Track last cluster check for standalone nodes
|
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
|
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
|
persistence *config.ConfigPersistence // Add persistence for saving updated configs
|
||||||
pbsBackupPollers map[string]bool // Track PBS backup polling goroutines per instance
|
pbsBackupPollers map[string]bool // Track PBS backup polling goroutines per instance
|
||||||
runtimeCtx context.Context // Context used while monitor is running
|
runtimeCtx context.Context // Context used while monitor is running
|
||||||
|
|
@ -318,6 +320,42 @@ func safeFloat(val float64) float64 {
|
||||||
return val
|
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 (
|
const (
|
||||||
dockerConnectionPrefix = "docker-"
|
dockerConnectionPrefix = "docker-"
|
||||||
dockerOfflineGraceMultiplier = 4
|
dockerOfflineGraceMultiplier = 4
|
||||||
|
|
@ -1286,6 +1324,8 @@ func New(cfg *config.Config) (*Monitor, error) {
|
||||||
lastAuthAttempt: make(map[string]time.Time),
|
lastAuthAttempt: make(map[string]time.Time),
|
||||||
lastClusterCheck: make(map[string]time.Time),
|
lastClusterCheck: make(map[string]time.Time),
|
||||||
lastPhysicalDiskPoll: 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),
|
persistence: config.NewConfigPersistence(cfg.DataPath),
|
||||||
pbsBackupPollers: make(map[string]bool),
|
pbsBackupPollers: make(map[string]bool),
|
||||||
nodeSnapshots: make(map[string]NodeMemorySnapshot),
|
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
|
// Poll backups if enabled - respect configured interval or cycle gating
|
||||||
// This prevents slow backup/snapshot queries from blocking real-time stats
|
if instanceCfg.MonitorBackups {
|
||||||
// Also poll on first cycle (pollCounter == 1) to ensure data loads quickly
|
if !m.config.EnableBackupPolling {
|
||||||
backupCycles := 10 // default
|
log.Debug().
|
||||||
if m.config.BackupPollingCycles > 0 {
|
Str("instance", instanceName).
|
||||||
backupCycles = m.config.BackupPollingCycles
|
Msg("Skipping backup polling - globally disabled")
|
||||||
}
|
} else {
|
||||||
if instanceCfg.MonitorBackups && (m.pollCounter%int64(backupCycles) == 0 || m.pollCounter == 1) {
|
now := time.Now()
|
||||||
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 backup tasks
|
m.mu.RLock()
|
||||||
m.pollBackupTasks(backupCtx, instanceName, client)
|
lastPoll := m.lastPVEBackupPoll[instanceName]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
// Poll storage backups - pass nodes to avoid duplicate API calls
|
shouldPoll, reason, newLast := m.shouldRunBackupPoll(lastPoll, now)
|
||||||
m.pollStorageBackupsWithNodes(backupCtx, instanceName, client, nodes)
|
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
|
// Run backup polling in a separate goroutine to avoid blocking real-time stats
|
||||||
m.pollGuestSnapshots(backupCtx, instanceName, client)
|
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().
|
// Create a separate context with longer timeout for backup operations
|
||||||
Str("instance", instanceName).
|
backupCtx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
Dur("duration", time.Since(startTime)).
|
defer cancel()
|
||||||
Msg("Completed background backup/snapshot polling")
|
|
||||||
}()
|
// 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).
|
Str("instance", instanceName).
|
||||||
Msg("No PBS datastores available for backup polling")
|
Msg("No PBS datastores available for backup polling")
|
||||||
} else {
|
} else {
|
||||||
backupCycles := 10
|
if !m.config.EnableBackupPolling {
|
||||||
if m.config.BackupPollingCycles > 0 {
|
|
||||||
backupCycles = m.config.BackupPollingCycles
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldPoll := m.pollCounter%int64(backupCycles) == 0 || m.pollCounter == 1
|
|
||||||
if !shouldPoll {
|
|
||||||
log.Debug().
|
log.Debug().
|
||||||
Str("instance", instanceName).
|
Str("instance", instanceName).
|
||||||
Int64("cycle", m.pollCounter).
|
Msg("Skipping PBS backup polling - globally disabled")
|
||||||
Int("backupCycles", backupCycles).
|
|
||||||
Msg("Skipping PBS backup polling this cycle")
|
|
||||||
} else {
|
} else {
|
||||||
m.mu.Lock()
|
now := time.Now()
|
||||||
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()
|
|
||||||
|
|
||||||
|
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))
|
datastoreSnapshot := make([]models.PBSDatastore, len(pbsInst.Datastores))
|
||||||
copy(datastoreSnapshot, pbsInst.Datastores)
|
copy(datastoreSnapshot, pbsInst.Datastores)
|
||||||
|
|
||||||
go func(ds []models.PBSDatastore) {
|
m.mu.Lock()
|
||||||
defer func() {
|
if m.pbsBackupPollers[instanceName] {
|
||||||
m.mu.Lock()
|
m.mu.Unlock()
|
||||||
delete(m.pbsBackupPollers, instanceName)
|
log.Debug().
|
||||||
m.mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Info().
|
|
||||||
Str("instance", instanceName).
|
Str("instance", instanceName).
|
||||||
Int("datastores", len(ds)).
|
Msg("PBS backup polling already in progress")
|
||||||
Msg("Starting background PBS backup polling")
|
} else {
|
||||||
|
m.pbsBackupPollers[instanceName] = true
|
||||||
|
m.lastPBSBackupPoll[instanceName] = newLast
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
backupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
go func(ds []models.PBSDatastore, inst string, start time.Time, pbsClient *pbs.Client) {
|
||||||
defer cancel()
|
defer func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.pbsBackupPollers, inst)
|
||||||
|
m.lastPBSBackupPoll[inst] = time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
m.pollPBSBackups(backupCtx, instanceName, client, ds)
|
log.Info().
|
||||||
}(datastoreSnapshot)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue