commit c67ec238f4ee59fd24b08727166de7b81a332e03 Author: smolkik-code Date: Thu Jul 16 08:58:49 2026 +0700 Extend Veeam job monitoring diff --git a/Monitor-VeeamJobs.ps1 b/Monitor-VeeamJobs.ps1 new file mode 100644 index 0000000..c872523 --- /dev/null +++ b/Monitor-VeeamJobs.ps1 @@ -0,0 +1,583 @@ +<# +Monitors Veeam Backup & Replication jobs for Zabbix. + +Default mode prints one JSON object for a Zabbix Agent 2 UserParameter: + UserParameter=veeam.scripts.monitor,powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Program Files\Zabbix Agent 2\scripts\Monitor-VeeamJobs.ps1" + +Sender mode pushes the same metrics with zabbix_sender, useful for Task Scheduler: + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Program Files\Zabbix Agent 2\scripts\Monitor-VeeamJobs.ps1" -Mode Sender +#> + +[CmdletBinding()] +param( + [ValidateSet('Json', 'Sender')] + [string]$Mode = 'Json', + + [string]$ScriptDir = $PSScriptRoot, + [string]$JobExclusionFilterFile, + + [string]$ZabbixSender = 'C:\Program Files\Zabbix Agent 2\zabbix_sender.exe', + [string]$ZabbixConfig = 'C:\Program Files\Zabbix Agent 2\zabbix_agent2.conf', + [string]$ZabbixServer, + [string]$ZabbixHost +) + +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($ScriptDir)) { + $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +} + +if ([string]::IsNullOrWhiteSpace($JobExclusionFilterFile)) { + $JobExclusionFilterFile = Join-Path $ScriptDir 'NotifyOfDisabledJobs_EXCLUSIONS.IN' +} + +function Import-VeeamPowerShell { + Add-PSSnapin -Name VeeamPSSnapIn -ErrorAction SilentlyContinue + + if (-not (Get-Command Get-VBRJob -ErrorAction SilentlyContinue) -and + -not (Get-Command Get-VBRComputerBackupJob -ErrorAction SilentlyContinue)) { + Import-Module Veeam.Backup.PowerShell -ErrorAction SilentlyContinue + } + + if (-not (Get-Command Get-VBRJob -ErrorAction SilentlyContinue) -and + -not (Get-Command Get-VBRComputerBackupJob -ErrorAction SilentlyContinue)) { + throw 'Veeam PowerShell commands are not available. Run the script on the Veeam server or install/load the Veeam PowerShell module.' + } +} + +function Get-ZabbixAgentConfigValue { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if (-not (Test-Path -Path $Path)) { + return $null + } + + $line = Get-Content -Path $Path | + Where-Object { $_ -match "^\s*$([regex]::Escape($Name))\s*=" -and $_ -notmatch '^\s*#' } | + Select-Object -Last 1 + + if (-not $line) { + return $null + } + + return (($line -split '=', 2)[1]).Trim() +} + +function Get-ZabbixServerEndpoints { + param( + [Parameter(Mandatory = $true)] + [string]$Servers + ) + + $endpoints = @() + + foreach ($serverEndpoint in ($Servers -split ',')) { + $serverEndpoint = $serverEndpoint.Trim() + + if ([string]::IsNullOrWhiteSpace($serverEndpoint)) { + continue + } + + $serverHost = $serverEndpoint + $serverPort = $null + + if ($serverEndpoint -match '^(?.+):(?\d+)$') { + $serverHost = $Matches.host + $serverPort = $Matches.port + } + + $endpoints += [pscustomobject]@{ + Host = $serverHost + Port = $serverPort + } + } + + return $endpoints +} + +function Get-ObjectValue { + param( + [Parameter(Mandatory = $true)] + [object]$InputObject, + + [Parameter(Mandatory = $true)] + [string[]]$Paths + ) + + foreach ($path in $Paths) { + $current = $InputObject + + foreach ($part in ($path -split '\.')) { + if ($null -eq $current) { + break + } + + $property = $current.PSObject.Properties[$part] + + if ($null -eq $property) { + $current = $null + break + } + + $current = $property.Value + } + + if ($null -ne $current) { + return $current + } + } + + return $null +} + +function Get-VeeamJobName { + param( + [Parameter(Mandatory = $true)] + [object]$Job + ) + + $name = Get-ObjectValue -InputObject $Job -Paths @('Name', 'Info.Name') + + if ([string]::IsNullOrWhiteSpace($name)) { + return '' + } + + return $name +} + +function ConvertTo-ZabbixDateTime { + param( + [object]$Value + ) + + if ($null -eq $Value) { + return $null + } + + if ($Value -is [datetime]) { + if ($Value -eq [datetime]::MinValue -or $Value -eq [datetime]::MaxValue) { + return $null + } + + return $Value.ToString('yyyy-MM-dd HH:mm:ss') + } + + $text = [string]$Value + + if ([string]::IsNullOrWhiteSpace($text)) { + return $null + } + + return $text +} + +function Get-VeeamJobScheduleEnabled { + param( + [Parameter(Mandatory = $true)] + [object]$Job + ) + + return Get-ObjectValue -InputObject $Job -Paths @( + 'Info.IsScheduleEnabled', + 'IsScheduleEnabled', + 'ScheduleEnabled', + 'ScheduleOptions.Enabled', + 'JobScheduleOptions.Enabled' + ) +} + +function Get-VeeamJobLastResult { + param( + [Parameter(Mandatory = $true)] + [object]$Job + ) + + return Get-ObjectValue -InputObject $Job -Paths @( + 'Info.LastResult', + 'LastResult', + 'Info.LatestRunResult', + 'LatestRunResult' + ) +} + +function Get-VeeamJobLastState { + param( + [Parameter(Mandatory = $true)] + [object]$Job + ) + + return Get-ObjectValue -InputObject $Job -Paths @( + 'Info.LastState', + 'LastState', + 'Info.LatestRunState', + 'LatestRunState' + ) +} + +function Get-VeeamJobLastRun { + param( + [Parameter(Mandatory = $true)] + [object]$Job + ) + + $value = Get-ObjectValue -InputObject $Job -Paths @( + 'Info.LatestRunLocal', + 'LatestRunLocal', + 'Info.LastRunLocal', + 'LastRunLocal', + 'Info.LastRun', + 'LastRun', + 'Info.LastStartTime', + 'LastStartTime', + 'Info.LastEndTime', + 'LastEndTime' + ) + + return ConvertTo-ZabbixDateTime -Value $value +} + +function Get-VeeamJobNextRun { + param( + [Parameter(Mandatory = $true)] + [object]$Job + ) + + $value = Get-ObjectValue -InputObject $Job -Paths @( + 'Info.NextRun', + 'NextRun', + 'ScheduleOptions.NextRun', + 'JobScheduleOptions.NextRun', + 'ScheduleOptions.NextRunLocal', + 'JobScheduleOptions.NextRunLocal' + ) + + return ConvertTo-ZabbixDateTime -Value $value +} + +function Get-VeeamBackupJobName { + param( + [Parameter(Mandatory = $true)] + [object]$Backup + ) + + return Get-ObjectValue -InputObject $Backup -Paths @( + 'JobName', + 'Info.JobName', + 'Job.Name', + 'Name' + ) +} + +function Get-VeeamRestorePointCounts { + $counts = @{} + + if (-not (Get-Command Get-VBRBackup -ErrorAction SilentlyContinue) -or + -not (Get-Command Get-VBRRestorePoint -ErrorAction SilentlyContinue)) { + return $counts + } + + foreach ($backup in @(Get-VBRBackup -WarningAction SilentlyContinue)) { + $jobName = Get-VeeamBackupJobName -Backup $backup + + if ([string]::IsNullOrWhiteSpace($jobName)) { + continue + } + + try { + $restorePointCount = @(Get-VBRRestorePoint -Backup $backup -WarningAction SilentlyContinue).Count + + if (-not $counts.ContainsKey($jobName)) { + $counts[$jobName] = 0 + } + + $counts[$jobName] += $restorePointCount + } + catch { + if (-not $counts.ContainsKey($jobName)) { + $counts[$jobName] = $null + } + } + } + + return $counts +} + +function Format-ZabbixTextValue { + param( + [object[]]$Values + ) + + $text = @($Values | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Sort-Object -Unique) -join '; ' + + if ([string]::IsNullOrWhiteSpace($text)) { + return '-' + } + + return $text +} + +function Format-VeeamJobDetailsTable { + param( + [Parameter(Mandatory = $true)] + [object[]]$JobDetails + ) + + if ($JobDetails.Count -eq 0) { + return '-' + } + + $lines = @( + 'Name | Enabled | Last run | Next run | Result | Restore points' + '--- | --- | --- | --- | --- | ---' + ) + + foreach ($job in ($JobDetails | Sort-Object -Property name)) { + $enabled = if ($job.enabled -eq $true) { 'yes' } elseif ($job.enabled -eq $false) { 'no' } else { 'unknown' } + $lastRun = if ([string]::IsNullOrWhiteSpace($job.last_run)) { '-' } else { $job.last_run } + $nextRun = if ([string]::IsNullOrWhiteSpace($job.next_run)) { '-' } else { $job.next_run } + $result = if ([string]::IsNullOrWhiteSpace($job.last_result)) { $job.last_state } else { $job.last_result } + $restorePoints = if ($null -eq $job.restore_points) { '-' } else { $job.restore_points } + + $lines += ('{0} | {1} | {2} | {3} | {4} | {5}' -f $job.name, $enabled, $lastRun, $nextRun, $result, $restorePoints) + } + + return ($lines -join [Environment]::NewLine) +} + +function Format-ZabbixSenderValue { + param( + [object]$Value + ) + + if ($null -eq $Value) { + return '-' + } + + $text = [string]$Value + $text = $text -replace "(`r`n|`n|`r)", ' / ' + + if ([string]::IsNullOrWhiteSpace($text)) { + return '-' + } + + return $text +} + +function Get-VeeamJobs { + Import-VeeamPowerShell + + $jobs = @() + + if (Get-Command Get-VBRJob -ErrorAction SilentlyContinue) { + $jobs += @(Get-VBRJob -WarningAction SilentlyContinue) + } + + if (Get-Command Get-VBRComputerBackupJob -ErrorAction SilentlyContinue) { + $jobs += @(Get-VBRComputerBackupJob -WarningAction SilentlyContinue) + } + + return $jobs +} + +function Get-VeeamJobMetrics { + $allJobs = @(Get-VeeamJobs) + $restorePointCounts = Get-VeeamRestorePointCounts + $jobDetails = @($allJobs | ForEach-Object { + $jobName = Get-VeeamJobName -Job $_ + $scheduleEnabled = Get-VeeamJobScheduleEnabled -Job $_ + $lastResult = Get-VeeamJobLastResult -Job $_ + $lastState = Get-VeeamJobLastState -Job $_ + $restorePoints = $null + + if ($restorePointCounts.ContainsKey($jobName)) { + $restorePoints = $restorePointCounts[$jobName] + } + + [ordered]@{ + name = $jobName + enabled = $scheduleEnabled + last_run = Get-VeeamJobLastRun -Job $_ + next_run = Get-VeeamJobNextRun -Job $_ + last_result = if ($null -eq $lastResult) { $null } else { [string]$lastResult } + last_state = if ($null -eq $lastState) { $null } else { [string]$lastState } + restore_points = $restorePoints + } + }) + $disabledJobs = @($allJobs | Where-Object { + $scheduleEnabled = Get-VeeamJobScheduleEnabled -Job $_ + + $scheduleEnabled -eq $false + }) + $errorJobs = @($allJobs | Where-Object { + $lastState = Get-VeeamJobLastState -Job $_ + $lastResult = Get-VeeamJobLastResult -Job $_ + + $lastState -eq 'Failed' -or $lastResult -eq 'Failed' + }) + $warningJobs = @($allJobs | Where-Object { + $lastState = Get-VeeamJobLastState -Job $_ + $lastResult = Get-VeeamJobLastResult -Job $_ + + $lastState -eq 'Warning' -or $lastResult -eq 'Warning' + }) + $successfulJobs = @($allJobs | Where-Object { + $lastResult = Get-VeeamJobLastResult -Job $_ + + $lastResult -eq 'Success' + }) + + $disabledCount = $disabledJobs.Count + $disabledExcludedJobs = @() + + if (Test-Path -Path $JobExclusionFilterFile) { + $jobsToExclude = @(Get-Content -Path $JobExclusionFilterFile | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { $_.Trim() }) + + if ($jobsToExclude.Count -gt 0) { + $disabledExcludedJobs = @($disabledJobs | Where-Object { + $jobName = Get-VeeamJobName -Job $_ + $jobsToExclude | Where-Object { $jobName -like "*$_*" } + }) + + $disabledJobsFiltered = @($disabledJobs | Where-Object { + $jobName = Get-VeeamJobName -Job $_ + -not ($jobsToExclude | Where-Object { $jobName -like "*$_*" }) + }) + $disabledJobs = $disabledJobsFiltered + $disabledCount = $disabledJobsFiltered.Count + } + } + + [ordered]@{ + total = $allJobs.Count + disabled = $disabledCount + successful = $successfulJobs.Count + error = $errorJobs.Count + warning = $warningJobs.Count + disabled_names = Format-ZabbixTextValue -Values @($disabledJobs | ForEach-Object { Get-VeeamJobName -Job $_ }) + disabled_excluded_names = Format-ZabbixTextValue -Values @($disabledExcludedJobs | ForEach-Object { Get-VeeamJobName -Job $_ }) + error_names = Format-ZabbixTextValue -Values @($errorJobs | ForEach-Object { Get-VeeamJobName -Job $_ }) + warning_names = Format-ZabbixTextValue -Values @($warningJobs | ForEach-Object { Get-VeeamJobName -Job $_ }) + jobs_table = Format-VeeamJobDetailsTable -JobDetails $jobDetails + jobs = $jobDetails + } +} + +function Send-ZabbixMetrics { + param( + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary]$Metrics + ) + + if (-not (Test-Path -Path $ZabbixSender)) { + throw "zabbix_sender.exe was not found: $ZabbixSender" + } + + if ([string]::IsNullOrWhiteSpace($ZabbixServer)) { + $ZabbixServer = Get-ZabbixAgentConfigValue -Path $ZabbixConfig -Name 'ServerActive' + } + + if ([string]::IsNullOrWhiteSpace($ZabbixServer)) { + $ZabbixServer = Get-ZabbixAgentConfigValue -Path $ZabbixConfig -Name 'Server' + } + + if ([string]::IsNullOrWhiteSpace($ZabbixHost)) { + $ZabbixHost = Get-ZabbixAgentConfigValue -Path $ZabbixConfig -Name 'Hostname' + } + + if ([string]::IsNullOrWhiteSpace($ZabbixHost)) { + $hostnameItem = Get-ZabbixAgentConfigValue -Path $ZabbixConfig -Name 'HostnameItem' + + if ([string]::IsNullOrWhiteSpace($hostnameItem) -or $hostnameItem -eq 'system.hostname') { + $ZabbixHost = $env:COMPUTERNAME + } + } + + if ([string]::IsNullOrWhiteSpace($ZabbixServer)) { + throw "Zabbix server was not provided and was not found in $ZabbixConfig" + } + + if ([string]::IsNullOrWhiteSpace($ZabbixHost)) { + throw "Zabbix hostname was not provided, was not found in $ZabbixConfig, and COMPUTERNAME is empty. Pass -ZabbixHost with the exact Zabbix host name." + } + + $serverEndpoints = @(Get-ZabbixServerEndpoints -Servers $ZabbixServer) + + if ($serverEndpoints.Count -eq 0) { + throw "No usable Zabbix server endpoints were found in: $ZabbixServer" + } + + $senderInput = New-TemporaryFile + + try { + Write-Host "Sending as host: $ZabbixHost" + + @( + "`"$ZabbixHost`" veeam.jobs.total $($Metrics.total)" + "`"$ZabbixHost`" veeam.jobs.disabled $($Metrics.disabled)" + "`"$ZabbixHost`" veeam.jobs.successful $($Metrics.successful)" + "`"$ZabbixHost`" veeam.jobs.error $($Metrics.error)" + "`"$ZabbixHost`" veeam.jobs.warning $($Metrics.warning)" + "`"$ZabbixHost`" veeam.jobs.disabled.names $(Format-ZabbixSenderValue -Value $Metrics.disabled_names)" + "`"$ZabbixHost`" veeam.jobs.disabled.excluded.names $(Format-ZabbixSenderValue -Value $Metrics.disabled_excluded_names)" + "`"$ZabbixHost`" veeam.jobs.error.names $(Format-ZabbixSenderValue -Value $Metrics.error_names)" + "`"$ZabbixHost`" veeam.jobs.warning.names $(Format-ZabbixSenderValue -Value $Metrics.warning_names)" + "`"$ZabbixHost`" veeam.jobs.table $(Format-ZabbixSenderValue -Value $Metrics.jobs_table)" + ) | Set-Content -Path $senderInput -Encoding ASCII + + $failedEndpoints = @() + + foreach ($serverEndpoint in $serverEndpoints) { + $senderArgs = @('-z', $serverEndpoint.Host, '-i', $senderInput) + + if (-not [string]::IsNullOrWhiteSpace($serverEndpoint.Port)) { + $senderArgs += @('-p', $serverEndpoint.Port) + } + + $displayEndpoint = $serverEndpoint.Host + + if (-not [string]::IsNullOrWhiteSpace($serverEndpoint.Port)) { + $displayEndpoint = "$displayEndpoint`:$($serverEndpoint.Port)" + } + + Write-Host "Sending to Zabbix endpoint: $displayEndpoint" + & $ZabbixSender @senderArgs + + if ($LASTEXITCODE -eq 0) { + return + } + + $failedEndpoints += "$displayEndpoint (exit code $LASTEXITCODE)" + } + + throw "zabbix_sender failed for all configured endpoints: $($failedEndpoints -join '; ')" + } + finally { + Remove-Item -Path $senderInput -Force -ErrorAction SilentlyContinue + } +} + +try { + $metrics = Get-VeeamJobMetrics + + if ($Mode -eq 'Sender') { + Send-ZabbixMetrics -Metrics $metrics + } + else { + $metrics | ConvertTo-Json -Depth 5 -Compress + } +} +catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..48ff0cb --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Veeam Backup Jobs for Zabbix + +This repository contains a PowerShell collector and a Zabbix 7.0 template for +monitoring Veeam Backup & Replication jobs. + +## Collector output + +`Monitor-VeeamJobs.ps1` prints one compressed JSON object by default: + +```json +{ + "total": 10, + "disabled": 1, + "successful": 7, + "error": 1, + "warning": 1, + "disabled_names": "Disabled job", + "disabled_excluded_names": "Policy disabled by design", + "error_names": "Failed job", + "warning_names": "Warning job", + "jobs_table": "Name | Enabled | Last run | Next run | Result | Restore points\n...", + "jobs": [ + { + "name": "Daily backup", + "enabled": true, + "last_run": "2026-07-16 01:00:00", + "next_run": "2026-07-17 01:00:00", + "last_result": "Success", + "last_state": "Stopped", + "restore_points": 14 + } + ] +} +``` + +Restore point counting is best effort. The script uses `Get-VBRBackup` and +`Get-VBRRestorePoint` when both cmdlets are available; if Veeam cannot map a job +to backups in the current environment, the job remains visible and its restore +point count is shown as `-`. + +## Zabbix dashboard idea + +Create a dashboard named `Veeam Jobs` for hosts linked to the `Veeam Backup Jobs` +template. + +Suggested widgets: + +| Area | Widget | Items | +| --- | --- | --- | +| Top row | Item value | `veeam.jobs.total` | +| Top row | Item value | `veeam.jobs.successful` | +| Top row | Item value | `veeam.jobs.disabled` | +| Top row | Item value | `veeam.jobs.error` | +| Top row | Item value | `veeam.jobs.warning` | +| Middle | Plain text | `veeam.jobs.table` | +| Bottom left | Plain text | `veeam.jobs.error.names` | +| Bottom center | Plain text | `veeam.jobs.warning.names` | +| Bottom right | Plain text | `veeam.jobs.disabled.names` | + +Use a red threshold for `veeam.jobs.error`, yellow for `veeam.jobs.warning`, and +keep `veeam.jobs.disabled` aligned with the existing exclusion policy. The +template intentionally keeps `veeam.jobs.disabled.excluded.names`, because some +disabled policies are expected. diff --git a/zabbix-templete-veeam-jobs.xml b/zabbix-templete-veeam-jobs.xml new file mode 100644 index 0000000..6e97b8a --- /dev/null +++ b/zabbix-templete-veeam-jobs.xml @@ -0,0 +1,370 @@ + + + 7.0 + + + 6822b9e565e14f159eb87e271cc98988 + Templates/Backup + + + + + +