<# .SYNOPSIS Reports Veeam Backup for Microsoft 365 (VB365) job results to the Backup Dashboard. .DESCRIPTION Runs on the VB365 server, normally as a SYSTEM scheduled task every 60 minutes (see Install-Vb365Collector.ps1). For every backup job (and backup copy job) it sends: - the job's next scheduled run, which 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. One VB365 server can protect several Microsoft 365 organizations, so the dashboard company is chosen per job from its organization: OrganizationTenants in the config maps an organization name (e.g. "contoso.onmicrosoft.com") to a company; otherwise Tenant is used; otherwise the organization's own name. 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. .PARAMETER ConfigPath JSON config with ApiUrl and ApiKey (optional: Tenant, OrganizationTenants, 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-Vb365BackupStatus.ps1 -DryRun .EXAMPLE .\Send-Vb365BackupStatus.ps1 -LookbackHours 720 #> [CmdletBinding()] param( [string]$ConfigPath = (Join-Path $env:ProgramData 'BackupDashboard\vb365-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 $Invariant = [Globalization.CultureInfo]::InvariantCulture # ---------- Helpers ---------- function Import-Vb365 { if (Get-Command Get-VBOJob -ErrorAction SilentlyContinue) { return } # already loaded if (Get-Module -ListAvailable -Name Veeam.Archiver.PowerShell) { Import-Module Veeam.Archiver.PowerShell return } # Fall back to the default install path Import-Module (Join-Path $env:ProgramFiles 'Veeam\Backup365\Veeam.Archiver.PowerShell\Veeam.Archiver.PowerShell.psd1') } function Get-Time($Object, [string]$Field) { $value = Get-FirstValue $Object @($Field) if ($null -eq $value) { return $null } $dt = [datetime]$value if ($dt.Year -le 1900) { return $null } # e.g. EndTime while a session is running return $dt } # "1.5 GB", "512 MB", "123456" -> bytes; $null when it can't be read function ConvertTo-Bytes($Value) { if ($null -eq $Value) { return $null } if ($Value -is [ValueType]) { return [long]$Value } $match = [regex]::Match([string]$Value, '^\s*([\d.,]+)\s*([KMGTP]?B)?\s*$', 'IgnoreCase') if (-not $match.Success) { return $null } $number = 0.0 if (-not [double]::TryParse(($match.Groups[1].Value -replace ',', '.'), [Globalization.NumberStyles]::Float, $Invariant, [ref]$number)) { return $null } $power = @{ '' = 0; 'B' = 0; 'KB' = 1; 'MB' = 2; 'GB' = 3; 'TB' = 4; 'PB' = 5 }[$match.Groups[2].Value.ToUpperInvariant()] return [long]($number * [math]::Pow(1024, $power)) } # ---------- Jobs and tenants ---------- function Get-OrganizationName($Job) { $org = Get-FirstValue $Job @('Organization') if ($null -eq $org) { # Copy jobs point at their source backup job $org = Get-FirstValue (Get-FirstValue $Job @('BackupJob')) @('Organization') } if ($org -is [string]) { return $org } $name = Get-FirstValue $org @('Name', 'OfficeName', 'DisplayName') if ($name) { return [string]$name } return $null } function Get-TenantForJob($Job) { $org = Get-OrganizationName $Job if ($org -and $Config.OrganizationTenants) { $mapped = $Config.OrganizationTenants.PSObject.Properties[$org] # case-insensitive lookup if ($mapped -and $mapped.Value) { return [string]$mapped.Value } } if ($Config.Tenant) { return [string]$Config.Tenant } if ($org) { return $org } return $env:COMPUTERNAME } function Get-NextRun($Job) { try { $enabled = Get-FirstValue $Job @('IsEnabled', 'Enabled') if ($enabled -eq $false) { return $null } return ConvertTo-IsoTime (Get-Time $Job 'NextRun') } catch { Write-Log "Couldn't read schedule for job '$($Job.Name)': $(Get-ErrorText $_)" 'WARN' return $null } } # ---------- Sessions ---------- function Get-RunStatus($Session) { $status = [string](Get-FirstValue $Session @('Status', 'Result', 'State')) if ($status -match 'Fail|Error') { return 'failed' } if ($status -match 'Warn') { return 'warning' } if ($status -match 'Succe') { return 'success' } if ($status -match 'Run|Queue|Progress|Start|Wait') { return 'running' } if (-not (Get-Time $Session 'EndTime')) { return 'running' } # Stopped (cancelled), Disconnected, or a state we don't recognise: surface it rather than hide it return 'warning' } function Test-EmptyGuid($Value) { return (-not $Value) -or [string]$Value -eq [string][guid]::Empty } # Warning/error lines from the session log. Each log item's status is a prefix of its Title, e.g. # "[Warning] Item was skipped" (there's no separate status property), so match on that rather than # on words in the text, which would catch e.g. "[Success] Processing site: .../error-reports". function Get-RunMessage($Session, [string]$Status) { if ($Status -eq 'running') { return '' } $lines = New-Object 'System.Collections.Generic.List[string]' $sessionStatus = [string](Get-FirstValue $Session @('Status', 'Result', 'State')) if ($Status -ne 'success') { foreach ($entry in @(Get-FirstValue $Session @('Log', 'Logs') | Where-Object { $_ })) { $title = ([string](Get-FirstValue $entry @('Title', 'Message', 'Text'))).Trim() $prefix = [regex]::Match($title, '^\[(\w+)\]\s*') if ($prefix.Success) { if ($prefix.Groups[1].Value -notmatch 'Warn|Fail|Error') { continue } $title = $title.Substring($prefix.Length) } elseif ([string](Get-FirstValue $entry @('Status', 'Type', 'Severity')) -notmatch 'Warn|Fail|Error') { continue } if ($title -and -not $lines.Contains($title)) { $lines.Add($title) } } } if ($lines.Count -eq 0) { if ($Status -eq 'success') { $lines.Add('Job completed successfully.') } else { $lines.Add("Job finished with status '$sessionStatus'.") } } elseif ($sessionStatus -and $sessionStatus -notmatch 'Fail|Warn') { $lines.Insert(0, "Session status: $sessionStatus") } # A warning/failure VB365 is about to retry; the retry arrives as its own run if ($Status -ne 'success' -and (Get-FirstValue $Session @('JobWillBeRetried')) -eq $true) { $lines.Add('Veeam will retry this job.') } return ($lines -join "`n") } function ConvertTo-Run($Session, $Job, [string]$JobKind) { $status = Get-RunStatus $Session $details = [ordered]@{ vb365_server = $env:COMPUTERNAME; job_kind = $JobKind } $org = Get-OrganizationName $Job if ($org) { $details.organization = $org } $sessionStatus = Get-FirstValue $Session @('Status', 'Result', 'State') if ($sessionStatus) { $details.vb365_status = [string]$sessionStatus } # Retries are separate sessions pointing at the session they retry $mainSession = Get-FirstValue $Session @('MainSessionId') if (-not (Test-EmptyGuid $mainSession)) { $details.retry_of = [string]$mainSession } if ((Get-FirstValue $Session @('JobWillBeRetried')) -eq $true) { $details.will_retry = $true } $run = [ordered]@{ external_id = [string](Get-FirstValue $Session @('Id', 'SessionId')) status = $status started_at = ConvertTo-IsoTime (Get-Time $Session 'CreationTime') message = Get-RunMessage $Session $status details = $details } if ($status -ne 'running') { $finished = ConvertTo-IsoTime (Get-Time $Session 'EndTime') if ($finished) { $run.finished_at = $finished } # otherwise the API uses receipt time $stats = Get-FirstValue $Session @('Statistics') $processed = Get-FirstValue $stats @('ProcessedObjects') if ($null -ne $processed) { $details.processed_objects = $processed } $transferred = ConvertTo-Bytes (Get-FirstValue $stats @('TransferredData', 'TransferredDataSize')) if ($transferred -gt 0) { $run.size_bytes = $transferred } } return $run } # ---------- Main ---------- try { $Config = Initialize-Collector -Name 'vb365' -ConfigPath $ConfigPath -LookbackHours $LookbackHours -DryRun:$DryRun -RequiredKeys 'ApiUrl', 'ApiKey' $SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Veeam Backup for M365' } $LookbackHours = $Config.LookbackHours Import-Vb365 $since = (Get-Date).AddHours(-$LookbackHours) # (job, kind, sessions) for backup jobs, plus backup copy jobs where the cmdlets exist $work = New-Object 'System.Collections.Generic.List[object]' foreach ($job in @(Get-VBOJob)) { $sessions = @(Get-VBOJobSession -Job $job | Where-Object { (Get-Time $_ 'CreationTime') -ge $since }) $work.Add([pscustomobject]@{ Job = $job; Kind = 'backup'; Sessions = $sessions }) } if ((Get-Command Get-VBOCopyJob -ErrorAction SilentlyContinue) -and (Get-Command Get-VBOCopyJobSession -ErrorAction SilentlyContinue)) { $copyJobs = @(Get-VBOCopyJob) if ($copyJobs.Count) { # Fetch once and match by job ID, since the per-job parameter name isn't consistent $copySessions = @(Get-VBOCopyJobSession | Where-Object { (Get-Time $_ 'CreationTime') -ge $since }) foreach ($job in $copyJobs) { $id = [string]$job.Id $work.Add([pscustomobject]@{ Job = $job; Kind = 'copy' Sessions = @($copySessions | Where-Object { [string](Get-FirstValue $_ @('JobId')) -eq $id }) }) } } } } catch { Write-Log "Collector setup failed: $(Get-ErrorText $_)" 'ERROR' exit 2 } $failures = 0 foreach ($item in $work) { $job = $item.Job $tenant = Get-TenantForJob $job $jobInfo = [ordered]@{ external_id = [string]$job.Id name = [string]$job.Name # Always sent (null when disabled/unscheduled) so the dashboard's value tracks VB365's next_run_at = Get-NextRun $job } $pending = @($item.Sessions | Where-Object { $_ -and -not (Test-RunSent ([string](Get-FirstValue $_ @('Id', 'SessionId')))) } | Sort-Object { Get-Time $_ 'CreationTime' }) try { if ($pending.Count -eq 0) { # Keep next_run_at fresh even when nothing new ran Send-JobReport -Tenant $tenant -SourceSystem $SourceSystem -Job $jobInfo } foreach ($session in $pending) { $run = ConvertTo-Run $session $job $item.Kind Send-JobReport -Tenant $tenant -SourceSystem $SourceSystem -Job $jobInfo -Run $run -SentKey $run.external_id -StartedAt ((Get-Time $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 $work.Count -Failures $failures if ($exitCode) { exit $exitCode }