ProxmoxMonitor.psm1

#requires -Version 5.1

Set-StrictMode -Version Latest
$script:ProxmoxMonitorVersion = '1.3.1'

#region NableMonitoring

function Get-PVEValue {
    param([AllowNull()]$Object, [Parameter(Mandatory)][string]$Name, $Default = $null)
    if ($Object -is [Collections.IDictionary] -and $Object.Contains($Name) -and $null -ne $Object[$Name]) { return $Object[$Name] }
    if ($null -ne $Object) {
        $property = $Object.PSObject.Properties[$Name]
        if ($null -ne $property -and $null -ne $property.Value) { return $property.Value }
    }
    return $Default
}

function New-MonitoringResult {
    param([Parameter(Mandatory)][string]$CheckName)
    [pscustomobject]@{
        CheckName = $CheckName
        Status = 'OK'
        Findings = [Collections.Generic.List[object]]::new()
        Metrics = [ordered]@{}
    }
}

function Add-MonitoringFinding {
    param(
        [Parameter(Mandatory)]$Result,
        [Parameter(Mandatory)][ValidateSet('OK', 'WARNING', 'CRITICAL', 'UNKNOWN')][string]$Status,
        [Parameter(Mandatory)][string]$Message
    )
    $rank = @{ OK = 0; WARNING = 1; UNKNOWN = 2; CRITICAL = 3 }
    if ($rank[$Status] -gt $rank[$Result.Status]) { $Result.Status = $Status }
    $Result.Findings.Add([pscustomobject]@{ Status = $Status; Message = $Message })
}

function Add-MonitoringMetric {
    param([Parameter(Mandatory)]$Result, [Parameter(Mandatory)][string]$Name, $Value)
    $Result.Metrics[$Name] = $Value
}

function Get-Percent {
    param([double]$Used, [double]$Total)
    if ($Total -le 0) { return 0.0 }
    return [Math]::Round(($Used / $Total) * 100, 2)
}

#endregion

#region ProxmoxAPI

$script:PVEContext = $null

function Initialize-PVECertificateValidator {
    if ($null -ne ('PVECertificateValidation' -as [type])) { return }
    $source = @'
using System;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
 
public static class PVECertificateValidation
{
    public static Func<HttpRequestMessage, X509Certificate2, X509Chain, SslPolicyErrors, bool> Ignore()
    {
        return delegate(HttpRequestMessage request, X509Certificate2 certificate, X509Chain chain, SslPolicyErrors errors)
        {
            return true;
        };
    }
 
    public static Func<HttpRequestMessage, X509Certificate2, X509Chain, SslPolicyErrors, bool> Pin(string expected)
    {
        return delegate(HttpRequestMessage request, X509Certificate2 certificate, X509Chain chain, SslPolicyErrors errors)
        {
            if (certificate == null) return false;
            using (SHA256 sha256 = SHA256.Create())
            {
                string actual = BitConverter.ToString(sha256.ComputeHash(certificate.RawData)).Replace("-", "");
                return String.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
            }
        };
    }
}
'@

    if ($PSVersionTable.PSEdition -eq 'Desktop') { Add-Type -TypeDefinition $source -ReferencedAssemblies 'System.Net.Http.dll' }
    else { Add-Type -TypeDefinition $source }
}

function Set-PVEErrorCategory {
    param([Parameter(Mandatory)][Exception]$Exception, [Parameter(Mandatory)][string]$Category)
    $Exception.Data['PVECategory'] = $Category
    return $Exception
}

function Invoke-PVEHttpSend {
    param([Parameter(Mandatory)]$Client, [Parameter(Mandatory)]$Request)
    $response = $Client.SendAsync($Request).GetAwaiter().GetResult()
    try {
        [pscustomobject]@{
            IsSuccessStatusCode = $response.IsSuccessStatusCode
            StatusCode = [int]$response.StatusCode
            Content = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
        }
    }
    finally { $response.Dispose() }
}

function Connect-Proxmox {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Server,
        [Parameter(Mandatory)][string]$TokenID,
        [Parameter(Mandatory)][string]$TokenSecret,
        [ValidateSet('Ignore', 'Validate', 'Pin')][string]$TlsMode = 'Ignore',
        [string]$CertificateSha256,
        [ValidateRange(1, 300)][int]$TimeoutSec = 30,
        [switch]$SkipValidation
    )

    $serverUri = $null
    if (-not [Uri]::TryCreate($Server, [UriKind]::Absolute, [ref]$serverUri) -or $serverUri.Scheme -ne 'https') {
        throw (Set-PVEErrorCategory ([ArgumentException]::new('Server must be an absolute HTTPS URL.')) 'Configuration')
    }
    if ($TokenID -notmatch '^[^!\s]+![^=\s]+$') {
        throw (Set-PVEErrorCategory ([ArgumentException]::new('TokenID must use the format user@realm!tokenid.')) 'Configuration')
    }
    if ([string]::IsNullOrWhiteSpace($TokenSecret)) {
        throw (Set-PVEErrorCategory ([ArgumentException]::new('TokenSecret cannot be empty.')) 'Configuration')
    }

    $pin = ($CertificateSha256 -replace '[^0-9A-Fa-f]', '').ToUpperInvariant()
    if ($TlsMode -eq 'Pin' -and $pin.Length -ne 64) {
        throw (Set-PVEErrorCategory ([ArgumentException]::new('CertificateSha256 must contain a 64-character SHA-256 thumbprint when TlsMode is Pin.')) 'Configuration')
    }

    if ($null -ne $script:PVEContext -and $null -ne $script:PVEContext.Client) {
        $script:PVEContext.Client.Dispose()
        $script:PVEContext.Handler.Dispose()
    }

    Add-Type -AssemblyName System.Net.Http
    $handler = [Net.Http.HttpClientHandler]::new()
    if ($TlsMode -eq 'Ignore') {
        Initialize-PVECertificateValidator
        $handler.ServerCertificateCustomValidationCallback = [PVECertificateValidation]::Ignore()
    }
    elseif ($TlsMode -eq 'Pin') {
        Initialize-PVECertificateValidator
        $handler.ServerCertificateCustomValidationCallback = [PVECertificateValidation]::Pin($pin)
    }

    $client = [Net.Http.HttpClient]::new($handler)
    $client.Timeout = [TimeSpan]::FromSeconds($TimeoutSec)
    $null = $client.DefaultRequestHeaders.TryAddWithoutValidation('Authorization', "PVEAPIToken=$TokenID=$TokenSecret")
    $null = $client.DefaultRequestHeaders.TryAddWithoutValidation('Accept', 'application/json')

    $baseUri = $Server.TrimEnd('/')
    if ($baseUri -notmatch '/api2/json$') { $baseUri = "$baseUri/api2/json" }
    $script:PVEContext = [pscustomobject]@{
        BaseUri = $baseUri
        Client  = $client
        Handler = $handler
    }

    if (-not $SkipValidation) { $null = Invoke-PVERequest -Path '/version' }
    [pscustomobject]@{ Server = $baseUri; TokenID = $TokenID; TlsMode = $TlsMode; Connected = $true }
}

function Invoke-PVERequest {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        [ValidateSet('GET', 'POST', 'PUT', 'DELETE')][string]$Method = 'GET',
        [hashtable]$Body
    )

    if ($null -eq $script:PVEContext) {
        throw (Set-PVEErrorCategory ([InvalidOperationException]::new('Connect-Proxmox must be called before making API requests.')) 'Configuration')
    }

    $uri = '{0}/{1}' -f $script:PVEContext.BaseUri, $Path.TrimStart('/')
    $request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $uri)
    if ($Body) { $request.Content = [Net.Http.FormUrlEncodedContent]::new($Body) }

    try {
        $response = Invoke-PVEHttpSend -Client $script:PVEContext.Client -Request $request
        $content = $response.Content
        if (-not $response.IsSuccessStatusCode) {
            $code = $response.StatusCode
            if ($code -in 401, 403) {
                $exception = Set-PVEErrorCategory ([UnauthorizedAccessException]::new("Proxmox authentication or authorization failed (HTTP $code) for '$Path'.")) 'Authentication'
                $exception.Data['HttpStatusCode'] = $code
                throw $exception
            }
            $exception = Set-PVEErrorCategory ([InvalidOperationException]::new("Proxmox API returned HTTP $code for '$Path'.")) 'Availability'
            $exception.Data['HttpStatusCode'] = $code
            throw $exception
        }
        try { $envelope = $content | ConvertFrom-Json -ErrorAction Stop }
        catch { throw [IO.InvalidDataException]::new("Proxmox returned invalid JSON for '$Path'.", $_.Exception) }
        if ($null -eq $envelope -or $envelope.PSObject.Properties.Name -notcontains 'data') {
            throw [IO.InvalidDataException]::new("Proxmox returned an invalid response envelope for '$Path'.")
        }
        return $envelope.data
    }
    catch {
        if ($_.Exception.Data.Contains('PVECategory')) { throw }
        if ($_.Exception -is [IO.InvalidDataException] -or $_.Exception.InnerException -is [IO.InvalidDataException]) {
            throw (Set-PVEErrorCategory ([IO.InvalidDataException]::new($_.Exception.Message, $_.Exception)) 'InvalidResponse')
        }
        if ($_.Exception -is [Threading.Tasks.TaskCanceledException] -or $_.Exception.InnerException -is [Threading.Tasks.TaskCanceledException]) {
            throw (Set-PVEErrorCategory ([TimeoutException]::new("Proxmox API request timed out for '$Path'.", $_.Exception)) 'Timeout')
        }
        throw (Set-PVEErrorCategory ([InvalidOperationException]::new("Proxmox API request failed for '$Path': $($_.Exception.Message)", $_.Exception)) 'Availability')
    }
    finally { $request.Dispose() }
}

function Disconnect-Proxmox {
    if ($null -ne $script:PVEContext) {
        $script:PVEContext.Client.Dispose()
        $script:PVEContext.Handler.Dispose()
        $script:PVEContext = $null
    }
}

function Get-PVECluster { Invoke-PVERequest '/cluster/status' }
function Get-PVENodes { Invoke-PVERequest '/nodes' }
function Get-PVEStorage { Invoke-PVERequest '/cluster/resources?type=storage' }
function Get-PVEGuests { Invoke-PVERequest '/cluster/resources?type=vm' }
function Get-PVEHA { Invoke-PVERequest '/cluster/ha/status/current' }
function Get-PVECephStatus { Invoke-PVERequest '/cluster/ceph/status' }
function Get-PVECephMetadata { Invoke-PVERequest '/cluster/ceph/metadata' }
function Get-PVEReplication { Invoke-PVERequest '/cluster/replication' }
function Get-PVEBackups { @(Invoke-PVERequest '/cluster/tasks') | Where-Object { $_.type -eq 'vzdump' } }
function Get-PVENodeStatus { param([Parameter(Mandatory)][string]$Node) Invoke-PVERequest "/nodes/$([Uri]::EscapeDataString($Node))/status" }
function Get-PVEDisks { param([Parameter(Mandatory)][string]$Node) Invoke-PVERequest "/nodes/$([Uri]::EscapeDataString($Node))/disks/list" }
function Get-PVEZfsPools { param([Parameter(Mandatory)][string]$Node) Invoke-PVERequest "/nodes/$([Uri]::EscapeDataString($Node))/disks/zfs" }
function Get-PVEZfsPoolDetails {
    param([Parameter(Mandatory)][string]$Node, [Parameter(Mandatory)][string]$Pool)
    Invoke-PVERequest "/nodes/$([Uri]::EscapeDataString($Node))/disks/zfs/$([Uri]::EscapeDataString($Pool))"
}
function Get-PVEReplicationStatus {
    param([Parameter(Mandatory)][string]$Node, [Parameter(Mandatory)][string]$JobID)
    Invoke-PVERequest "/nodes/$([Uri]::EscapeDataString($Node))/replication/$([Uri]::EscapeDataString($JobID))/status"
}

#endregion

#region ProxmoxCollector

function Get-CollectorValue {
    param([AllowNull()]$Object, [string]$Name, $Default = $null)
    if ($Object -is [Collections.IDictionary] -and $Object.Contains($Name) -and $null -ne $Object[$Name]) { return $Object[$Name] }
    if ($null -ne $Object) {
        $property = $Object.PSObject.Properties[$Name]
        if ($null -ne $property -and $null -ne $property.Value) { return $property.Value }
    }
    return $Default
}

function Invoke-PVEDomainRequest {
    param([Parameter(Mandatory)][scriptblock]$Request)
    try { [pscustomobject]@{ Data = @(& $Request); Error = $null } }
    catch { [pscustomobject]@{ Data = @(); Error = [pscustomobject]@{ Message = $_.Exception.Message; Category = [string]$_.Exception.Data['PVECategory']; HttpStatusCode = $_.Exception.Data['HttpStatusCode'] } } }
}

function Read-PVESnapshot {
    param([string]$Path)
    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
    try { Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop }
    catch { return $null }
}

function ConvertTo-PVEUtcDateTime {
    param([Parameter(Mandatory)]$Value)
    if ($Value -is [DateTime]) { return ([DateTime]$Value).ToUniversalTime() }
    if ($Value -is [DateTimeOffset]) { return ([DateTimeOffset]$Value).UtcDateTime }
    return [DateTimeOffset]::Parse([string]$Value, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind).UtcDateTime
}

function Write-PVESnapshot {
    param([Parameter(Mandatory)]$Snapshot, [Parameter(Mandatory)][string]$Path)
    $directory = Split-Path -Parent $Path
    if (-not (Test-Path -LiteralPath $directory)) { $null = New-Item -ItemType Directory -Path $directory -Force }
    $temp = "$Path.$([Guid]::NewGuid().ToString('N')).tmp"
    try {
        $Snapshot | ConvertTo-Json -Depth 20 -Compress | Set-Content -LiteralPath $temp -Encoding UTF8
        Move-Item -LiteralPath $temp -Destination $Path -Force
    }
    finally { if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Force } }
}

function Get-GuestRates {
    param([object[]]$CurrentGuests, $PreviousSnapshot, [DateTime]$CurrentTime)
    $rates = [Collections.Generic.List[object]]::new()
    if ($null -eq $PreviousSnapshot) { return $rates }
    $previousTime = ConvertTo-PVEUtcDateTime $PreviousSnapshot.CollectedAtUtc
    $seconds = ($CurrentTime - $previousTime).TotalSeconds
    if ($seconds -le 0) { return $rates }
    $previous = @{}
    foreach ($guest in @($PreviousSnapshot.Guests.Data)) { $previous["$(Get-CollectorValue $guest 'type')/$(Get-CollectorValue $guest 'vmid')"] = $guest }
    foreach ($guest in $CurrentGuests) {
        $key = "$(Get-CollectorValue $guest 'type')/$(Get-CollectorValue $guest 'vmid')"
        if (-not $previous.ContainsKey($key)) { continue }
        $old = $previous[$key]
        $netInDelta = [double](Get-CollectorValue $guest 'netin' 0) - [double](Get-CollectorValue $old 'netin' 0)
        $netOutDelta = [double](Get-CollectorValue $guest 'netout' 0) - [double](Get-CollectorValue $old 'netout' 0)
        if ($netInDelta -lt 0 -or $netOutDelta -lt 0) { continue }
        $rates.Add([pscustomobject]@{ vmid = [int](Get-CollectorValue $guest 'vmid' 0); type = [string](Get-CollectorValue $guest 'type'); netin_bps = [Math]::Round($netInDelta / $seconds, 2); netout_bps = [Math]::Round($netOutDelta / $seconds, 2) })
    }
    return $rates
}

function New-PVESnapshot {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Config, $PreviousSnapshot, [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$TokenSecret)

    $secret = $TokenSecret
    $tls = Get-CollectorValue $Config 'Tls' ([pscustomobject]@{})
    Connect-Proxmox -Server $Config.Server -TokenID $Config.TokenID -TokenSecret $secret `
        -TlsMode ([string](Get-CollectorValue $tls 'Mode' 'Ignore')) `
        -CertificateSha256 ([string](Get-CollectorValue $tls 'CertificateSha256' '')) `
        -TimeoutSec ([int](Get-CollectorValue $Config 'TimeoutSec' 30)) | Out-Null
    $secret = $null

    try {
        $collectedAt = [DateTime]::UtcNow
        $cluster = Invoke-PVEDomainRequest { Get-PVECluster }
        $nodes = Invoke-PVEDomainRequest { Get-PVENodes }
        $storage = Invoke-PVEDomainRequest { Get-PVEStorage }
        $guests = Invoke-PVEDomainRequest { Get-PVEGuests }
        $backups = Invoke-PVEDomainRequest { Get-PVEBackups }
        $replication = Invoke-PVEDomainRequest { Get-PVEReplication }
        $ha = Invoke-PVEDomainRequest { Get-PVEHA }
        $cephStatus = Invoke-PVEDomainRequest { Get-PVECephStatus }
        $cephMetadata = Invoke-PVEDomainRequest { Get-PVECephMetadata }

        $nodeStatus = [ordered]@{}; $disks = [ordered]@{}; $zfsPools = [ordered]@{}; $zfsPoolDetails = [Collections.Generic.List[object]]::new()
        foreach ($node in @($nodes.Data)) {
            if ((Get-CollectorValue $node 'status') -ne 'online') { continue }
            $name = [string](Get-CollectorValue $node 'node')
            $nodeStatus[$name] = Invoke-PVEDomainRequest { Get-PVENodeStatus -Node $name }
            $disks[$name] = Invoke-PVEDomainRequest { Get-PVEDisks -Node $name }
            $zfsPools[$name] = Invoke-PVEDomainRequest { Get-PVEZfsPools -Node $name }
            foreach ($pool in @($zfsPools[$name].Data)) {
                $poolName = [string](Get-CollectorValue $pool 'name' '')
                if ([string]::IsNullOrWhiteSpace($poolName)) { continue }
                $zfsPoolDetails.Add([pscustomobject]@{ node = $name; pool = $poolName; Result = Invoke-PVEDomainRequest { Get-PVEZfsPoolDetails -Node $name -Pool $poolName } })
            }
        }

        $replicationStatus = [Collections.Generic.List[object]]::new()
        foreach ($job in @($replication.Data)) {
            $source = [string](Get-CollectorValue $job 'source' '')
            $id = [string](Get-CollectorValue $job 'id' '')
            if ([string]::IsNullOrWhiteSpace($source) -or [string]::IsNullOrWhiteSpace($id)) { continue }
            $state = Invoke-PVEDomainRequest { Get-PVEReplicationStatus -Node $source -JobID $id }
            $replicationStatus.Add([pscustomobject]@{ id = $id; source = $source; Result = $state })
        }

        [pscustomobject]@{
            SchemaVersion = 2; ClusterName = $Config.ClusterName; CollectedAtUtc = $collectedAt.ToString('o')
            Cluster = $cluster; Nodes = $nodes; NodeStatus = $nodeStatus; Storage = $storage; Disks = $disks
            Guests = $guests; GuestRates = @(Get-GuestRates @($guests.Data) $PreviousSnapshot $collectedAt)
            Backups = $backups; Replication = $replication; ReplicationStatus = $replicationStatus; HA = $ha
            CephStatus = $cephStatus; CephMetadata = $cephMetadata; ZfsPools = $zfsPools; ZfsPoolDetails = $zfsPoolDetails
        }
    }
    finally { Disconnect-Proxmox }
}

function Get-PVESnapshot {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Config, [string]$TokenSecret, [switch]$Force)

    $cachePath = $Config.Runtime.CachePath
    $ttl = [int](Get-CollectorValue $Config 'CacheTtlSeconds' 120)
    $cached = Read-PVESnapshot $cachePath
    if (-not $Force -and $null -ne $cached -and (([DateTime]::UtcNow - (ConvertTo-PVEUtcDateTime $cached.CollectedAtUtc)).TotalSeconds -le $ttl)) { return $cached }

    $sha256 = [Security.Cryptography.SHA256]::Create()
    try { $hash = ([BitConverter]::ToString($sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($cachePath.ToLowerInvariant())))).Replace('-', '').Substring(0, 16) }
    finally { $sha256.Dispose() }
    $mutex = [Threading.Mutex]::new($false, "Local\ProxmoxMonitor_$hash")
    $locked = $false
    try {
        try { $locked = $mutex.WaitOne([TimeSpan]::FromSeconds(60)) }
        catch [Threading.AbandonedMutexException] { $locked = $true }
        if (-not $locked) { throw [TimeoutException]::new('Timed out waiting for the Proxmox snapshot collector lock.') }
        $cached = Read-PVESnapshot $cachePath
        if (-not $Force -and $null -ne $cached -and (([DateTime]::UtcNow - (ConvertTo-PVEUtcDateTime $cached.CollectedAtUtc)).TotalSeconds -le $ttl)) { return $cached }
        $snapshot = New-PVESnapshot -Config $Config -PreviousSnapshot $cached -TokenSecret $TokenSecret
        Write-PVESnapshot -Snapshot $snapshot -Path $cachePath
        return $snapshot
    }
    finally {
        if ($locked) { $mutex.ReleaseMutex() }
        $mutex.Dispose()
    }
}

#endregion

#region ProxmoxChecks

function Get-CheckValue {
    param([AllowNull()]$Object, [string]$Name, $Default = $null)
    if ($Object -is [Collections.IDictionary] -and $Object.Contains($Name) -and $null -ne $Object[$Name]) { return $Object[$Name] }
    if ($null -ne $Object) {
        $property = $Object.PSObject.Properties[$Name]
        if ($null -ne $property -and $null -ne $property.Value) { return $property.Value }
    }
    return $Default
}

function Get-CheckEntries {
    param($Object)
    if ($Object -is [Collections.IDictionary]) {
        foreach ($key in $Object.Keys) { [pscustomobject]@{ Name = [string]$key; Value = $Object[$key] } }
    }
    elseif ($null -ne $Object) {
        foreach ($property in $Object.PSObject.Properties | Where-Object { $_.MemberType -in 'NoteProperty', 'Property' }) { [pscustomobject]@{ Name = $property.Name; Value = $property.Value } }
    }
}

function Add-DomainFailure {
    param($Result, $Domain, [string]$Name)
    $errorValue = Get-CheckValue $Domain 'Error'
    if ($null -ne $errorValue) { Add-MonitoringFinding $Result CRITICAL "$Name collection failed: $(Get-CheckValue $errorValue 'Message' 'unknown error')"; return $true }
    return $false
}

function Test-Threshold {
    param($Result, [string]$Label, [double]$Value, [double]$Warning, [double]$Critical, [switch]$LowerIsWorse)
    if ($LowerIsWorse) {
        if ($Value -le $Critical) { Add-MonitoringFinding $Result CRITICAL "$Label is $Value." }
        elseif ($Value -le $Warning) { Add-MonitoringFinding $Result WARNING "$Label is $Value." }
    }
    else {
        if ($Value -ge $Critical) { Add-MonitoringFinding $Result CRITICAL "$Label is $Value." }
        elseif ($Value -ge $Warning) { Add-MonitoringFinding $Result WARNING "$Label is $Value." }
    }
}

function Test-PVEClusterHealth {
    param($Snapshot, $Config)
    $result = New-MonitoringResult 'Proxmox cluster health'
    if (Add-DomainFailure $result $Snapshot.Cluster 'Cluster') { return $result }
    $entries = @($Snapshot.Cluster.Data)
    $nodes = @($entries | Where-Object { (Get-CheckValue $_ 'type') -eq 'node' })
    $online = @($nodes | Where-Object { [int](Get-CheckValue $_ 'online' 0) -eq 1 })
    Add-MonitoringMetric $result 'nodes_total' $nodes.Count
    Add-MonitoringMetric $result 'nodes_online' $online.Count
    if ($nodes.Count -eq 0) { Add-MonitoringFinding $result CRITICAL 'No cluster nodes were returned.' }
    foreach ($node in $nodes | Where-Object { [int](Get-CheckValue $_ 'online' 0) -ne 1 }) { Add-MonitoringFinding $result CRITICAL "Node $(Get-CheckValue $node 'name' 'unknown') is offline." }
    $cluster = $entries | Where-Object { (Get-CheckValue $_ 'type') -eq 'cluster' } | Select-Object -First 1
    if ([bool](Get-CheckValue (Get-CheckValue $Config 'Cluster' ([pscustomobject]@{})) 'RequireQuorum' $true)) {
        if ($null -eq $cluster) { Add-MonitoringFinding $result WARNING 'No cluster quorum record was returned.' }
        elseif ([int](Get-CheckValue $cluster 'quorate' 0) -ne 1) { Add-MonitoringFinding $result CRITICAL 'The cluster does not have quorum.' }
        else { Add-MonitoringMetric $result 'quorate' 1 }
    }
    if ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "Cluster has quorum and $($online.Count) online node(s)." }
    return $result
}

function Test-PVENodeHealth {
    param($Snapshot, $Config)
    $result = New-MonitoringResult 'Proxmox node health'
    if (Add-DomainFailure $result $Snapshot.Nodes 'Node list') { return $result }
    $thresholds = Get-CheckValue $Config 'Nodes' ([pscustomobject]@{})
    if (@($Snapshot.Nodes.Data).Count -eq 0) { Add-MonitoringFinding $result CRITICAL 'No nodes were returned.'; return $result }
    foreach ($node in @($Snapshot.Nodes.Data)) {
        $name = [string](Get-CheckValue $node 'node' 'unknown')
        if ((Get-CheckValue $node 'status' 'unknown') -ne 'online') { Add-MonitoringFinding $result CRITICAL "Node $name is offline."; continue }
        $domain = Get-CheckValue $Snapshot.NodeStatus $name
        if (Add-DomainFailure $result $domain "Node $name status") { continue }
        $detail = @($domain.Data) | Select-Object -First 1
        $cpu = [Math]::Round([double](Get-CheckValue $detail 'cpu' 0) * 100, 2)
        $memoryObject = Get-CheckValue $detail 'memory' ([pscustomobject]@{})
        $memory = Get-Percent ([double](Get-CheckValue $memoryObject 'used' (Get-CheckValue $detail 'mem' 0))) ([double](Get-CheckValue $memoryObject 'total' (Get-CheckValue $detail 'maxmem' 0)))
        $cpuInfo = Get-CheckValue $detail 'cpuinfo' ([pscustomobject]@{})
        $cores = [Math]::Max(1, [int](Get-CheckValue $cpuInfo 'cpus' (Get-CheckValue $detail 'maxcpu' 1)))
        $load = @(Get-CheckValue $detail 'loadavg' @('0'))
        $loadPerCore = [Math]::Round(([double]$load[0]) / $cores, 2)
        Add-MonitoringMetric $result "$name.cpu_percent" $cpu
        Add-MonitoringMetric $result "$name.memory_percent" $memory
        Add-MonitoringMetric $result "$name.load_per_core" $loadPerCore
        Add-MonitoringMetric $result "$name.uptime_seconds" ([long](Get-CheckValue $detail 'uptime' 0))
        Test-Threshold $result "Node $name CPU percent" $cpu ([double](Get-CheckValue $thresholds 'CpuWarningPercent' 80)) ([double](Get-CheckValue $thresholds 'CpuCriticalPercent' 95))
        Test-Threshold $result "Node $name memory percent" $memory ([double](Get-CheckValue $thresholds 'MemoryWarningPercent' 85)) ([double](Get-CheckValue $thresholds 'MemoryCriticalPercent' 95))
        Test-Threshold $result "Node $name load per core" $loadPerCore ([double](Get-CheckValue $thresholds 'LoadPerCoreWarning' 1)) ([double](Get-CheckValue $thresholds 'LoadPerCoreCritical' 2))
    }
    if ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "All $(@($Snapshot.Nodes.Data).Count) node(s) are healthy." }
    return $result
}

function Test-PVEStorageHealth {
    param($Snapshot, $Config)
    $result = New-MonitoringResult 'Proxmox storage and disk health'
    if (Add-DomainFailure $result $Snapshot.Storage 'Storage') { return $result }
    $thresholds = Get-CheckValue $Config 'Storage' ([pscustomobject]@{})
    if (@($Snapshot.Storage.Data).Count -eq 0) { Add-MonitoringFinding $result CRITICAL 'No storage resources were returned.'; return $result }
    foreach ($storage in @($Snapshot.Storage.Data)) {
        $id = "$(Get-CheckValue $storage 'node' 'cluster')/$(Get-CheckValue $storage 'storage' 'unknown')"
        if ((Get-CheckValue $storage 'status' 'unknown') -ne 'available') { Add-MonitoringFinding $result CRITICAL "Storage $id is $(Get-CheckValue $storage 'status' 'unknown')."; continue }
        $used = [double](Get-CheckValue $storage 'disk' 0); $total = [double](Get-CheckValue $storage 'maxdisk' 0)
        $usage = Get-Percent $used $total; $freeGB = [Math]::Round(($total - $used) / 1GB, 2)
        Add-MonitoringMetric $result "$id.usage_percent" $usage; Add-MonitoringMetric $result "$id.free_gb" $freeGB
        Test-Threshold $result "Storage $id usage percent" $usage ([double](Get-CheckValue $thresholds 'UsageWarningPercent' 80)) ([double](Get-CheckValue $thresholds 'UsageCriticalPercent' 90))
        Test-Threshold $result "Storage $id free GB" $freeGB ([double](Get-CheckValue $thresholds 'FreeWarningGB' 20)) ([double](Get-CheckValue $thresholds 'FreeCriticalGB' 10)) -LowerIsWorse
    }
    foreach ($property in @(Get-CheckEntries $Snapshot.Disks)) {
        $node = $property.Name; $domain = $property.Value
        if (Add-DomainFailure $result $domain "Physical disks on $node") { continue }
        foreach ($disk in @($domain.Data)) {
            $device = [string](Get-CheckValue $disk 'devpath' (Get-CheckValue $disk 'name' 'unknown'))
            $health = [string](Get-CheckValue $disk 'health' '')
            if (-not [string]::IsNullOrWhiteSpace($health)) {
                Add-MonitoringMetric $result "$node.$device.health" $health
                if ($health.ToUpperInvariant() -notin 'PASSED', 'OK', 'UNKNOWN') { Add-MonitoringFinding $result CRITICAL "Disk $device on $node reports SMART health '$health'." }
            }
            $wear = Get-CheckValue $disk 'wearout'
            if ($null -ne $wear -and [string]$wear -match '^\d+(\.\d+)?$') {
                $remaining = [Math]::Max(0, [Math]::Round([double]$wear, 2))
                Add-MonitoringMetric $result "$node.$device.life_remaining_percent" $remaining
                Test-Threshold $result "Disk $device on $node life remaining percent" $remaining ([double](Get-CheckValue $thresholds 'LifeRemainingWarningPercent' 20)) ([double](Get-CheckValue $thresholds 'LifeRemainingCriticalPercent' 10)) -LowerIsWorse
            }
        }
    }
    if ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK 'Storage resources and reported physical disk health are healthy.' }
    return $result
}

function Test-PVEGuestHealth {
    param($Snapshot, $Config)
    $result = New-MonitoringResult 'Proxmox VM and LXC health'
    if (Add-DomainFailure $result $Snapshot.Guests 'Guest') { return $result }
    $settings = Get-CheckValue $Config 'Guests' ([pscustomobject]@{})
    $expected = @((Get-CheckValue $settings 'ExpectedRunningVMIDs' @()) | ForEach-Object { [int]$_ })
    $guests = @($Snapshot.Guests.Data)
    foreach ($vmid in $expected) {
        $guest = $guests | Where-Object { [int](Get-CheckValue $_ 'vmid' 0) -eq $vmid } | Select-Object -First 1
        if ($null -eq $guest) { Add-MonitoringFinding $result CRITICAL "Expected guest $vmid is missing." }
        elseif ((Get-CheckValue $guest 'status') -ne 'running') { Add-MonitoringFinding $result CRITICAL "Expected guest $vmid is $(Get-CheckValue $guest 'status' 'unknown')." }
    }
    foreach ($guest in $guests | Where-Object { (Get-CheckValue $_ 'status') -eq 'running' }) {
        $id = "$(Get-CheckValue $guest 'type' 'guest')/$(Get-CheckValue $guest 'vmid' 0)"
        $cpu = [Math]::Round([double](Get-CheckValue $guest 'cpu' 0) * 100, 2)
        $memory = Get-Percent ([double](Get-CheckValue $guest 'mem' 0)) ([double](Get-CheckValue $guest 'maxmem' 0))
        $disk = Get-Percent ([double](Get-CheckValue $guest 'disk' 0)) ([double](Get-CheckValue $guest 'maxdisk' 0))
        Add-MonitoringMetric $result "$id.cpu_percent" $cpu; Add-MonitoringMetric $result "$id.memory_percent" $memory
        Add-MonitoringMetric $result "$id.disk_percent" $disk; Add-MonitoringMetric $result "$id.uptime_seconds" ([long](Get-CheckValue $guest 'uptime' 0))
        Test-Threshold $result "Guest $id CPU percent" $cpu ([double](Get-CheckValue $settings 'CpuWarningPercent' 80)) ([double](Get-CheckValue $settings 'CpuCriticalPercent' 95))
        Test-Threshold $result "Guest $id memory percent" $memory ([double](Get-CheckValue $settings 'MemoryWarningPercent' 85)) ([double](Get-CheckValue $settings 'MemoryCriticalPercent' 95))
        if ([double](Get-CheckValue $guest 'maxdisk' 0) -gt 0) { Test-Threshold $result "Guest $id disk percent" $disk ([double](Get-CheckValue $settings 'DiskWarningPercent' 80)) ([double](Get-CheckValue $settings 'DiskCriticalPercent' 90)) }
    }
    foreach ($rate in @($Snapshot.GuestRates)) {
        $id = "$(Get-CheckValue $rate 'type')/$(Get-CheckValue $rate 'vmid')"
        Add-MonitoringMetric $result "$id.netin_bps" (Get-CheckValue $rate 'netin_bps' 0); Add-MonitoringMetric $result "$id.netout_bps" (Get-CheckValue $rate 'netout_bps' 0)
    }
    if ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "$($guests.Count) guest(s) evaluated; expected-running guests are online." }
    return $result
}

function Test-PVEBackupHealth {
    param($Snapshot, $Config)
    $result = New-MonitoringResult 'Proxmox backup health'
    if (Add-DomainFailure $result $Snapshot.Backups 'Backup task') { return $result }
    $settings = Get-CheckValue $Config 'Backups' ([pscustomobject]@{})
    $expected = @((Get-CheckValue $settings 'ExpectedVMIDs' @()) | ForEach-Object { [int]$_ })
    $maximumAge = [double](Get-CheckValue $settings 'MaximumAgeHours' 26)
    foreach ($vmid in $expected) {
        $tasks = @($Snapshot.Backups.Data | Where-Object { [int](Get-CheckValue $_ 'id' 0) -eq $vmid -and $null -ne (Get-CheckValue $_ 'endtime') } | Sort-Object { [long](Get-CheckValue $_ 'endtime' 0) } -Descending)
        if ($tasks.Count -eq 0) { Add-MonitoringFinding $result CRITICAL "No completed backup task was found for guest $vmid."; continue }
        $latest = $tasks[0]; $status = [string](Get-CheckValue $latest 'status' 'unknown')
        if ($status -ne 'OK') { Add-MonitoringFinding $result CRITICAL "Latest backup for guest $vmid failed with status '$status'."; continue }
        $age = [Math]::Round(([DateTime]::UtcNow - [DateTimeOffset]::FromUnixTimeSeconds([long]$latest.endtime).UtcDateTime).TotalHours, 2)
        Add-MonitoringMetric $result "$vmid.last_success_age_hours" $age
        if ($age -gt $maximumAge) { Add-MonitoringFinding $result CRITICAL "Latest successful backup for guest $vmid is $age hours old (maximum $maximumAge)." }
    }
    if ($expected.Count -eq 0) { Add-MonitoringFinding $result OK 'No guests are configured for required backup coverage.' }
    elseif ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "All $($expected.Count) required guest backup(s) are current." }
    return $result
}

function Test-PVEReplicationHealth {
    param($Snapshot, $Config)
    $result = New-MonitoringResult 'Proxmox replication health'
    if (Add-DomainFailure $result $Snapshot.Replication 'Replication') { return $result }
    $jobs = @($Snapshot.Replication.Data | Where-Object { [int](Get-CheckValue $_ 'disable' 0) -ne 1 })
    if ($jobs.Count -eq 0) { Add-MonitoringFinding $result OK 'Replication is not configured.'; return $result }
    $maxAge = [double](Get-CheckValue (Get-CheckValue $Config 'Replication' ([pscustomobject]@{})) 'MaximumSyncAgeMinutes' 30)
    foreach ($job in $jobs) {
        $id = [string](Get-CheckValue $job 'id' 'unknown'); $wrapper = @($Snapshot.ReplicationStatus | Where-Object { (Get-CheckValue $_ 'id') -eq $id }) | Select-Object -First 1
        if ($null -eq $wrapper) { Add-MonitoringFinding $result CRITICAL "No status was collected for replication job $id."; continue }
        if (Add-DomainFailure $result $wrapper.Result "Replication job $id") { continue }
        $status = @($wrapper.Result.Data) | Select-Object -First 1
        $errorText = [string](Get-CheckValue $status 'error' '')
        if (-not [string]::IsNullOrWhiteSpace($errorText)) { Add-MonitoringFinding $result CRITICAL "Replication job $id reports: $errorText" }
        $lastSync = [long](Get-CheckValue $status 'last_sync' 0)
        if ($lastSync -gt 0) {
            $age = [Math]::Round(([DateTime]::UtcNow - [DateTimeOffset]::FromUnixTimeSeconds($lastSync).UtcDateTime).TotalMinutes, 2)
            Add-MonitoringMetric $result "$id.last_sync_age_minutes" $age
            if ($age -gt $maxAge) { Add-MonitoringFinding $result CRITICAL "Replication job $id last synchronized $age minutes ago (maximum $maxAge)." }
        }
        else { Add-MonitoringFinding $result CRITICAL "Replication job $id has never completed a synchronization." }
    }
    if ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "All $($jobs.Count) replication job(s) are healthy." }
    return $result
}

function Test-PVEHAHealth {
    param($Snapshot, $Config)
    $result = New-MonitoringResult 'Proxmox HA health'
    if (Add-DomainFailure $result $Snapshot.HA 'HA') { return $result }
    $services = @($Snapshot.HA.Data | Where-Object { (Get-CheckValue $_ 'type') -eq 'service' })
    if ($services.Count -eq 0) { Add-MonitoringFinding $result OK 'HA is not configured.'; return $result }
    foreach ($service in $services) {
        $id = [string](Get-CheckValue $service 'sid' (Get-CheckValue $service 'id' 'unknown'))
        $state = [string](Get-CheckValue $service 'status' (Get-CheckValue $service 'state' 'unknown'))
        if ($state -notin 'started', 'running') { Add-MonitoringFinding $result CRITICAL "HA resource $id is '$state'." }
    }
    if ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "All $($services.Count) HA resource(s) are healthy." }
    return $result
}

function Test-PVECephHealth {
    param($Snapshot, $Config)
    $result = New-MonitoringResult 'Proxmox Ceph health'
    $cephStorage = @($Snapshot.Storage.Data | Where-Object {
        ([string](Get-CheckValue $_ 'type' '')).ToLowerInvariant() -in 'rbd', 'cephfs' -or
        ([string](Get-CheckValue $_ 'plugintype' '')).ToLowerInvariant() -in 'rbd', 'cephfs'
    })
    $statusDomain = Get-CheckValue $Snapshot 'CephStatus' ([pscustomobject]@{ Data = @(); Error = $null })
    $statusError = Get-CheckValue $statusDomain 'Error'
    if ($null -ne $statusError) {
        if ($cephStorage.Count -eq 0 -and [int](Get-CheckValue $statusError 'HttpStatusCode' 0) -eq 500) {
            Add-MonitoringFinding $result OK 'Ceph is not configured.'
            return $result
        }
        Add-DomainFailure $result $statusDomain 'Ceph status' | Out-Null
        return $result
    }

    $status = @($statusDomain.Data) | Select-Object -First 1
    if ($null -eq $status) {
        if ($cephStorage.Count -eq 0) { Add-MonitoringFinding $result OK 'Ceph is not configured.' }
        else { Add-MonitoringFinding $result UNKNOWN 'Ceph storage is configured but the status response was empty.' }
        return $result
    }

    $healthObject = Get-CheckValue $status 'health' ([pscustomobject]@{})
    $health = ([string](Get-CheckValue $healthObject 'status' (Get-CheckValue $status 'status' 'UNKNOWN'))).ToUpperInvariant()
    Add-MonitoringMetric $result 'ceph.health' $health
    if ($health -eq 'HEALTH_WARN') { Add-MonitoringFinding $result WARNING 'Ceph reports HEALTH_WARN.' }
    elseif ($health -ne 'HEALTH_OK') { Add-MonitoringFinding $result CRITICAL "Ceph reports $health." }

    $monMap = Get-CheckValue $status 'monmap' ([pscustomobject]@{})
    $monitors = @(Get-CheckValue $monMap 'mons' @())
    $quorum = @(Get-CheckValue $status 'quorum_names' @())
    if ($monitors.Count -gt 0) {
        Add-MonitoringMetric $result 'ceph.mon_total' $monitors.Count
        Add-MonitoringMetric $result 'ceph.mon_quorum' $quorum.Count
        if ($quorum.Count -lt $monitors.Count) { Add-MonitoringFinding $result CRITICAL "Only $($quorum.Count) of $($monitors.Count) Ceph monitors are in quorum." }
    }

    $mgrMap = Get-CheckValue $status 'mgrmap' ([pscustomobject]@{})
    $mgrAvailable = Get-CheckValue $mgrMap 'available'
    $activeManager = [string](Get-CheckValue $mgrMap 'active_name' '')
    if ($null -ne $mgrAvailable -or -not [string]::IsNullOrWhiteSpace($activeManager)) {
        $managerOnline = (-not [string]::IsNullOrWhiteSpace($activeManager)) -or [bool]$mgrAvailable
        Add-MonitoringMetric $result 'ceph.mgr_active' ([int]$managerOnline)
        if (-not $managerOnline) { Add-MonitoringFinding $result CRITICAL 'Ceph has no active manager.' }
    }

    $outerOsdMap = Get-CheckValue $status 'osdmap' ([pscustomobject]@{})
    $osdMap = Get-CheckValue $outerOsdMap 'osdmap' $outerOsdMap
    $totalOsds = [int](Get-CheckValue $osdMap 'num_osds' 0)
    $upOsds = [int](Get-CheckValue $osdMap 'num_up_osds' 0)
    $inOsds = [int](Get-CheckValue $osdMap 'num_in_osds' 0)
    Add-MonitoringMetric $result 'ceph.osd_total' $totalOsds
    Add-MonitoringMetric $result 'ceph.osd_up' $upOsds
    Add-MonitoringMetric $result 'ceph.osd_in' $inOsds
    if ($totalOsds -gt 0 -and $upOsds -lt $totalOsds) { Add-MonitoringFinding $result CRITICAL "$($totalOsds - $upOsds) of $totalOsds Ceph OSDs are down." }
    if ($totalOsds -gt 0 -and $inOsds -lt $totalOsds) { Add-MonitoringFinding $result WARNING "$($totalOsds - $inOsds) of $totalOsds Ceph OSDs are out." }

    $metadata = Get-CheckValue $Snapshot 'CephMetadata'
    if ($null -ne $metadata -and $null -ne (Get-CheckValue $metadata 'Error')) { Add-MonitoringFinding $result WARNING 'Ceph daemon metadata could not be collected.' }
    if ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "Ceph is $health; MON quorum, MGR and all $totalOsds OSD(s) are healthy." }
    return $result
}

function Add-ZfsVdevFindings {
    param($Result, $Vdev, [string]$Node, [string]$Pool)
    if ($null -eq $Vdev) { return }
    $name = [string](Get-CheckValue $Vdev 'name' 'unknown')
    $state = ([string](Get-CheckValue $Vdev 'state' '')).ToUpperInvariant()
    if ($state -in 'DEGRADED', 'FAULTED', 'OFFLINE', 'UNAVAIL', 'REMOVED', 'SUSPENDED') { Add-MonitoringFinding $Result CRITICAL "ZFS vdev $name in $Node/$Pool is $state." }
    foreach ($counter in 'read', 'write', 'cksum') {
        $value = [long](Get-CheckValue $Vdev $counter 0)
        if ($value -gt 0) { Add-MonitoringFinding $Result CRITICAL "ZFS vdev $name in $Node/$Pool reports $value $counter error(s)." }
    }
    foreach ($child in @(Get-CheckValue $Vdev 'children' @())) { Add-ZfsVdevFindings -Result $Result -Vdev $child -Node $Node -Pool $Pool }
}

function Test-PVEZfsHealth {
    param($Snapshot, $Config)
    $result = New-MonitoringResult 'Proxmox ZFS pool and scrub health'
    $settings = Get-CheckValue $Config 'ZFS' ([pscustomobject]@{})
    $warningAge = [double](Get-CheckValue $settings 'ScrubWarningAgeDays' 35)
    $criticalAge = [double](Get-CheckValue $settings 'ScrubCriticalAgeDays' 45)
    $poolCount = 0
    foreach ($entry in @(Get-CheckEntries (Get-CheckValue $Snapshot 'ZfsPools' ([ordered]@{})))) {
        $node = $entry.Name; $domain = $entry.Value
        if (Add-DomainFailure $result $domain "ZFS pools on $node") { continue }
        foreach ($pool in @($domain.Data)) {
            $poolCount++
            $poolName = [string](Get-CheckValue $pool 'name' 'unknown')
            $health = ([string](Get-CheckValue $pool 'health' 'UNKNOWN')).ToUpperInvariant()
            Add-MonitoringMetric $result "$node.$poolName.health" $health
            if ($health -ne 'ONLINE') { Add-MonitoringFinding $result CRITICAL "ZFS pool $node/$poolName is $health." }
            $detailWrapper = @(Get-CheckValue $Snapshot 'ZfsPoolDetails' @() | Where-Object { (Get-CheckValue $_ 'node') -eq $node -and (Get-CheckValue $_ 'pool') -eq $poolName }) | Select-Object -First 1
            if ($null -eq $detailWrapper) { Add-MonitoringFinding $result UNKNOWN "No details were collected for ZFS pool $node/$poolName."; continue }
            if (Add-DomainFailure $result $detailWrapper.Result "ZFS pool $node/$poolName details") { continue }
            $detail = @($detailWrapper.Result.Data) | Select-Object -First 1
            if ($null -eq $detail) { Add-MonitoringFinding $result UNKNOWN "ZFS pool $node/$poolName returned empty details."; continue }
            Add-ZfsVdevFindings -Result $result -Vdev $detail -Node $node -Pool $poolName
            $dataErrors = [string](Get-CheckValue $detail 'errors' '')
            if (-not [string]::IsNullOrWhiteSpace($dataErrors) -and $dataErrors -notmatch '^No known data errors$') { Add-MonitoringFinding $result CRITICAL "ZFS pool $node/$poolName reports: $dataErrors" }
            $scan = [string](Get-CheckValue $detail 'scan' '')
            if ([string]::IsNullOrWhiteSpace($scan) -or $scan -match '^none requested') { Add-MonitoringFinding $result WARNING "ZFS pool $node/$poolName has no recorded scrub."; continue }
            Add-MonitoringMetric $result "$node.$poolName.scrub" $scan
            if ($scan -match 'with\s+([1-9][0-9]*)\s+errors?') { Add-MonitoringFinding $result CRITICAL "ZFS pool $node/$poolName scrub reports $($Matches[1]) error(s)." }
            elseif ($scan -match 'scrub canceled') { Add-MonitoringFinding $result WARNING "ZFS pool $node/$poolName scrub was canceled." }
            elseif ($scan -match 'scrub in progress') { continue }
            elseif ($scan -match '\son\s+(.+)$') {
                $completionText = [string]$Matches[1]
                $completed = [DateTime]::MinValue
                [string[]]$formats = @('ddd MMM dd HH:mm:ss yyyy', 'ddd MMM d HH:mm:ss yyyy')
                if ([DateTime]::TryParseExact($completionText, $formats, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AllowWhiteSpaces, [ref]$completed)) {
                    $ageDays = [Math]::Round(((Get-Date).ToUniversalTime() - $completed.ToUniversalTime()).TotalDays, 2)
                    Add-MonitoringMetric $result "$node.$poolName.scrub_age_days" $ageDays
                    Test-Threshold $result "ZFS pool $node/$poolName scrub age in days" $ageDays $warningAge $criticalAge
                }
                else { Add-MonitoringFinding $result WARNING "ZFS pool $node/$poolName scrub completion time could not be parsed." }
            }
        }
    }
    if ($poolCount -eq 0 -and $result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK 'ZFS is not configured.' }
    elseif ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "All $poolCount ZFS pool(s) and recorded scrubs are healthy." }
    return $result
}

function Invoke-PVEHealthCheck {
    param([Parameter(Mandatory)][ValidateSet('Cluster', 'Node', 'Storage', 'Guest', 'Backup', 'Replication', 'HA', 'Ceph', 'Zfs')][string]$Check, [Parameter(Mandatory)]$Snapshot, [Parameter(Mandatory)]$Config)
    & "Test-PVE${Check}Health" -Snapshot $Snapshot -Config $Config
}

#endregion

#region ProxmoxPublic

function New-ProxmoxMonitorConfig {
    param(
        [string]$ClusterName,
        [string]$Server,
        [string]$TokenID,
        [string]$TlsMode,
        [string]$CertificateSha256,
        [int]$TimeoutSec,
        [bool]$RequireQuorum,
        [int[]]$ExpectedRunningVMIDs,
        [int[]]$ExpectedBackupVMIDs
    )

    [pscustomobject]@{
        SchemaVersion = 1
        ClusterName = $ClusterName
        Server = $Server
        TokenID = $TokenID
        Tls = [pscustomobject]@{ Mode = $TlsMode; CertificateSha256 = $CertificateSha256 }
        TimeoutSec = $TimeoutSec
        Cluster = [pscustomobject]@{ RequireQuorum = $RequireQuorum }
        Nodes = [pscustomobject]@{
            CpuWarningPercent = 80; CpuCriticalPercent = 95
            MemoryWarningPercent = 85; MemoryCriticalPercent = 95
            LoadPerCoreWarning = 1.0; LoadPerCoreCritical = 2.0
        }
        Storage = [pscustomobject]@{
            UsageWarningPercent = 80; UsageCriticalPercent = 90
            FreeWarningGB = 20; FreeCriticalGB = 10
            LifeRemainingWarningPercent = 20; LifeRemainingCriticalPercent = 10
        }
        ZFS = [pscustomobject]@{ ScrubWarningAgeDays = 35; ScrubCriticalAgeDays = 45 }
        Guests = [pscustomobject]@{
            ExpectedRunningVMIDs = @($ExpectedRunningVMIDs)
            CpuWarningPercent = 80; CpuCriticalPercent = 95
            MemoryWarningPercent = 85; MemoryCriticalPercent = 95
            DiskWarningPercent = 80; DiskCriticalPercent = 90
        }
        Backups = [pscustomobject]@{ ExpectedVMIDs = @($ExpectedBackupVMIDs); MaximumAgeHours = 26 }
        Replication = [pscustomobject]@{ MaximumSyncAgeMinutes = 30 }
    }
}

function ConvertTo-ProxmoxMetricList {
    param($Metrics)
    $items = [Collections.Generic.List[object]]::new()
    foreach ($entry in $Metrics.GetEnumerator()) { $items.Add([pscustomobject]@{ Name = [string]$entry.Key; Value = $entry.Value }) }
    return @($items)
}

function Select-ProxmoxProperties {
    param([object[]]$InputObject, [string[]]$Property)
    @($InputObject | ForEach-Object {
        $source = $_; $selected = [ordered]@{}
        foreach ($name in $Property) {
            $value = Get-PVEValue -Object $source -Name $name
            if ($null -ne $value) { $selected[$name] = $value }
        }
        [pscustomobject]$selected
    })
}

function Get-ProxmoxObjectEntries {
    param($Object)
    if ($Object -is [Collections.IDictionary]) {
        return @($Object.Keys | ForEach-Object { [pscustomobject]@{ Name = [string]$_; Value = $Object[$_] } })
    }
    if ($null -eq $Object) { return @() }
    return @($Object.PSObject.Properties | Where-Object { $_.MemberType -in 'NoteProperty', 'Property' })
}

function Get-ProxmoxCounterStatePath {
    param([string]$StateDirectory, [string]$ClusterName, [string]$Server)
    if ([string]::IsNullOrWhiteSpace($StateDirectory)) { return $null }
    $sha256 = [Security.Cryptography.SHA256]::Create()
    try { $hash = ([BitConverter]::ToString($sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($Server.ToLowerInvariant())))).Replace('-', '').Substring(0, 12) }
    finally { $sha256.Dispose() }
    return (Join-Path $StateDirectory ("$ClusterName-$hash-counters.json"))
}

function Write-ProxmoxCounterState {
    param($Snapshot, [string]$Path)
    if ([string]::IsNullOrWhiteSpace($Path)) { return }
    $guests = @($Snapshot.Guests.Data | ForEach-Object {
        [pscustomobject]@{
            type = Get-PVEValue $_ 'type'
            vmid = Get-PVEValue $_ 'vmid'
            netin = Get-PVEValue $_ 'netin' 0
            netout = Get-PVEValue $_ 'netout' 0
        }
    })
    Write-PVESnapshot -Path $Path -Snapshot ([pscustomobject]@{
        SchemaVersion = 1
        CollectedAtUtc = $Snapshot.CollectedAtUtc
        Guests = [pscustomobject]@{ Data = $guests }
    })
}

function New-ProxmoxEmptyInventory {
    [pscustomobject][ordered]@{
        Nodes = @()
        Storage = @()
        Disks = @()
        Guests = @()
        GuestNetworkRates = @()
        BackupTasks = @()
        ReplicationJobs = @()
        HAStatus = @()
        CephStatus = @()
        CephMetadata = @()
        ZfsPools = @()
    }
}

function New-ProxmoxCompleteReport {
    param($Snapshot, $Config, [string]$ModuleVersion)

    $checks = [Collections.Generic.List[object]]::new()
    $rank = @{ OK = 0; WARNING = 1; UNKNOWN = 2; CRITICAL = 3 }
    $overallStatus = 'OK'
    foreach ($checkName in 'Cluster', 'Node', 'Storage', 'Guest', 'Backup', 'Replication', 'HA', 'Ceph', 'Zfs') {
        $result = Invoke-PVEHealthCheck -Check $checkName -Snapshot $Snapshot -Config $Config
        if ($rank[$result.Status] -gt $rank[$overallStatus]) { $overallStatus = $result.Status }
        $checks.Add([pscustomobject]@{
            Check = $checkName
            Status = [string]$result.Status
            Findings = @($result.Findings | ForEach-Object { [pscustomobject]@{ Status = [string]$_.Status; Message = [string]$_.Message } })
            Metrics = @(ConvertTo-ProxmoxMetricList $result.Metrics)
        })
    }

    $diskInventory = [Collections.Generic.List[object]]::new()
    foreach ($nodeEntry in @(Get-ProxmoxObjectEntries $Snapshot.Disks)) {
        foreach ($disk in @($nodeEntry.Value.Data)) {
            foreach ($item in @(Select-ProxmoxProperties @($disk) @('devpath', 'model', 'serial', 'type', 'health', 'wearout', 'size'))) {
                $item | Add-Member -NotePropertyName node -NotePropertyValue $nodeEntry.Name -Force
                $diskInventory.Add($item)
            }
        }
    }

    $zfsInventory = [Collections.Generic.List[object]]::new()
    foreach ($nodeEntry in @(Get-ProxmoxObjectEntries $Snapshot.ZfsPools)) {
        foreach ($pool in @($nodeEntry.Value.Data)) {
            foreach ($item in @(Select-ProxmoxProperties @($pool) @('name', 'health', 'size', 'alloc', 'free', 'frag', 'dedup'))) {
                $item | Add-Member -NotePropertyName node -NotePropertyValue $nodeEntry.Name -Force
                $detailWrapper = @($Snapshot.ZfsPoolDetails | Where-Object { (Get-PVEValue $_ 'node') -eq $nodeEntry.Name -and (Get-PVEValue $_ 'pool') -eq (Get-PVEValue $pool 'name') }) | Select-Object -First 1
                if ($null -ne $detailWrapper -and $null -eq $detailWrapper.Result.Error) {
                    $detail = @($detailWrapper.Result.Data) | Select-Object -First 1
                    foreach ($name in 'state', 'scan', 'errors') {
                        $value = Get-PVEValue $detail $name
                        if ($null -ne $value) { $item | Add-Member -NotePropertyName $name -NotePropertyValue $value -Force }
                    }
                }
                $zfsInventory.Add($item)
            }
        }
    }

    [pscustomobject][ordered]@{
        SchemaVersion = 1
        GeneratedAtUtc = [DateTime]::UtcNow.ToString('o')
        ModuleVersion = $ModuleVersion
        ClusterName = [string]$Config.ClusterName
        Server = [string]$Config.Server
        CollectedAtUtc = [string]$Snapshot.CollectedAtUtc
        OverallStatus = $overallStatus
        Checks = @($checks)
        Inventory = [pscustomobject][ordered]@{
            Nodes = @(Select-ProxmoxProperties @($Snapshot.Nodes.Data) @('node', 'status', 'cpu', 'maxcpu', 'mem', 'maxmem', 'uptime'))
            Storage = @(Select-ProxmoxProperties @($Snapshot.Storage.Data) @('storage', 'node', 'type', 'plugintype', 'status', 'disk', 'maxdisk'))
            Disks = @($diskInventory)
            Guests = @(Select-ProxmoxProperties @($Snapshot.Guests.Data) @('vmid', 'name', 'type', 'node', 'status', 'cpu', 'maxcpu', 'mem', 'maxmem', 'disk', 'maxdisk', 'uptime'))
            GuestNetworkRates = @(Select-ProxmoxProperties @($Snapshot.GuestRates) @('vmid', 'type', 'netin_bps', 'netout_bps'))
            BackupTasks = @(Select-ProxmoxProperties @($Snapshot.Backups.Data) @('id', 'node', 'status', 'starttime', 'endtime', 'upid'))
            ReplicationJobs = @(Select-ProxmoxProperties @($Snapshot.Replication.Data) @('id', 'source', 'target', 'type', 'disable'))
            HAStatus = @(Select-ProxmoxProperties @($Snapshot.HA.Data) @('sid', 'type', 'state', 'status', 'node', 'group'))
            CephStatus = @($Snapshot.CephStatus.Data)
            CephMetadata = @($Snapshot.CephMetadata.Data)
            ZfsPools = @($zfsInventory)
        }
    }
}

function Invoke-ProxmoxMonitor {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidatePattern('^https://')][string]$Server,
        [Parameter(Mandatory)][ValidatePattern('^[^!\s]+![^=\s]+$')][string]$TokenID,
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$TokenSecret,
        [ValidatePattern('^[A-Za-z0-9._-]+$')][string]$ClusterName = 'default',
        [ValidateSet('Ignore', 'Validate', 'Pin')][string]$TlsMode = 'Ignore',
        [string]$CertificateSha256 = '',
        [ValidateRange(1, 300)][int]$TimeoutSec = 30,
        [int[]]$ExpectedRunningVMIDs = @(),
        [int[]]$ExpectedBackupVMIDs = @(),
        [switch]$Standalone,
        [string]$StateDirectory = (Join-Path $env:ProgramData 'N-able\ProxmoxMonitor\State'),
        [ValidateSet('Json', 'Object')][string]$OutputFormat = 'Json',
        [switch]$Compress
    )

    $moduleVersion = if ($ExecutionContext.SessionState.Module) { [string]$ExecutionContext.SessionState.Module.Version } else { 'source' }
    $secretValue = $TokenSecret
    try {
        $config = New-ProxmoxMonitorConfig -ClusterName $ClusterName -Server $Server -TokenID $TokenID `
            -TlsMode $TlsMode -CertificateSha256 $CertificateSha256 -TimeoutSec $TimeoutSec `
            -RequireQuorum (-not $Standalone) -ExpectedRunningVMIDs $ExpectedRunningVMIDs -ExpectedBackupVMIDs $ExpectedBackupVMIDs
        $statePath = Get-ProxmoxCounterStatePath -StateDirectory $StateDirectory -ClusterName $ClusterName -Server $Server
        $previousSnapshot = if ($null -ne $statePath) { Read-PVESnapshot -Path $statePath } else { $null }
        $snapshot = New-PVESnapshot -Config $config -PreviousSnapshot $previousSnapshot -TokenSecret $secretValue
        $secretValue = $null
        Write-ProxmoxCounterState -Snapshot $snapshot -Path $statePath
        $report = New-ProxmoxCompleteReport -Snapshot $snapshot -Config $config -ModuleVersion $moduleVersion
    }
    catch {
        $secretValue = $null
        $category = [string]$_.Exception.Data['PVECategory']
        $status = if ($category -in 'InvalidResponse', 'Configuration' -or $_.Exception -is [IO.InvalidDataException]) { 'UNKNOWN' } else { 'CRITICAL' }
        $message = $_.Exception.Message
        $failedChecks = @('Cluster', 'Node', 'Storage', 'Guest', 'Backup', 'Replication', 'HA', 'Ceph', 'Zfs' | ForEach-Object {
            [pscustomobject]@{
                Check = $_
                Status = $status
                Findings = @([pscustomobject]@{ Status = $status; Message = "Collection failed: $message" })
                Metrics = @()
            }
        })
        $report = [pscustomobject][ordered]@{
            SchemaVersion = 1
            GeneratedAtUtc = [DateTime]::UtcNow.ToString('o')
            ModuleVersion = $moduleVersion
            ClusterName = $ClusterName
            Server = $Server
            CollectedAtUtc = $null
            OverallStatus = $status
            Checks = $failedChecks
            Inventory = New-ProxmoxEmptyInventory
        }
    }

    if ($OutputFormat -eq 'Object') { return $report }
    return ($report | ConvertTo-Json -Depth 30 -Compress:$Compress)
}

#endregion

Export-ModuleMember -Function Invoke-ProxmoxMonitor