Fix WSB collector against a real server's object model
Checked with Get-WsbCollectorDiagnostics.ps1 output from a Server 2019
Hyper-V host (AAOG-EDM-HVR1):
- WBJob StartTime/EndTime are culture-formatted local-time strings
("10/6/2026 9:00 PM"). Parse them with the current culture: the
[datetime] cast uses the invariant (US) format and would misread
dd/MM dates, e.g. on en-CA/en-GB servers. Confirmed local, not UTC,
against the Microsoft-Windows-Backup event log
- Hyper-V backups nest VMs under a VirtualMachineList item's
SubItemList; failures, names and the items list now come from the
leaf items, falling back to a parent's SubItemFailed count
- Report size from the top-level items' BytesProcessed
- FailureLogPath is set even on success: running backups get no
message, and the log is only cited for warnings/failures
- HRESULTs are strings; conversion tolerates hex and bad values
- Mock rebuilt on the real shapes; tested under en-US and en-GB
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -105,19 +105,33 @@ function Import-Wsb {
|
||||
|
||||
# ---------- 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 }
|
||||
$dt = [datetime]$value
|
||||
if ($dt.Year -le 1900) { return $null } # e.g. EndTime while a backup is running
|
||||
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 job doesn't have the property.
|
||||
# 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 }
|
||||
return [long]$value
|
||||
try { return [long]$value } catch { return $null }
|
||||
}
|
||||
|
||||
function Format-HResult([long]$Value) {
|
||||
@@ -135,15 +149,42 @@ function Get-ItemName($Item) {
|
||||
return 'Item'
|
||||
}
|
||||
|
||||
# Items (volumes, system state, ...) that reported an error
|
||||
# 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) {
|
||||
return @(Get-JobItems $Job | Where-Object {
|
||||
$hr = Get-HResult $_ 'HResult'
|
||||
$state = [string](Get-FirstValue $_ @('State', 'JobState'))
|
||||
($hr -and $hr -ne 0) -or $state -match 'Fail'
|
||||
$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') {
|
||||
@@ -161,6 +202,7 @@ function Get-RunStatus($Job) {
|
||||
}
|
||||
|
||||
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')
|
||||
@@ -178,24 +220,24 @@ function Get-RunMessage($Job, [string]$Status) {
|
||||
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) {
|
||||
switch ($Status) {
|
||||
'success' { return 'Backup completed successfully.' }
|
||||
'running' { return '' }
|
||||
default { return "Backup finished with state '$state'." }
|
||||
}
|
||||
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)
|
||||
@@ -206,7 +248,7 @@ function Get-RunMessage($Job, [string]$Status) {
|
||||
function ConvertTo-Run($Job) {
|
||||
$status = Get-RunStatus $Job
|
||||
$details = [ordered]@{ server = $env:COMPUTERNAME }
|
||||
$items = @(Get-JobItems $Job | ForEach-Object { Get-ItemName $_ })
|
||||
$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 }
|
||||
@@ -223,6 +265,8 @@ function ConvertTo-Run($Job) {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user