<# .SYNOPSIS Reports Veeam Backup & Replication job results to the Backup Dashboard. .DESCRIPTION Runs on the VBR server, normally as a SYSTEM scheduled task every 60 minutes (see Install-VeeamCollector.ps1). For every backup, replica and backup copy job it sends: - the job's next scheduled run (drives the dashboard's Overdue flag), and - every session from the last LookbackHours that the dashboard hasn't already received in its final state. Running sessions are re-sent each time until they finish. Already-reported sessions are tracked in a state file, and the ingest API is idempotent, so a failed or repeated run is harmless: anything not confirmed is retried next time. Compatible with Windows PowerShell 5.1 and PowerShell 7. .PARAMETER ConfigPath JSON config with ApiUrl, ApiKey and Tenant (optional: SourceSystem, LookbackHours). .PARAMETER LookbackHours Overrides the config value. Use 720 once to backfill 30 days of history. .PARAMETER DryRun Print the payloads instead of sending them. Doesn't touch the state or log files. .EXAMPLE .\Send-VeeamBackupStatus.ps1 -DryRun .EXAMPLE .\Send-VeeamBackupStatus.ps1 -LookbackHours 720 #> [CmdletBinding()] param( [string]$ConfigPath = (Join-Path $env:ProgramData 'BackupDashboard\veeam-collector.json'), [int]$LookbackHours = 0, [switch]$DryRun ) $ErrorActionPreference = 'Stop' # Shared collector code: installed next to this script; in the repo, in ..\common $sharedModule = @( (Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'), (Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1') ) | Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $sharedModule) { Write-Host "BackupDashboard.Collector.psm1 not found next to $PSCommandPath; re-run the installer." exit 2 } Import-Module $sharedModule -Force # ---------- Helpers ---------- function Import-Veeam { if (Get-Command Get-VBRJob -ErrorAction SilentlyContinue) { return } # already loaded if (Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { Import-Module Veeam.Backup.PowerShell -WarningAction SilentlyContinue # VBR 11+ } else { Add-PSSnapin VeeamPSSnapin # VBR 10 and earlier } } function Get-NextRun($Job) { try { if (-not $Job.IsScheduleEnabled) { return $null } # 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 } # 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 { Write-Log "Couldn't read schedule for job '$($Job.Name)': $(Get-ErrorText $_)" 'WARN' } return $null } function Get-RunStatus($Session) { if ([string]$Session.State -ne 'Stopped') { return 'running' } switch ([string]$Session.Result) { 'Success' { return 'success' } 'Warning' { return 'warning' } 'Failed' { return 'failed' } # Stopped without a result (e.g. cancelled): surface it rather than hide it default { return 'warning' } } } # Builds a readable summary: the session's reason, any task (VM/object) that didn't succeed, # and warning/error lines from the session log. function Get-RunMessage($Session, [string]$Status, $Tasks) { $lines = New-Object 'System.Collections.Generic.List[string]' try { $reason = [string]$Session.Info.Reason if ($reason.Trim()) { $lines.Add($reason.Trim()) } } catch { } foreach ($task in $Tasks) { $taskStatus = [string]$task.Status if ($taskStatus -and $taskStatus -ne 'Success' -and $taskStatus -ne 'InProgress' -and $taskStatus -ne 'Pending') { $taskReason = '' try { $taskReason = ([string]$task.Info.Reason).Trim() } catch { } $line = "$($task.Name): $taskStatus" if ($taskReason) { $line += " - $taskReason" } $lines.Add($line) } } if ($Status -eq 'warning' -or $Status -eq 'failed') { try { foreach ($record in $Session.Logger.GetLog().UpdatedRecords) { $recordStatus = [string]$record.Status if ($recordStatus -eq 'EWarning' -or $recordStatus -eq 'EFailed') { $title = ([string]$record.Title).Trim() if ($title -and -not $lines.Contains($title)) { $lines.Add($title) } } } } catch { } } if ($lines.Count -eq 0) { switch ($Status) { 'success' { return 'Job completed successfully.' } 'running' { return '' } default { return "Job finished with result '$($Session.Result)'." } } } return ($lines -join "`n") } # VBR 12+ backup copy jobs: Get-VBRJob returns the copy (policy) job, but Get-VBRBackupSession # returns only the sessions of its hidden worker jobs, one per source job, never the policy's own. # Each worker job records its policy in PolicyJobId, so those sessions can be filed under it. function Get-PolicyJobId($Session, [hashtable]$KnownJobIds) { try { $policyId = [string]$Session.GetJob().PolicyJobId if ($policyId -and $KnownJobIds.ContainsKey($policyId)) { return $policyId } } catch { } return '' } function ConvertTo-Run($Session, $Job) { $status = Get-RunStatus $Session $tasks = @() try { $tasks = @($Session.GetTaskSessions()) } catch { } $details = [ordered]@{ job_type = [string]$Job.JobType vbr_server = $env:COMPUTERNAME objects = $tasks.Count } $failedTasks = @($tasks | Where-Object { [string]$_.Status -eq 'Failed' }).Count $warningTasks = @($tasks | Where-Object { [string]$_.Status -eq 'Warning' }).Count if ($failedTasks) { $details.objects_failed = $failedTasks } if ($warningTasks) { $details.objects_warning = $warningTasks } try { if ($Session.IsRetryMode) { $details.retry = $true } } catch { } # Worker session of a backup copy job: say which source job it copied # (worker sessions are named "\") if ($Session.JobId.ToString() -ne $Job.Id.ToString()) { $details.copied_from = ([string]$Session.JobName -split '\\')[-1] } $run = [ordered]@{ external_id = $Session.Id.ToString() status = $status started_at = ConvertTo-IsoTime $Session.CreationTime message = Get-RunMessage $Session $status $tasks details = $details } if ($status -ne 'running') { $finished = ConvertTo-IsoTime $Session.EndTime if ($finished) { $run.finished_at = $finished } # otherwise the API uses receipt time try { $size = [long]$Session.BackupStats.BackupSize if ($size -gt 0) { $run.size_bytes = $size } $dataSize = [long]$Session.BackupStats.DataSize if ($dataSize -gt 0) { $details.data_size_bytes = $dataSize } } catch { } } return $run } # ---------- Main ---------- try { $Config = Initialize-Collector -Name 'veeam' -ConfigPath $ConfigPath -LookbackHours $LookbackHours -DryRun:$DryRun $SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Veeam B&R' } $LookbackHours = $Config.LookbackHours Import-Veeam $since = (Get-Date).AddHours(-$LookbackHours) $jobs = @(Get-VBRJob -WarningAction SilentlyContinue) $jobIds = @{} foreach ($j in $jobs) { $jobIds[$j.Id.ToString()] = $true } # Get-VBRBackupSession returns every session in the database, so fetch once and bucket by job $sessionsByJob = @{} $policyByWorker = @{} # worker job ID -> policy job ID ('' if it isn't a worker of a listed job) foreach ($s in Get-VBRBackupSession -WarningAction SilentlyContinue) { if ($s.CreationTime -lt $since) { continue } $key = $s.JobId.ToString() if (-not $jobIds.ContainsKey($key)) { if (-not $policyByWorker.ContainsKey($key)) { $policyByWorker[$key] = Get-PolicyJobId $s $jobIds } if (-not $policyByWorker[$key]) { continue } $key = $policyByWorker[$key] } if (-not $sessionsByJob.ContainsKey($key)) { $sessionsByJob[$key] = New-Object 'System.Collections.Generic.List[object]' } $sessionsByJob[$key].Add($s) } } catch { Write-Log "Collector setup failed: $(Get-ErrorText $_)" 'ERROR' exit 2 } $failures = 0 foreach ($job in $jobs) { $jobInfo = [ordered]@{ external_id = $job.Id.ToString() name = $job.Name # Always sent (null when unscheduled/disabled) so the dashboard's value tracks Veeam's next_run_at = Get-NextRun $job } $pending = @() if ($sessionsByJob.ContainsKey($jobInfo.external_id)) { $pending = @($sessionsByJob[$jobInfo.external_id] | Where-Object { -not (Test-RunSent ($_.Id.ToString())) } | Sort-Object CreationTime) } try { if ($pending.Count -eq 0) { # Keep next_run_at fresh even when nothing new ran Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo } foreach ($session in $pending) { $run = ConvertTo-Run $session $job Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo -Run $run -SentKey $run.external_id -StartedAt ([datetime]$session.CreationTime) } } catch { $failures++ Write-Log "Failed to report job '$($job.Name)': $(Get-ErrorText $_)" 'ERROR' if (Test-AuthRejected $_) { Write-Log 'API key rejected; check ApiKey in the config. Stopping.' 'ERROR' break } } } $exitCode = Complete-Collector -JobCount $jobs.Count -Failures $failures if ($exitCode) { exit $exitCode }