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]>
303 lines
12 KiB
PowerShell
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 }
|