From 51b97c45569034c8401c7bf5a63f8c170c0c3f9d Mon Sep 17 00:00:00 2001 From: kyle Date: Thu, 8 Oct 2026 09:42:01 -0600 Subject: [PATCH] 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 --- .../veeam/Get-VeeamCollectorDiagnostics.ps1 | 44 ++++++++++++++++--- public/js/app.js | 9 ++-- public/js/format.js | 2 +- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/collectors/veeam/Get-VeeamCollectorDiagnostics.ps1 b/collectors/veeam/Get-VeeamCollectorDiagnostics.ps1 index 199a834..1dae4ec 100644 --- a/collectors/veeam/Get-VeeamCollectorDiagnostics.ps1 +++ b/collectors/veeam/Get-VeeamCollectorDiagnostics.ps1 @@ -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 { "" } - ' {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}: "; continue } + " ${source} ($($opts.GetType().FullName)):" + foreach ($p in $opts.PSObject.Properties) { + $value = try { $p.Value } catch { "" } + $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 diff --git a/public/js/app.js b/public/js/app.js index 5d49221..3efae9c 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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))); diff --git a/public/js/format.js b/public/js/format.js index 92459d0..8a2b177 100644 --- a/public/js/format.js +++ b/public/js/format.js @@ -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()) {