Files
kyleandClaude Opus 5.5 8d0e6dca71 Move code shared by the collectors into one module
The Veeam, MARS, WSB and VB365 collectors each carried identical copies
of logging, ISO time conversion, property lookup, posting to the API,
config loading, TLS setup, the sent-runs state file and the 401 check,
and their installers repeated the same key prompt, locked-down config,
file copy and scheduled-task registration. These now live in
collectors/common/BackupDashboard.Collector.psm1:

- Initialize-Collector / Test-RunSent / Send-JobReport /
  Test-AuthRejected / Complete-Collector for collector runs
- Write-Log, Get-ErrorText, ConvertTo-IsoTime, Get-FirstValue
- Install-CollectorTask for the shared half of each installer

Collectors find the module next to themselves (installed) or in
..\common (repo); installers copy it into Program Files beside the
collector. The diagnostic scripts stay standalone single files.

No behavior change: a harness ran each collector against its mock
(12 scenarios: dry run, two real runs against a stub API, state file,
logs, exit codes, rejected key, en-GB, backfill) before and after, and
all 84 outputs are identical. Installers, run with stand-ins for the
Windows-only commands, write identical configs and tasks; the only
differences are the module being copied and the summary line naming
the shell's path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 11:09:01 -06:00

303 lines
12 KiB
PowerShell

<#
.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 }