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 <[email protected]>
This commit is contained in:
2026-10-08 09:53:55 -06:00
co-authored by Claude Opus 5.5
parent 51b97c4556
commit dd3e2fe885
3 changed files with 35 additions and 14 deletions
+1 -1
View File
@@ -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 | | 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** | | 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` | | 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 | | Message | Session reason, each VM or object that didn't succeed, and warning/error lines from the session log |
| Size | `BackupStats.BackupSize` | | Size | `BackupStats.BackupSize` |
+17 -6
View File
@@ -85,14 +85,25 @@ function Import-Veeam {
function Get-NextRun($Job) { function Get-NextRun($Job) {
try { try {
if (-not $Job.IsScheduleEnabled) { return $null } 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 (-not $raw) { return $null } # e.g. chained jobs that run "after" another job
if ($raw -is [datetime]) { return ConvertTo-IsoTime $raw } if ($raw -is [datetime]) { return ConvertTo-IsoTime $raw }
# Older versions return a string formatted with the server's culture, so parse with it # A string formatted by VBR ("2026-10-09 12:15:00 AM" on VBR 13, en-CA). Try the server's
$parsed = [datetime]::MinValue # culture first, then the invariant one
if ([datetime]::TryParse([string]$raw, [Globalization.CultureInfo]::CurrentCulture, foreach ($culture in [Globalization.CultureInfo]::CurrentCulture, [Globalization.CultureInfo]::InvariantCulture) {
[Globalization.DateTimeStyles]::None, [ref]$parsed)) { $parsed = [datetime]::MinValue
return ConvertTo-IsoTime $parsed 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' Write-Log "Couldn't parse next run '$raw' for job '$($Job.Name)'" 'WARN'
} catch { } catch {
+17 -7
View File
@@ -12,12 +12,22 @@ function New-StableGuid([string]$Seed) {
} }
$notSet = Get-Date '1900-01-01' $notSet = Get-Date '1900-01-01'
function New-MockJob($Name, $Type, $NextRun, [bool]$Enabled = $true) { # As on VBR 13: the ScheduleOptions property is a stale snapshot with an empty NextRun, and the live
[pscustomobject]@{ # 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 Id = New-StableGuid $Name; Name = $Name; JobType = $Type
IsScheduleEnabled = $Enabled 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) { 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 = @( $global:MockJobs = @(
(New-MockJob 'File Server – Nightly' 'Backup' $now.Date.AddDays(1).AddHours(22)), (New-MockJob 'File Server – Nightly' 'Backup' (Format-VbrTime $now.Date.AddDays(1).AddMinutes(15))),
# Older VBR versions return NextRun as a culture-formatted string # Some versions return NextRun as a DateTime
(New-MockJob 'SQL01 – Logs' 'Backup' $now.AddMinutes(40).ToString()), (New-MockJob 'SQL01 – Logs' 'Backup' $now.AddMinutes(40)),
(New-MockJob 'Offsite Copy' 'BackupSync' $null), (New-MockJob 'Offsite Copy' 'BackupSync' $null),
(New-MockJob 'Old Exchange' 'Backup' $now.AddDays(1) $false), (New-MockJob 'Old Exchange' 'Backup' $now.AddDays(1) $false),
# VBR 12+ backup copy job (immediate mode, no schedule): the "policy" job Get-VBRJob returns # 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 $fs, $sql, $copy, $old, $cloud = $global:MockJobs