Diagnose Veeam schedules; show "Not scheduled" for no next run

Veeam jobs on a VBR 13 server show no next run on the dashboard. The
diagnostics script gains a section dumping each job's schedule
properties (ScheduleOptions, GetScheduleOptions(),
Get-VBRJobScheduleOptions) and the current culture, to find where VBR 13
keeps the next run.

The dashboard showed "Never" for a missing next run, which reads as
"this job will never run"; it now says "Not scheduled" (e.g. immediate
mode copy jobs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-08 09:42:01 -06:00
co-authored by Claude Opus 5.5
parent 555e928e01
commit 51b97c4556
3 changed files with 45 additions and 10 deletions
@@ -9,6 +9,7 @@
2. sessions whose JobId matches no job (the runs that are going missing)
3. for jobs with no matching sessions, their type/link-related properties and methods
4. what other job cmdlets (e.g. Get-VBRBackupCopyJob) return
5. each job's schedule properties, which the collector reads for the next run
Nothing is changed in Veeam or the dashboard. Output is also saved to a text file.
.EXAMPLE
@@ -42,14 +43,14 @@ function Format-Value($Value) {
# Property and method names matching $LinkPattern, with property values. Every read is guarded:
# some Veeam properties throw when accessed.
function Show-LinkMembers($Object) {
foreach ($p in $Object.PSObject.Properties | Where-Object { $_.Name -match $LinkPattern }) {
function Show-LinkMembers($Object, [string]$Pattern = $LinkPattern, [string]$Indent = ' ') {
foreach ($p in $Object.PSObject.Properties | Where-Object { $_.Name -match $Pattern }) {
$value = try { Format-Value $p.Value } catch { "<error: $($_.Exception.Message)>" }
' {0} = {1}' -f $p.Name, $value
'{0}{1} = {2}' -f $Indent, $p.Name, $value
}
$methods = @($Object | Get-Member -MemberType Methods -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match $LinkPattern } | ForEach-Object Name)
if ($methods) { ' methods: ' + ($methods -join ', ') }
Where-Object { $_.Name -match $Pattern } | ForEach-Object Name)
if ($methods) { $Indent + 'methods: ' + ($methods -join ', ') }
}
function Get-Prop($Object, [string]$Name) {
@@ -147,6 +148,39 @@ $report = & {
" ${cmd}: error: $($_.Exception.Message)"
}
}
''
# The collector uses IsScheduleEnabled and ScheduleOptions.NextRun; show what else is there in
# case this version keeps the next run somewhere different
"== 5. Schedules (culture: $([Globalization.CultureInfo]::CurrentCulture.Name))"
$SchedulePattern = 'Schedul|Next|Latest|Run|Enabled|Daily|Periodic|Monthly|Continuous|After|Option'
foreach ($j in $jobs) {
" $($j.Name) [$(Get-Prop $j 'JobType')]"
Show-LinkMembers $j $SchedulePattern ' '
foreach ($source in 'ScheduleOptions', 'GetScheduleOptions()', 'Get-VBRJobScheduleOptions') {
try {
$opts = switch ($source) {
'ScheduleOptions' { $j.ScheduleOptions }
'GetScheduleOptions()' { $j.GetScheduleOptions() }
'Get-VBRJobScheduleOptions' { Get-VBRJobScheduleOptions -Job $j -WarningAction SilentlyContinue }
}
if ($null -eq $opts) { " ${source}: <null>"; continue }
" ${source} ($($opts.GetType().FullName)):"
foreach ($p in $opts.PSObject.Properties) {
$value = try { $p.Value } catch { "<error: $($_.Exception.Message)>" }
$type = if ($null -eq $value) { 'null' } else { $value.GetType().Name }
if ($value -is [string] -or $value -is [ValueType] -or $null -eq $value) {
' {0} ({1}) = {2}' -f $p.Name, $type, (Format-Value $value)
} else {
' {0} ({1})' -f $p.Name, $type
}
}
} catch {
" ${source}: not available: $($_.Exception.Message)"
}
}
''
}
} | Out-String -Width 300
$report
+5 -4
View File
@@ -95,8 +95,8 @@ function statusBadge(status, { asButton = false, opensDialog = false, dataset =
const runningTitle = (since) => (since ? `Running since ${formatDateTime(since)}` : null);
function timeCell(date, extra = null) {
if (!date) return el('td', {}, el('span', { class: 'muted' }, 'Never'));
function timeCell(date, { extra = null, emptyLabel = 'Never' } = {}) {
if (!date) return el('td', {}, el('span', { class: 'muted' }, emptyLabel));
return el('td', {},
el('time', { class: 'time', datetime: date.toISOString(), title: date.toString() },
el('span', {}, formatDateTime(date), extra),
@@ -112,7 +112,8 @@ function renderRow(job) {
asButton: true, opensDialog: true, dataset: { jobId: job.id }, title: runningTitle(job.runningSince),
})),
timeCell(job.lastRun),
timeCell(job.nextRun, overdueTag));
// No next run: unscheduled, or e.g. a Veeam copy job in immediate mode
timeCell(job.nextRun, { extra: overdueTag, emptyLabel: 'Not scheduled' }));
}
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
@@ -304,7 +305,7 @@ function renderSummarySection() {
// While a job is running, its "last run" is the previous one — say how that went
status === 'running' && lastStatus ? detailRow('Previous result', STATUS[lastStatus].label) : [],
detailRow('Last finished', formatDateTime(detail ? parseDate(detail.last_run_at) : job.lastRun)),
detailRow('Next scheduled', formatDateTime(detail ? parseDate(detail.next_run_at) : job.nextRun)),
detailRow('Next scheduled', formatDateTime(detail ? parseDate(detail.next_run_at) : job.nextRun, 'Not scheduled')),
detailRow('Duration', formatDuration(detail?.duration_seconds)),
detailRow('Size', formatBytes(detail?.size_bytes)),
detailRow('Source', detail?.source_system)));
+1 -1
View File
@@ -13,7 +13,7 @@ export function parseDate(value) {
return Number.isNaN(d.getTime()) ? null : d;
}
export const formatDateTime = (d) => (d ? dateTimeFmt.format(d) : '—');
export const formatDateTime = (d, emptyLabel = '—') => (d ? dateTimeFmt.format(d) : emptyLabel);
export const formatTime = (d) => timeFmt.format(d);
export function formatRelative(d, now = Date.now()) {