From dd3e2fe885a6490aae9a4ca480a041989ad4e73a Mon Sep 17 00:00:00 2001 From: kyle Date: Thu, 8 Oct 2026 09:53:55 -0600 Subject: [PATCH] Read Veeam next run from the live schedule On VBR 13 a job's ScheduleOptions property is a stale snapshot: NextRun is empty (and its LatestRunLocal years old), so every Veeam job showed no next run and could never be flagged Overdue. GetScheduleOptions() / Get-VBRJobScheduleOptions return the live schedule ("2026-10-09 12:15:00 AM"). Use GetScheduleOptions(), falling back to the property. Immediate-mode backup copy jobs (IsContinuous) have a live NextRun too, but it's a stale date months in the past, which would show as Overdue; keep reporting no next run for them. Parsing tries the current culture, then invariant; VBR 13's format parsed correctly under en-CA, en-US, fr-CA and en-GB. Mock updated to VBR 13's schedule shape. Co-Authored-By: Claude Opus 5.5 --- collectors/veeam/README.md | 2 +- collectors/veeam/Send-VeeamBackupStatus.ps1 | 23 ++++++++++++++------ collectors/veeam/tests/MockVeeam.ps1 | 24 +++++++++++++++------ 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/collectors/veeam/README.md b/collectors/veeam/README.md index 65cd8c0..5b3dede 100644 --- a/collectors/veeam/README.md +++ b/collectors/veeam/README.md @@ -40,7 +40,7 @@ delete the scheduled task and both `BackupDashboard` folders. | Job | Job name. The job's GUID is the ID, so renaming a job in Veeam doesn't duplicate it | | Status | Session result (Success / Warning / Failed). Any state other than Stopped is **Running**. A session that stopped with no result (e.g. cancelled) is reported as **Warning** | | Started / finished | Session `CreationTime` / `EndTime` | -| Next scheduled | Job's next run. Sent as empty for disabled jobs and chained ("run after") jobs, so those are never flagged Overdue | +| Next scheduled | Job's next run, from the live schedule (`GetScheduleOptions()`). Sent as empty, so never flagged Overdue, for disabled jobs, chained ("run after") jobs, and immediate-mode backup copy jobs, which run whenever their source does | | Message | Session reason, each VM or object that didn't succeed, and warning/error lines from the session log | | Size | `BackupStats.BackupSize` | diff --git a/collectors/veeam/Send-VeeamBackupStatus.ps1 b/collectors/veeam/Send-VeeamBackupStatus.ps1 index 50a75a3..36c6dcb 100644 --- a/collectors/veeam/Send-VeeamBackupStatus.ps1 +++ b/collectors/veeam/Send-VeeamBackupStatus.ps1 @@ -85,14 +85,25 @@ function Import-Veeam { function Get-NextRun($Job) { try { if (-not $Job.IsScheduleEnabled) { return $null } - $raw = $Job.ScheduleOptions.NextRun + # Immediate-mode backup copy jobs run whenever their source job does. Their schedule still + # carries a NextRun, but it's a stale date (months old on VBR 13) that would show as Overdue. + if ($Job.IsContinuous) { return $null } + + # The ScheduleOptions property is a stale snapshot on VBR 13 (NextRun empty, last run years + # old); GetScheduleOptions() reads the live schedule. Fall back for versions without it. + $options = $null + try { $options = $Job.GetScheduleOptions() } catch { } + if (-not $options) { $options = $Job.ScheduleOptions } + $raw = $options.NextRun if (-not $raw) { return $null } # e.g. chained jobs that run "after" another job if ($raw -is [datetime]) { return ConvertTo-IsoTime $raw } - # Older versions return a string formatted with the server's culture, so parse with it - $parsed = [datetime]::MinValue - if ([datetime]::TryParse([string]$raw, [Globalization.CultureInfo]::CurrentCulture, - [Globalization.DateTimeStyles]::None, [ref]$parsed)) { - return ConvertTo-IsoTime $parsed + # A string formatted by VBR ("2026-10-09 12:15:00 AM" on VBR 13, en-CA). Try the server's + # culture first, then the invariant one + foreach ($culture in [Globalization.CultureInfo]::CurrentCulture, [Globalization.CultureInfo]::InvariantCulture) { + $parsed = [datetime]::MinValue + if ([datetime]::TryParse([string]$raw, $culture, [Globalization.DateTimeStyles]::None, [ref]$parsed)) { + return ConvertTo-IsoTime $parsed + } } Write-Log "Couldn't parse next run '$raw' for job '$($Job.Name)'" 'WARN' } catch { diff --git a/collectors/veeam/tests/MockVeeam.ps1 b/collectors/veeam/tests/MockVeeam.ps1 index 230785c..855ba9b 100644 --- a/collectors/veeam/tests/MockVeeam.ps1 +++ b/collectors/veeam/tests/MockVeeam.ps1 @@ -12,12 +12,22 @@ function New-StableGuid([string]$Seed) { } $notSet = Get-Date '1900-01-01' -function New-MockJob($Name, $Type, $NextRun, [bool]$Enabled = $true) { - [pscustomobject]@{ +# As on VBR 13: the ScheduleOptions property is a stale snapshot with an empty NextRun, and the live +# schedule (NextRun as a "2026-10-09 12:15:00 AM" string) comes from GetScheduleOptions(). +# -Continuous: an immediate-mode copy job, whose live NextRun is a stale date in the past. +function New-MockJob($Name, $Type, $NextRun, [bool]$Enabled = $true, [switch]$Continuous) { + $job = [pscustomobject]@{ Id = New-StableGuid $Name; Name = $Name; JobType = $Type IsScheduleEnabled = $Enabled - ScheduleOptions = [pscustomobject]@{ NextRun = $NextRun } + IsContinuous = [bool]$Continuous + ScheduleOptions = [pscustomobject]@{ NextRun = '' } + LiveNextRun = $NextRun } + $job | Add-Member ScriptMethod GetScheduleOptions { [pscustomobject]@{ NextRun = $this.LiveNextRun } } + $job +} +function Format-VbrTime([datetime]$Value) { + $Value.ToString('yyyy-MM-dd h:mm:ss tt', [Globalization.CultureInfo]::InvariantCulture) } function New-MockSession($Job, $Start, $End, $State, $Result, $Reason = '', $Tasks = @(), $LogRecords = @(), $Size = 0) { @@ -40,13 +50,13 @@ function New-MockTask($Name, $Status, $Reason = '') { } $global:MockJobs = @( - (New-MockJob 'File Server – Nightly' 'Backup' $now.Date.AddDays(1).AddHours(22)), - # Older VBR versions return NextRun as a culture-formatted string - (New-MockJob 'SQL01 – Logs' 'Backup' $now.AddMinutes(40).ToString()), + (New-MockJob 'File Server – Nightly' 'Backup' (Format-VbrTime $now.Date.AddDays(1).AddMinutes(15))), + # Some versions return NextRun as a DateTime + (New-MockJob 'SQL01 – Logs' 'Backup' $now.AddMinutes(40)), (New-MockJob 'Offsite Copy' 'BackupSync' $null), (New-MockJob 'Old Exchange' 'Backup' $now.AddDays(1) $false), # VBR 12+ backup copy job (immediate mode, no schedule): the "policy" job Get-VBRJob returns - (New-MockJob 'Cloud Copy' 'SimpleBackupCopyPolicy' $null) + (New-MockJob 'Cloud Copy' 'SimpleBackupCopyPolicy' (Format-VbrTime $now.AddMonths(-6)) -Continuous) ) $fs, $sql, $copy, $old, $cloud = $global:MockJobs