<# .SYNOPSIS Reports Windows Server Backup (WSB) results to the Backup Dashboard. .DESCRIPTION Runs on a server with the Windows Server Backup feature, normally as a SYSTEM scheduled task every 60 minutes (see Install-WsbCollector.ps1). The server appears on the dashboard as one job. Each run sends: - the next scheduled backup (Get-WBSummary's NextBackupTime), which drives the dashboard's Overdue flag, and - every backup from the last LookbackHours that the dashboard hasn't already received in its final state. A backup in progress is sent as Running until it finishes. WSB jobs have no ID, so a run is identified by its start time. Already-reported runs 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. Written for Windows PowerShell 5.1, which the WindowsServerBackup module targets. .PARAMETER ConfigPath JSON config with ApiUrl, ApiKey and Tenant (optional: JobName, 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-WsbBackupStatus.ps1 -DryRun .EXAMPLE .\Send-WsbBackupStatus.ps1 -LookbackHours 720 #> [CmdletBinding()] param( [string]$ConfigPath = (Join-Path $env:ProgramData 'BackupDashboard\wsb-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 # Get-WBJob -Previous accepts at most 100. That covers a 30-day backfill at up to 3 backups a day; # servers backing up more often only get their latest 100 runs backfilled. $MaxPreviousJobs = 100 # ---------- Helpers ---------- function Import-Wsb { if (Get-Command Get-WBJob -ErrorAction SilentlyContinue) { return } # already loaded # Present only when the Windows Server Backup feature (with its command-line tools) is installed Import-Module WindowsServerBackup } # ---------- Jobs ---------- # WBJob times are strings in local time, to the minute, formatted with the current culture # ("10/6/2026 9:00 PM" on en-US). Parse with that same culture: PowerShell's [datetime] cast would # use the invariant (US) format and misread dd/MM dates. Empty while not set (e.g. EndTime of a # running backup). function Get-JobTime($Job, [string]$Field) { $value = Get-FirstValue $Job @($Field) if ($null -eq $value) { return $null } if ($value -is [datetime]) { $dt = $value } else { $dt = [datetime]::MinValue if (-not [datetime]::TryParse([string]$value, [Globalization.CultureInfo]::CurrentCulture, [Globalization.DateTimeStyles]::AssumeLocal, [ref]$dt)) { Write-Log "Couldn't parse $Field '$value'" 'WARN' return $null } } if ($dt.Year -le 1900) { return $null } return $dt } # HRESULTs as numbers; 0 = OK. $null when the object doesn't have the property. WSB returns them # as strings ("0"); PowerShell's conversion also accepts hex ("0x80042302") and negative decimals. function Get-HResult($Object, [string]$Field) { $value = Get-FirstValue $Object @($Field) if ($null -eq $value) { return $null } try { return [long]$value } catch { return $null } } function Format-HResult([long]$Value) { if ($Value -lt 0) { $Value += 4294967296 } # show as the familiar unsigned 0x8XXXXXXX return '0x{0:X8}' -f $Value } function Get-JobItems($Job) { return @(Get-FirstValue $Job @('JobItems', 'Items') | Where-Object { $_ }) } function Get-ItemName($Item) { $name = Get-FirstValue $Item @('Name', 'VolumeLabel', 'MountPath', 'Path', 'Type') if ($name) { return [string]$name } return 'Item' } # The things actually backed up. Items can nest: a Hyper-V backup has one VirtualMachineList item # whose SubItemList holds each VM (and "Host Component"). function Get-LeafItems($Items) { foreach ($item in $Items) { $subItems = @(Get-FirstValue $item @('SubItemList') | Where-Object { $_ }) if ($subItems.Count) { Get-LeafItems $subItems } else { $item } } } function Test-ItemFailed($Item) { $hr = Get-HResult $Item 'HResult' $state = [string](Get-FirstValue $Item @('State', 'JobState')) return ($hr -and $hr -ne 0) -or $state -match 'Fail' } # Items that reported an error: failed leaves, or a parent that counts failed sub-items without # saying which function Get-FailedItems($Job) { $items = Get-JobItems $Job $failed = @(Get-LeafItems $items | Where-Object { Test-ItemFailed $_ }) if ($failed.Count) { return $failed } return @($items | Where-Object { (Test-ItemFailed $_) -or ((Get-FirstValue $_ @('SubItemFailed')) -and [long](Get-FirstValue $_ @('SubItemFailed')) -gt 0) }) } # Bytes processed, from the top-level items (sub-items are already counted in their parent) function Get-BytesProcessed($Job) { $sum = [long]0 foreach ($item in Get-JobItems $Job) { $value = Get-FirstValue $item @('BytesProcessed') if ($value) { $sum += [long]$value } } return $sum } function Get-RunStatus($Job) { $state = [string](Get-FirstValue $Job @('JobState', 'State')) if (-not (Get-JobTime $Job 'EndTime') -and $state -notmatch 'Complet|Fail|Cancel|Abort|Stop|Succe') { return 'running' } if ($state -match 'Fail') { return 'failed' } if ($state -match 'Cancel|Abort|Stop') { return 'warning' } $hr = Get-HResult $Job 'HResult' if ($hr -and $hr -ne 0) { return 'failed' } # Overall success but something underneath went wrong (e.g. a volume or files skipped) $detailed = Get-HResult $Job 'DetailedHResult' if (($detailed -and $detailed -ne 0) -or (Get-FailedItems $Job).Count) { return 'warning' } if (-not $state -or $state -match 'Complet|Succe') { return 'success' } return 'warning' # finished in a state we don't recognise: surface it rather than hide it } function Get-RunMessage($Job, [string]$Status) { if ($Status -eq 'running') { return '' } $lines = New-Object 'System.Collections.Generic.List[string]' $state = [string](Get-FirstValue $Job @('JobState', 'State')) $description = Get-FirstValue $Job @('ErrorDescription', 'DetailedMessage') if ($description) { $lines.Add(([string]$description).Trim()) } $hr = Get-HResult $Job 'HResult' $detailed = Get-HResult $Job 'DetailedHResult' if (($hr -and $hr -ne 0) -or ($detailed -and $detailed -ne 0)) { $codes = @() if ($hr -and $hr -ne 0) { $codes += "error $(Format-HResult $hr)" } if ($detailed -and $detailed -ne 0) { $codes += "detailed error $(Format-HResult $detailed)" } $lines.Add('Windows Server Backup reported ' + ($codes -join ', ') + '.') } foreach ($item in Get-FailedItems $Job) { $itemError = Get-FirstValue $item @('ErrorDescription', 'DetailedMessage') $itemHr = Get-HResult $item 'HResult' $subFailed = Get-FirstValue $item @('SubItemFailed') $line = "$(Get-ItemName $item) - " # names are often volumes like "D:", so no colon if ($itemError) { $line += ([string]$itemError).Trim() } elseif ($itemHr) { $line += "error $(Format-HResult $itemHr)" } elseif ($subFailed -and [long]$subFailed -gt 0) { $line += "$subFailed sub-item(s) failed" } else { $line += [string](Get-FirstValue $item @('State', 'JobState')) } $lines.Add($line) } # WSB fills in FailureLogPath even for successful backups, so only point at it when relevant if ($Status -ne 'success') { $failureLog = Get-FirstValue $Job @('FailureLogPath') if ($failureLog) { $lines.Add("Failure log: $failureLog") } } if ($lines.Count -eq 0) { if ($Status -eq 'success') { return 'Backup completed successfully.' } return "Backup finished with state '$state'." } # WSB reports "Completed" even for a backup that failed with an error code, so only mention # the state when it says something else (e.g. cancelled) if ($state -and $Status -ne 'success' -and $state -notmatch 'Complet') { $lines.Insert(0, "Job state: $state") } return ($lines -join "`n") } function ConvertTo-Run($Job) { $status = Get-RunStatus $Job $details = [ordered]@{ server = $env:COMPUTERNAME } $items = @(Get-LeafItems (Get-JobItems $Job) | ForEach-Object { Get-ItemName $_ }) if ($items.Count) { $details.items = $items } $state = Get-FirstValue $Job @('JobState', 'State') if ($state) { $details.wsb_state = [string]$state } $version = Get-FirstValue $Job @('VersionId') if ($version) { $details.version_id = [string]$version } # No external_id: WSB jobs have no ID, so the API keys the run on started_at $run = [ordered]@{ status = $status started_at = ConvertTo-IsoTime (Get-JobTime $Job 'StartTime') message = Get-RunMessage $Job $status details = $details } if ($status -ne 'running') { $finished = ConvertTo-IsoTime (Get-JobTime $Job 'EndTime') if ($finished) { $run.finished_at = $finished } # otherwise the API uses receipt time $bytes = Get-BytesProcessed $Job if ($bytes -gt 0) { $run.size_bytes = $bytes } } return $run } function Test-IsBackupJob($Job) { # Get-WBJob also lists recoveries; only backups belong on the dashboard $type = [string](Get-FirstValue $Job @('JobType', 'Type')) return (-not $type) -or $type -match 'Backup' } function Get-NextRun { try { $summary = Get-WBSummary return ConvertTo-IsoTime (Get-FirstValue $summary @('NextBackupTime')) } catch { Write-Log "Couldn't read the backup summary: $(Get-ErrorText $_)" 'WARN' return $null } } # ---------- Main ---------- try { $Config = Initialize-Collector -Name 'wsb' -ConfigPath $ConfigPath -LookbackHours $LookbackHours -DryRun:$DryRun $SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Windows Server Backup' } $JobName = if ($Config.JobName) { [string]$Config.JobName } else { "$env:COMPUTERNAME - Windows Server Backup" } $LookbackHours = $Config.LookbackHours Import-Wsb $since = (Get-Date).AddHours(-$LookbackHours) # Get-WBJob with no arguments returns the job in progress (if any); -Previous lists finished ones $jobs = @(@(Get-WBJob) + @(Get-WBJob -Previous $MaxPreviousJobs) | Where-Object { $_ -and (Test-IsBackupJob $_) -and (Get-JobTime $_ 'StartTime') }) $byStart = [ordered]@{} foreach ($job in $jobs) { $start = Get-JobTime $job 'StartTime' if ($start -lt $since) { continue } $byStart[(ConvertTo-IsoTime $start)] = $job # a job in both lists: the finished entry (later) wins } $pending = @($byStart.Keys | Sort-Object | Where-Object { -not (Test-RunSent ($_)) } | ForEach-Object { $byStart[$_] }) $jobInfo = [ordered]@{ # Stable per server, so renaming the dashboard job via JobName doesn't create a new one external_id = "wsb:$($env:COMPUTERNAME.ToLowerInvariant())" name = $JobName # Always sent (null when unscheduled) so the dashboard's value tracks WSB's next_run_at = Get-NextRun } } catch { Write-Log "Collector setup failed: $(Get-ErrorText $_)" 'ERROR' exit 2 } $failures = 0 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 ($job in $pending) { $run = ConvertTo-Run $job Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo -Run $run -SentKey $run.started_at -StartedAt ((Get-JobTime $job 'StartTime')) } } catch { $failures++ Write-Log "Failed to report: $(Get-ErrorText $_)" 'ERROR' } $exitCode = Complete-Collector -Failures $failures if ($exitCode) { exit $exitCode }