helpers/discovery/DiscoveryProvider-HyperV.ps1

<#
.SYNOPSIS
    Hyper-V discovery provider for infrastructure monitoring.
 
.DESCRIPTION
    Registers a Hyper-V discovery provider that uses CIM sessions to
    discover Hyper-V hosts and virtual machines, then builds a monitor
    plan suitable for WhatsUp Gold or standalone use.
 
    Discovery discovers:
      - Hyper-V hosts (IP, OS, CPU, Memory, Uptime)
      - Virtual machines (IP, State, CPU, Memory, Disks, NICs)
 
    Authentication:
      PSCredential (domain\user + password) via CIM sessions (WSMan).
      No external modules required — uses built-in Hyper-V PowerShell.
 
    Prerequisites:
      1. Hyper-V PowerShell module (installed with Hyper-V role or RSAT)
      2. WinRM/WSMan enabled on target hosts
      3. Credentials with Hyper-V Administrators or local admin access
 
.NOTES
    Author: Jason Alberino (jason@wug.ninja)
    Requires: DiscoveryHelpers.ps1 loaded first, Hyper-V PowerShell module
    Encoding: UTF-8 with BOM
#>


# Ensure DiscoveryHelpers is available
if (-not (Get-Command -Name 'Register-DiscoveryProvider' -ErrorAction SilentlyContinue)) {
    $discoveryPath = Join-Path (Split-Path $MyInvocation.MyCommand.Path -Parent) 'DiscoveryHelpers.ps1'
    if (Test-Path $discoveryPath) {
        . $discoveryPath
    }
    else {
        throw "DiscoveryHelpers.ps1 not found. Load it before this provider."
    }
}

# Ensure HypervHelpers is available
$hypervHelpersPath = Join-Path (Split-Path $MyInvocation.MyCommand.Path -Parent) '..\hyperv\HypervHelpers.ps1'
if (Test-Path $hypervHelpersPath) {
    . $hypervHelpersPath
}

# ============================================================================
# Hyper-V Discovery Provider
# ============================================================================

Register-DiscoveryProvider -Name 'HyperV' `
    -MatchAttribute 'DiscoveryHelper.HyperV' `
    -AuthType 'BasicAuth' `
    -DefaultPort 5985 `
    -DefaultProtocol 'https' `
    -IgnoreCertErrors $false `
    -DiscoverScript {
        param($ctx)

        $items = @()
        $targets = if ($ctx.DeviceIP -is [System.Collections.IEnumerable] -and $ctx.DeviceIP -isnot [string]) {
            @($ctx.DeviceIP)
        } else {
            @($ctx.DeviceIP)
        }

        # --- Resolve credential ---
        $cred = $null
        if ($ctx.Credential -and $ctx.Credential.PSCredential -and $ctx.Credential.PSCredential -is [PSCredential]) {
            $cred = $ctx.Credential.PSCredential
        }
        elseif ($ctx.Credential -and $ctx.Credential.Username -and $ctx.Credential.Password) {
            $secPwd = ConvertTo-SecureString $ctx.Credential.Password -AsPlainText -Force
            $cred = [PSCredential]::new($ctx.Credential.Username, $secPwd)
        }
        elseif ($ctx.Credential -and $ctx.Credential -is [PSCredential]) {
            $cred = $ctx.Credential
        }

        if (-not $cred) {
            Write-Warning "No valid Hyper-V credential available."
            return $items
        }

        # ================================================================
        # Phase 1: Connect to each host and enumerate VMs
        # ================================================================
        $hostMap = @{}  # hostName -> @{ IP; OS; CPUModel; RAMTotal; ... }
        $vmMap   = @{}  # "host:vmname" -> @{ ... }
        $clusterInfo = $null  # populated if any target belongs to a cluster

        # --- Helper: Connect to a remote target (WSMan -> DCOM -> WMI) ---
        # Returns @{ Session; ConnMethod; UseDirect }
        function Connect-HypervTarget {
            param([string]$Target, [PSCredential]$Cred)
            $result = @{ Session = $null; ConnMethod = $null; UseDirect = $false }

            Write-Host " Trying WSMan to $Target..." -ForegroundColor DarkGray -NoNewline
            try {
                $opt = New-CimSessionOption -Protocol Wsman
                $result.Session = New-CimSession -ComputerName $Target -Credential $Cred -SessionOption $opt -ErrorAction Stop
                $result.ConnMethod = 'WSMan'
                Write-Host " OK" -ForegroundColor Green
                return $result
            } catch {
                Write-Host " failed" -ForegroundColor DarkYellow
                Write-Verbose "WSMan failed for $Target : $_"
            }

            Write-Host " Trying DCOM to $Target..." -ForegroundColor DarkGray -NoNewline
            try {
                $opt = New-CimSessionOption -Protocol Dcom
                $result.Session = New-CimSession -ComputerName $Target -Credential $Cred -SessionOption $opt -ErrorAction Stop
                $result.ConnMethod = 'DCOM'
                $result.UseDirect = $true
                Write-Host " OK" -ForegroundColor Green
                return $result
            } catch {
                Write-Host " failed" -ForegroundColor DarkYellow
                Write-Verbose "DCOM failed for $Target : $_"
            }

            Write-Host " Trying WMI to $Target..." -ForegroundColor DarkGray -NoNewline
            try {
                $test = Get-WmiObject -Class Win32_OperatingSystem -ComputerName $Target -Credential $Cred -ErrorAction Stop
                if ($test) {
                    $result.ConnMethod = 'WMI'
                    $result.UseDirect = $true
                    Write-Host " OK" -ForegroundColor Green
                    return $result
                }
            } catch {
                Write-Host " failed" -ForegroundColor DarkYellow
            }

            return $null
        }

        # --- Helper: Gather host details ---
        function Get-TargetHostInfo {
            param([string]$Target, [PSCredential]$Cred, $Session, [bool]$UseDirect)
            if ($UseDirect) {
                $os  = Get-WmiObject -Class Win32_OperatingSystem -ComputerName $Target -Credential $Cred
                $cs  = Get-WmiObject -Class Win32_ComputerSystem -ComputerName $Target -Credential $Cred
                $cpu = Get-WmiObject -Class Win32_Processor -ComputerName $Target -Credential $Cred | Select-Object -First 1
            } else {
                $os  = Get-CimInstance -CimSession $Session -ClassName Win32_OperatingSystem
                $cs  = Get-CimInstance -CimSession $Session -ClassName Win32_ComputerSystem
                $cpu = Get-CimInstance -CimSession $Session -ClassName Win32_Processor | Select-Object -First 1
            }
            $hostIP = $null
            $netConfigs = $null
            try {
                if ($UseDirect) {
                    $netConfigs = Get-WmiObject -Class Win32_NetworkAdapterConfiguration -ComputerName $Target -Credential $Cred -Filter "IPEnabled = True"
                } else {
                    $netConfigs = Get-CimInstance -CimSession $Session -ClassName Win32_NetworkAdapterConfiguration -Filter "IPEnabled = True"
                }
                $hostIP = $netConfigs |
                    ForEach-Object { $_.IPAddress } |
                    Where-Object { $_ -match '^\d{1,3}(\.\d{1,3}){3}$' -and $_ -notlike '169.254.*' } |
                    Select-Object -First 1
            } catch { }
            if (-not $hostIP) { $hostIP = $Target }

            # Resolve real hostname from Win32_ComputerSystem
            $resolvedName = if ($cs -and $cs.Name) { $cs.Name } else { $Target }

            # CPU topology
            $cpuCount = if ($UseDirect) {
                @(Get-WmiObject -Class Win32_Processor -ComputerName $Target -Credential $Cred).Count
            } else {
                @(Get-CimInstance -CimSession $Session -ClassName Win32_Processor).Count
            }

            # Host disks
            $hostDisks = @()
            try {
                if ($UseDirect) {
                    $hostDisks = @(Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType = 3' -ComputerName $Target -Credential $Cred)
                } else {
                    $hostDisks = @(Get-CimInstance -CimSession $Session -ClassName Win32_LogicalDisk -Filter 'DriveType = 3')
                }
            } catch {}
            $diskParts = @()
            $diskTotalGB = 0
            $diskFreeGB  = 0
            foreach ($d in $hostDisks) {
                $dTotal = [math]::Round([long]$d.Size / 1GB, 0)
                $dFree  = [math]::Round([long]$d.FreeSpace / 1GB, 0)
                $diskTotalGB += $dTotal
                $diskFreeGB  += $dFree
                $diskParts += "$($d.DeviceID) $dFree/$($dTotal) GB"
            }

            # NIC count
            $nicCount = if ($netConfigs) { @($netConfigs).Count } else { 0 }

            # Virtual switch names
            $switchNames = ''
            try {
                if ($UseDirect) {
                    $vSwitches = @(Get-WmiObject -Namespace 'root\virtualization\v2' -Class Msvm_VirtualEthernetSwitch -ComputerName $Target -Credential $Cred)
                } else {
                    $vSwitches = @(Get-CimInstance -CimSession $Session -Namespace 'root\virtualization\v2' -ClassName Msvm_VirtualEthernetSwitch)
                }
                $switchNames = ($vSwitches | ForEach-Object { $_.ElementName } | Select-Object -Unique) -join ', '
            } catch {}

            # Uptime
            $uptimeHours = 0
            try {
                if ($os.LastBootUpTime -is [datetime]) {
                    $uptimeHours = [math]::Round(((Get-Date) - $os.LastBootUpTime).TotalHours, 1)
                } else {
                    $boot = [System.Management.ManagementDateTimeConverter]::ToDateTime($os.LastBootUpTime)
                    $uptimeHours = [math]::Round(((Get-Date) - $boot).TotalHours, 1)
                }
            } catch {}

            return @{
                HostName    = $resolvedName
                IP          = $hostIP
                OSName      = "$($os.Caption)"
                CPUModel    = "$($cpu.Name)"
                CPUSockets  = "$cpuCount"
                CPUCores    = "$($cpu.NumberOfCores)"
                CPULogical  = "$($cpu.NumberOfLogicalProcessors)"
                RAMTotal    = "$([math]::Round($cs.TotalPhysicalMemory / 1GB, 2))"
                RAMFree     = "$([math]::Round($os.FreePhysicalMemory / 1MB, 2))"
                Status      = if ($os.Status -eq 'OK') { 'running' } else { "$($os.Status)" }
                Manufacturer = "$($cs.Manufacturer)"
                Model       = "$($cs.Model)"
                NicCount    = "$nicCount"
                DiskSummary = ($diskParts -join ', ')
                DiskTotalGB = "$diskTotalGB"
                DiskFreeGB  = "$diskFreeGB"
                SwitchNames = $switchNames
                Uptime      = "$uptimeHours hours"
            }
        }

        # --- Helper: Enumerate VMs on a target ---
        function Get-TargetVMs {
            param([string]$Target, [PSCredential]$Cred, $Session, [bool]$UseDirect)
            if (-not $UseDirect -and $Session) {
                return @(Get-VM -CimSession $Session -ErrorAction Stop)
            }
            # WMI / DCOM fallback
            try {
                $wmiVMs = Get-WmiObject -Namespace 'root\virtualization\v2' -Class Msvm_ComputerSystem `
                    -ComputerName $Target -Credential $Cred -ErrorAction Stop |
                    Where-Object { $_.Caption -eq 'Virtual Machine' }
                return @(foreach ($wv in $wmiVMs) {
                    [PSCustomObject]@{
                        Name           = $wv.ElementName
                        VMId           = $wv.Name
                        State          = switch ([int]$wv.EnabledState) { 2 { 'Running' } 3 { 'Off' } 6 { 'Saved' } 9 { 'Paused' } 32768 { 'Paused' } 32769 { 'Suspended' } default { "Unknown($($wv.EnabledState))" } }
                        ProcessorCount = 0
                        CPUUsage       = 0
                        MemoryAssigned = 0
                        _WmiMode       = $true
                    }
                })
            } catch {
                Write-Warning "VM enumeration failed for $Target : $_"
                return @()
            }
        }

        # ==============================================================
        # Phase 1a: Probe for Failover Cluster membership
        # ==============================================================
        # Connect to the first target and check MSCluster namespace.
        # If it's a cluster node, discover all sibling nodes and expand
        # the target list so we enumerate VMs across all nodes.
        # ==============================================================
        $processedTargets = @{}    # prevent duplicates when cluster adds nodes
        $clusterVMOwners  = @{}    # VMId -> OwnerNode from cluster role data
        $clusterNodeStates = @{}   # NodeName -> state string

        # Use a while loop so targets added mid-iteration (cluster nodes) are visited
        [System.Collections.ArrayList]$targets = @($targets)
        $targetIndex = 0
        while ($targetIndex -lt $targets.Count) {
            $seedTarget = $targets[$targetIndex]
            $targetIndex++
            if ($processedTargets.ContainsKey($seedTarget)) { continue }

            $conn = Connect-HypervTarget -Target $seedTarget -Cred $cred
            if (-not $conn) {
                Write-Warning "All connection methods failed for $seedTarget. Skipping."
                continue
            }

            Write-Host " Connected: $seedTarget ($($conn.ConnMethod))" -ForegroundColor DarkGray

            # --- Cluster detection ---
            if (-not $clusterInfo) {
                try {
                    $clusterObj = Get-WmiObject -Namespace 'root\MSCluster' -Class MSCluster_Cluster `
                        -ComputerName $seedTarget -Credential $cred -ErrorAction Stop
                    if ($clusterObj) {
                        $clusterName = $clusterObj.Name
                        Write-Host " Failover Cluster detected: $clusterName" -ForegroundColor Cyan

                        # Discover all cluster nodes
                        $clusterNodes = @(Get-WmiObject -Namespace 'root\MSCluster' -Class MSCluster_Node `
                            -ComputerName $seedTarget -Credential $cred -ErrorAction Stop)

                        $nodeNames = @()
                        foreach ($n in $clusterNodes) {
                            $nodeName = "$($n.Name)"
                            $nodeNames += $nodeName
                            $stateStr = switch ([int]$n.State) {
                                0 { 'Up' }
                                1 { 'Down' }
                                2 { 'Paused' }
                                3 { 'Joining' }
                                default { "Unknown($($n.State))" }
                            }
                            $clusterNodeStates[$nodeName] = $stateStr
                            Write-Host " Node: $nodeName ($stateStr)" -ForegroundColor DarkGray
                        }

                        # Quorum info
                        $quorumType = ''
                        try {
                            $quorumRes = Get-WmiObject -Namespace 'root\MSCluster' -Class MSCluster_Cluster `
                                -ComputerName $seedTarget -Credential $cred -ErrorAction Stop
                            $quorumType = switch ([int]$quorumRes.QuorumType) {
                                1 { 'NodeMajority' }
                                2 { 'NodeAndDiskMajority' }
                                3 { 'NodeAndFileShareMajority' }
                                4 { 'DiskOnly' }
                                5 { 'NodeAndCloudWitness' }
                                default { "Type$($quorumRes.QuorumType)" }
                            }
                        } catch { }

                        # Enumerate clustered VM resource groups for owner tracking
                        try {
                            $clusterGroups = Get-WmiObject -Namespace 'root\MSCluster' -Class MSCluster_ResourceGroup `
                                -ComputerName $seedTarget -Credential $cred -ErrorAction Stop
                            foreach ($grp in $clusterGroups) {
                                # GroupType 111 = Virtual Machine; also check name pattern
                                if ([int]$grp.GroupType -eq 111 -or $grp.Name -match '^[0-9a-f]{8}-') {
                                    $clusterVMOwners[$grp.Name] = "$($grp.OwnerNode)"
                                }
                            }
                            Write-Host " Clustered VM roles: $($clusterVMOwners.Count)" -ForegroundColor DarkGray
                        } catch {
                            Write-Verbose "Could not enumerate cluster VM groups: $_"
                        }

                        $clusterInfo = @{
                            ClusterName = $clusterName
                            Nodes       = $nodeNames
                            NodeStates  = $clusterNodeStates
                            QuorumType  = $quorumType
                            VMOwners    = $clusterVMOwners
                        }

                        # Resolve cluster node IPs via MSCluster_NetworkInterface
                        # so we can connect even when the client lacks DNS for the domain
                        $clusterNodeIPs = @{}
                        try {
                            $clNetIfs = Get-WmiObject -Namespace 'root\MSCluster' -Class MSCluster_NetworkInterface `
                                -ComputerName $seedTarget -Credential $cred -ErrorAction Stop
                            foreach ($nif in $clNetIfs) {
                                $nNode = "$($nif.Node)"
                                $nIP   = "$($nif.IPAddress)"
                                if ($nIP -and $nIP -match '^\d{1,3}(\.\d{1,3}){3}$' -and $nIP -notlike '169.254.*') {
                                    if (-not $clusterNodeIPs.ContainsKey($nNode)) {
                                        $clusterNodeIPs[$nNode] = $nIP
                                    }
                                }
                            }
                        } catch {
                            Write-Verbose "Could not resolve cluster node IPs: $_"
                        }
                        $clusterInfo['NodeIPs'] = $clusterNodeIPs

                        # Add sibling nodes to targets so we enumerate VMs on each
                        # Try IP first (client may not have DNS for the cluster domain),
                        # fall back to hostname, skip if already in targets or is the seed
                        foreach ($nodeName in $nodeNames) {
                            $nodeIP = if ($clusterNodeIPs.ContainsKey($nodeName)) { $clusterNodeIPs[$nodeName] } else { $null }

                            # Skip if this node (by name or IP) is already covered
                            $alreadyCovered = ($targets -contains $nodeName) -or ($nodeName -eq $seedTarget) -or
                                              ($processedTargets.ContainsKey($nodeName))
                            if ($nodeIP) {
                                $alreadyCovered = $alreadyCovered -or ($targets -contains $nodeIP) -or
                                                  ($nodeIP -eq $seedTarget) -or ($processedTargets.ContainsKey($nodeIP))
                            }
                            if ($alreadyCovered) { continue }

                            # Prefer IP for connectivity, fall back to hostname
                            $nodeTarget = if ($nodeIP) { $nodeIP } else { $nodeName }
                            [void]$targets.Add($nodeTarget)
                            $ipDisplay = if ($nodeIP) { " ($nodeIP)" } else { ' (no IP resolved)' }
                            Write-Host " Auto-added cluster node: $nodeName$ipDisplay" -ForegroundColor DarkGray
                        }
                    }
                } catch {
                    Write-Verbose "No failover cluster on $seedTarget (root\MSCluster not available)."
                }
            }

            # --- Gather host info ---
            $processedTargets[$seedTarget] = $true
            try {
                $hostInfo = Get-TargetHostInfo -Target $seedTarget -Cred $cred -Session $conn.Session -UseDirect $conn.UseDirect
                $hostInfo['ConnMethod'] = $conn.ConnMethod
                $resolvedHostName = $hostInfo.HostName
                # Mark resolved hostname as processed to avoid duplicate when cluster adds it by name
                $processedTargets[$resolvedHostName] = $true
                if ($clusterInfo) {
                    $hostInfo['ClusterName'] = $clusterInfo.ClusterName
                    $nodeStateKey = if ($clusterNodeStates.ContainsKey($seedTarget)) { $seedTarget }
                        elseif ($clusterNodeStates.ContainsKey($resolvedHostName)) { $resolvedHostName }
                        else { $null }
                    $hostInfo['NodeState'] = if ($nodeStateKey) { $clusterNodeStates[$nodeStateKey] } else { 'Unknown' }
                }
                $hostMap[$resolvedHostName] = $hostInfo
            } catch {
                Write-Warning "Error getting host info for $seedTarget : $_"
            }

            # --- Enumerate VMs ---
            try {
                $vms = Get-TargetVMs -Target $seedTarget -Cred $cred -Session $conn.Session -UseDirect $conn.UseDirect
                foreach ($vm in $vms) {
                    $vmIP = $null
                    if (-not $conn.UseDirect -and $conn.Session) {
                        try {
                            $nics = Get-VMNetworkAdapter -CimSession $conn.Session -VM $vm -ErrorAction SilentlyContinue
                            $vmIP = $nics |
                                ForEach-Object { $_.IPAddresses } |
                                Where-Object { $_ -match '^\d{1,3}(\.\d{1,3}){3}$' -and $_ -notlike '169.254.*' } |
                                Select-Object -First 1
                        } catch { }
                    }

                    $memAssigned = if ($vm.MemoryAssigned) { [math]::Round($vm.MemoryAssigned / 1GB, 2) } else { 0 }

                    # Determine owner node — only for clustered VMs
                    $ownerNode = ''
                    if ($clusterInfo -and $clusterInfo.VMOwners.Count -gt 0) {
                        $vmIdStr = "$($vm.VMId)"
                        if ($clusterInfo.VMOwners.ContainsKey($vmIdStr)) {
                            $ownerNode = $clusterInfo.VMOwners[$vmIdStr]
                        }
                        # Also try by VM name (some clusters key by name)
                        $vmNameStr = "$($vm.Name)"
                        if ($clusterInfo.VMOwners.ContainsKey($vmNameStr)) {
                            $ownerNode = $clusterInfo.VMOwners[$vmNameStr]
                        }
                    }

                    $vmKey = "${resolvedHostName}:$($vm.Name)"
                    $vmMap[$vmKey] = @{
                        Name       = "$($vm.Name)"
                        VMId       = "$($vm.VMId)"
                        Host       = $resolvedHostName
                        OwnerNode  = $ownerNode
                        State      = "$($vm.State)"
                        IP         = $vmIP
                        CPUCount   = "$($vm.ProcessorCount)"
                        CPUUsage   = "$($vm.CPUUsage)"
                        MemoryGB   = "$memAssigned"
                    }
                }
            } catch {
                Write-Warning "Error enumerating VMs on $seedTarget : $_"
            }

            # Cleanup CIM session
            if ($conn.Session) {
                try { Remove-CimSession -CimSession $conn.Session -ErrorAction SilentlyContinue } catch { }
            }
        }

        # --- If cluster detected, also enumerate VMs from cluster resource
        # groups that weren't found on any live node (failed/offline VMs) ---
        if ($clusterInfo -and $clusterInfo.VMOwners.Count -gt 0) {
            $discoveredVMIds = @{}
            foreach ($vmKey in $vmMap.Keys) { $discoveredVMIds[$vmMap[$vmKey].VMId] = $true }
            foreach ($vmRoleKey in $clusterInfo.VMOwners.Keys) {
                if (-not $discoveredVMIds.ContainsKey($vmRoleKey)) {
                    # This VM role wasn't found on any live node — cluster role exists but VM is offline/failed
                    $ownerNode = $clusterInfo.VMOwners[$vmRoleKey]
                    $syntheticKey = "cluster:$vmRoleKey"
                    $vmMap[$syntheticKey] = @{
                        Name       = $vmRoleKey
                        VMId       = $vmRoleKey
                        Host       = $ownerNode
                        OwnerNode  = $ownerNode
                        State      = 'ClusterOffline'
                        IP         = $null
                        CPUCount   = '0'
                        CPUUsage   = '0'
                        MemoryGB   = '0'
                    }
                    Write-Verbose "Added offline cluster VM: $vmRoleKey (owner: $ownerNode)"
                }
            }
        }

        if ($clusterInfo) {
            Write-Host " Cluster: $($clusterInfo.ClusterName) ($($clusterInfo.Nodes.Count) nodes, $($clusterInfo.VMOwners.Count) VM roles)" -ForegroundColor Cyan
        }
        Write-Verbose "Topology: $($hostMap.Count) hosts, $($vmMap.Count) VMs"

        # ================================================================
        # Phase 2: Build discovery plan
        # ================================================================
        $baseAttrs = @{
            'DiscoveryHelper.HyperV' = 'true'
            'DiscoveryHelper.HyperV.LastRun' = (Get-Date).ToUniversalTime().ToString('o')
        }
        if ($clusterInfo) {
            $baseAttrs['HyperV.ClusterNodes'] = ($clusterInfo.Nodes -join ',')
            $baseAttrs['HyperV.QuorumType'] = $clusterInfo.QuorumType
        }

        # --- Per-Host items ---
        foreach ($hostName in @($hostMap.Keys | Sort-Object)) {
            $hostInfo = $hostMap[$hostName]
            $hostIP   = $hostInfo.IP

            $hostAttrs = $baseAttrs.Clone()
            $hostAttrs['HyperV.DeviceType'] = 'Host'
            $hostAttrs['HyperV.HostName']   = $hostName
            if ($hostIP) { $hostAttrs['HyperV.HostIP'] = $hostIP }
            $hostAttrs['HyperV.OS']         = $hostInfo.OSName
            $hostAttrs['HyperV.CPUModel']   = $hostInfo.CPUModel
            $hostAttrs['HyperV.CPUSockets'] = $hostInfo.CPUSockets
            $hostAttrs['HyperV.CPUCores']   = $hostInfo.CPUCores
            $hostAttrs['HyperV.CPULogical']  = $hostInfo.CPULogical
            $hostAttrs['HyperV.RAMTotalGB'] = $hostInfo.RAMTotal
            $hostAttrs['HyperV.RAMFreeGB']  = $hostInfo.RAMFree
            $hostAttrs['HyperV.NicCount']   = $hostInfo.NicCount
            $hostAttrs['HyperV.DiskSummary']= $hostInfo.DiskSummary
            $hostAttrs['HyperV.DiskTotalGB']= $hostInfo.DiskTotalGB
            $hostAttrs['HyperV.DiskFreeGB'] = $hostInfo.DiskFreeGB
            $hostAttrs['HyperV.SwitchNames']= $hostInfo.SwitchNames
            $hostAttrs['HyperV.Manufacturer'] = $hostInfo.Manufacturer
            $hostAttrs['HyperV.Model']      = $hostInfo.Model
            $hostAttrs['HyperV.Uptime']     = $hostInfo.Uptime
            $hostAttrs['HyperV.Status']     = $hostInfo.Status
            if ($hostInfo.ClusterName) { $hostAttrs['HyperV.ClusterName'] = $hostInfo.ClusterName }
            if ($hostInfo.NodeState)   { $hostAttrs['HyperV.NodeState']   = $hostInfo.NodeState }

            $items += New-DiscoveredItem `
                -Name 'HyperV - Host Status' `
                -ItemType 'ActiveMonitor' `
                -MonitorType 'PowerShell' `
                -MonitorParams @{
                    Description = "Monitors Hyper-V host $hostName connectivity and status"
                } `
                -UniqueKey "HyperV:host:${hostName}:active:status" `
                -Attributes $hostAttrs `
                -Tags @('hyperv', 'host', $hostName, $(if ($hostIP) { $hostIP } else { 'no-ip' }))

            $hostPerfMonitors = @(
                @{ Name = 'HyperV - Host CPU';    Key = 'cpu' }
                @{ Name = 'HyperV - Host Memory'; Key = 'memory' }
                @{ Name = 'HyperV - Host Disk';   Key = 'disk' }
            )
            foreach ($pm in $hostPerfMonitors) {
                $items += New-DiscoveredItem `
                    -Name $pm.Name `
                    -ItemType 'PerformanceMonitor' `
                    -MonitorType 'PowerShell' `
                    -MonitorParams @{
                        Description = "$($pm.Name) for host $hostName"
                    } `
                    -UniqueKey "HyperV:host:${hostName}:perf:$($pm.Key)" `
                    -Attributes $hostAttrs `
                    -Tags @('hyperv', 'host', $hostName, $(if ($hostIP) { $hostIP } else { 'no-ip' }))
            }
        }

        # --- Per-VM items ---
        foreach ($vmKey in @($vmMap.Keys | Sort-Object)) {
            $vmInfo = $vmMap[$vmKey]
            $vmName = $vmInfo.Name
            $vmIP   = $vmInfo.IP
            $vmHost = $vmInfo.Host

            $vmAttrs = $baseAttrs.Clone()
            $vmAttrs['HyperV.DeviceType'] = 'VM'
            $vmAttrs['HyperV.VMName']     = $vmName
            $vmAttrs['HyperV.VMId']       = $vmInfo.VMId
            $vmAttrs['HyperV.Host']       = $vmHost
            $vmAttrs['HyperV.State']      = $vmInfo.State
            $vmAttrs['HyperV.CPUCount']   = $vmInfo.CPUCount
            $vmAttrs['HyperV.MemoryGB']   = $vmInfo.MemoryGB
            if ($vmIP) { $vmAttrs['HyperV.VMIP'] = $vmIP }
            if ($vmInfo.OwnerNode) {
                $vmAttrs['HyperV.OwnerNode'] = $vmInfo.OwnerNode
                if ($clusterInfo) { $vmAttrs['HyperV.ClusterName'] = $clusterInfo.ClusterName }
            }

            $items += New-DiscoveredItem `
                -Name 'HyperV - VM Status' `
                -ItemType 'ActiveMonitor' `
                -MonitorType 'PowerShell' `
                -MonitorParams @{
                    Description = "Monitors VM $vmName state on host $vmHost"
                } `
                -UniqueKey "HyperV:vm:${vmHost}:${vmName}:active:status" `
                -Attributes $vmAttrs `
                -Tags @('hyperv', 'vm', $vmName, $(if ($vmIP) { $vmIP } else { 'no-ip' }), $vmHost)

            $vmPerfMonitors = @(
                @{ Name = 'HyperV - VM CPU';    Key = 'cpu' }
                @{ Name = 'HyperV - VM Memory'; Key = 'memory' }
            )
            foreach ($pm in $vmPerfMonitors) {
                $items += New-DiscoveredItem `
                    -Name $pm.Name `
                    -ItemType 'PerformanceMonitor' `
                    -MonitorType 'PowerShell' `
                    -MonitorParams @{
                        Description = "$($pm.Name) for VM $vmName on $vmHost"
                    } `
                    -UniqueKey "HyperV:vm:${vmHost}:${vmName}:perf:$($pm.Key)" `
                    -Attributes $vmAttrs `
                    -Tags @('hyperv', 'vm', $vmName, $(if ($vmIP) { $vmIP } else { 'no-ip' }), $vmHost)
            }
        }

        $cred = $null; $secPwd = $null
        return $items
    }

# ==============================================================================
# Export-HypervDiscoveryDashboardHtml
# ==============================================================================
function Export-HypervDiscoveryDashboardHtml {
    <#
    .SYNOPSIS
        Generates a Hyper-V dashboard HTML file from live host/VM data.
    .DESCRIPTION
        Reads the Hyper-V dashboard template, injects column definitions
        and row data as JSON, and writes the final HTML to OutputPath.
    .PARAMETER DashboardData
        Array of PSCustomObject rows from Get-HypervDashboard.
    .PARAMETER OutputPath
        File path for the generated HTML.
    .PARAMETER ReportTitle
        Dashboard title shown in header and browser tab.
    .PARAMETER TemplatePath
        Path to Hyperv-Dashboard-Template.html.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$DashboardData,
        [Parameter(Mandatory)][string]$OutputPath,
        [string]$ReportTitle = 'Hyper-V Dashboard',
        [string]$TemplatePath
    )

    if (-not $TemplatePath) {
        $TemplatePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'hyperv\Hyperv-Dashboard-Template.html'
    }
    if (-not (Test-Path $TemplatePath)) {
        Write-Error "Template not found: $TemplatePath"
        return
    }

    $firstObj = $DashboardData | Select-Object -First 1
    $columns = @()
    foreach ($prop in $firstObj.PSObject.Properties) {
        $col = @{
            field      = $prop.Name
            title      = ($prop.Name -creplace '(?<=[a-z])([A-Z])', ' $1').Trim()
            sortable   = $true
            searchable = $true
        }
        if ($prop.Name -eq 'State')     { $col.formatter = 'formatState' }
        if ($prop.Name -eq 'Status')    { $col.formatter = 'formatStatus' }
        if ($prop.Name -eq 'Heartbeat') { $col.formatter = 'formatHeartbeat' }
        if ($prop.Name -eq 'Type')      { $col.formatter = 'formatType' }
        $columns += $col
    }

    $columnsJson = $columns | ConvertTo-Json -Depth 5 -Compress
    # Force array wrapper even for a single item
    $dataJson    = ConvertTo-Json -InputObject @($DashboardData) -Depth 5 -Compress

    $tableConfig = @"
        columns: $columnsJson,
        data: $dataJson
"@


    $html = Get-Content -Path $TemplatePath -Raw
    $html = $html -replace 'replaceThisHere', $tableConfig
    $html = $html -replace 'ReplaceYourReportNameHere', $ReportTitle
    $html = $html -replace 'ReplaceUpdateTimeHere', (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')

    $parentDir = Split-Path $OutputPath -Parent
    if ($parentDir -and -not (Test-Path $parentDir)) {
        New-Item -ItemType Directory -Path $parentDir -Force | Out-Null
    }
    Set-Content -Path $OutputPath -Value $html -Encoding UTF8
    Write-Verbose "Dashboard written to: $OutputPath"
    return $OutputPath
}

# SIG # Begin signature block
# MIIVlwYJKoZIhvcNAQcCoIIViDCCFYQCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCB0xy8oxKVbdg4T
# r4G9DM45M46Da4abnlwEu81POQ+Hx6CCEdMwggVvMIIEV6ADAgECAhBI/JO0YFWU
# jTanyYqJ1pQWMA0GCSqGSIb3DQEBDAUAMHsxCzAJBgNVBAYTAkdCMRswGQYDVQQI
# DBJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcMB1NhbGZvcmQxGjAYBgNVBAoM
# EUNvbW9kbyBDQSBMaW1pdGVkMSEwHwYDVQQDDBhBQUEgQ2VydGlmaWNhdGUgU2Vy
# dmljZXMwHhcNMjEwNTI1MDAwMDAwWhcNMjgxMjMxMjM1OTU5WjBWMQswCQYDVQQG
# EwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMS0wKwYDVQQDEyRTZWN0aWdv
# IFB1YmxpYyBDb2RlIFNpZ25pbmcgUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEBAQUA
# A4ICDwAwggIKAoICAQCN55QSIgQkdC7/FiMCkoq2rjaFrEfUI5ErPtx94jGgUW+s
# hJHjUoq14pbe0IdjJImK/+8Skzt9u7aKvb0Ffyeba2XTpQxpsbxJOZrxbW6q5KCD
# J9qaDStQ6Utbs7hkNqR+Sj2pcaths3OzPAsM79szV+W+NDfjlxtd/R8SPYIDdub7
# P2bSlDFp+m2zNKzBenjcklDyZMeqLQSrw2rq4C+np9xu1+j/2iGrQL+57g2extme
# me/G3h+pDHazJyCh1rr9gOcB0u/rgimVcI3/uxXP/tEPNqIuTzKQdEZrRzUTdwUz
# T2MuuC3hv2WnBGsY2HH6zAjybYmZELGt2z4s5KoYsMYHAXVn3m3pY2MeNn9pib6q
# RT5uWl+PoVvLnTCGMOgDs0DGDQ84zWeoU4j6uDBl+m/H5x2xg3RpPqzEaDux5mcz
# mrYI4IAFSEDu9oJkRqj1c7AGlfJsZZ+/VVscnFcax3hGfHCqlBuCF6yH6bbJDoEc
# QNYWFyn8XJwYK+pF9e+91WdPKF4F7pBMeufG9ND8+s0+MkYTIDaKBOq3qgdGnA2T
# OglmmVhcKaO5DKYwODzQRjY1fJy67sPV+Qp2+n4FG0DKkjXp1XrRtX8ArqmQqsV/
# AZwQsRb8zG4Y3G9i/qZQp7h7uJ0VP/4gDHXIIloTlRmQAOka1cKG8eOO7F/05QID
# AQABo4IBEjCCAQ4wHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD
# VR0OBBYEFDLrkpr/NZZILyhAQnAgNpFcF4XmMA4GA1UdDwEB/wQEAwIBhjAPBgNV
# HRMBAf8EBTADAQH/MBMGA1UdJQQMMAoGCCsGAQUFBwMDMBsGA1UdIAQUMBIwBgYE
# VR0gADAIBgZngQwBBAEwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21v
# ZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEE
# KDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZI
# hvcNAQEMBQADggEBABK/oe+LdJqYRLhpRrWrJAoMpIpnuDqBv0WKfVIHqI0fTiGF
# OaNrXi0ghr8QuK55O1PNtPvYRL4G2VxjZ9RAFodEhnIq1jIV9RKDwvnhXRFAZ/ZC
# J3LFI+ICOBpMIOLbAffNRk8monxmwFE2tokCVMf8WPtsAO7+mKYulaEMUykfb9gZ
# pk+e96wJ6l2CxouvgKe9gUhShDHaMuwV5KZMPWw5c9QLhTkg4IUaaOGnSDip0TYl
# d8GNGRbFiExmfS9jzpjoad+sPKhdnckcW67Y8y90z7h+9teDnRGWYpquRRPaf9xH
# +9/DUp/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggYaMIIEAqADAgECAhBiHW0M
# UgGeO5B5FSCJIRwKMA0GCSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgwFgYD
# VQQKEw9TZWN0aWdvIExpbWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGljIENv
# ZGUgU2lnbmluZyBSb290IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEyMzU5
# NTlaMFQxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzAp
# BgNVBAMTIlNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGiMA0G
# CSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shTUxjI
# ztNsfvxYB5UXeWUzCxEeAEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2A2NV
# DgFigOMYzB2OKhdqfWGVoYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtbam+/3
# 6F09fy1tsB8je/RV0mIk8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP05Zw
# mRmTnAO5/arnY83jeNzhP06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9SJDm
# +qxp4VqpB3MV/h53yl41aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4TpxtwfvjsUe
# dyz8rNyfQJy/aOs5b4s+ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj8pz4
# 4MPZ1f9+YEQIQty/NQd/2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2oQBM
# dlyh2n5HirY4jKnFH/9gRvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1UdIwQY
# MBaAFDLrkpr/NZZILyhAQnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9IritU
# pimqF6TNDDAOBgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNV
# HSUEDDAKBggrBgEFBQcDAzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEsG
# A1UdHwREMEIwQKA+oDyGOmh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1
# YmxpY0NvZGVTaWduaW5nUm9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYGCCsG
# AQUFBzAChjpodHRwOi8vY3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2Rl
# U2lnbmluZ1Jvb3RSNDYucDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0
# aWdvLmNvbTANBgkqhkiG9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6YdURh
# w1aVcdGRP4Wh60BAscjW4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIBD0Zd
# OaWTsyNyBBsMLHqafvIhrCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOtBajj
# cw5+w/KeFvPYfLF/ldYpmlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0OhNc
# WbWDRF/3sBp6fWXhz7DcML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMbOalO
# hOfCipnx8CaLZeVme5yELg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxAMcJs
# zkyeiaerlphwoKx1uHRzNyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVSZ2z7
# 6mKnzAfZxCl/3dq3dUNw4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN/Y5J
# KdGvspbOrTfOXyXvmPL6E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjUYbHH
# j95Ejza63zdrEcxWLDX6xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8X8z2
# Bev6SivBBOHY+uqiirZtg0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1oDE5/
# L9Uo2bC5a4CH2RwwggY+MIIEpqADAgECAhAHnODk0RR/hc05c892LTfrMA0GCSqG
# SIb3DQEBDAUAMFQxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0
# ZWQxKzApBgNVBAMTIlNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYw
# HhcNMjYwMjA5MDAwMDAwWhcNMjkwNDIxMjM1OTU5WjBVMQswCQYDVQQGEwJVUzEU
# MBIGA1UECAwLQ29ubmVjdGljdXQxFzAVBgNVBAoMDkphc29uIEFsYmVyaW5vMRcw
# FQYDVQQDDA5KYXNvbiBBbGJlcmlubzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
# AgoCggIBAPN6aN4B1yYWkI5b5TBj3I0VV/peETrHb6EY4BHGxt8Ap+eT+WpEpJyE
# tRYPxEmNJL3A38Bkg7mwzPE3/1NK570ZBCuBjSAn4mSDIgIuXZnvyBO9W1OQs5d6
# 7MlJLUAEufl18tOr3ST1DeO9gSjQSAE5Nql0QDxPnm93OZBon+Fz3CmE+z3MwAe2
# h4KdtRAnCqwM+/V7iBdbw+JOxolpx+7RVjGyProTENIG3pe/hKvPb501lf8uBAAD
# LdjZr5ip8vIWbf857Yw1Bu10nVI7HW3eE8Cl5//d1ribHlzTzQLfttW+k+DaFsKZ
# BBL56l4YAlIVRsrOiE1kdHYYx6IGrEA809R7+TZA9DzGqyFiv9qmJAbL4fDwetDe
# yIq+Oztz1LvEdy8Rcd0JBY+J4S0eDEFIA3X0N8VcLeAwabKb9AjulKXwUeqCJLvN
# 79CJ90UTZb2+I+tamj0dn+IKMEsJ4v4Ggx72sxFr9+6XziodtTg5Luf2xd6+Phha
# mOxF2px9LObhBLLEMyRsCHZIzVZOFKu9BpHQH7ufGB+Sa80Tli0/6LEyn9+bMYWi
# 2ttn6lLOPThXMiQaooRUq6q2u3+F4SaPlxVFLI7OJVMhar6nW6joBvELTJPmANSM
# jDSRFDfHRCdGbZsL/keELJNy+jZctF6VvxQEjFM8/bazu6qYhrA7AgMBAAGjggGJ
# MIIBhTAfBgNVHSMEGDAWgBQPKssghyi47G9IritUpimqF6TNDDAdBgNVHQ4EFgQU
# 6YF0o0D5AVhKHbVocr8GaSIBibAwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQC
# MAAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwSgYDVR0gBEMwQTA1BgwrBgEEAbIxAQIB
# AwIwJTAjBggrBgEFBQcCARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EM
# AQQBMEkGA1UdHwRCMEAwPqA8oDqGOGh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2Vj
# dGlnb1B1YmxpY0NvZGVTaWduaW5nQ0FSMzYuY3JsMHkGCCsGAQUFBwEBBG0wazBE
# BggrBgEFBQcwAoY4aHR0cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGlj
# Q29kZVNpZ25pbmdDQVIzNi5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNl
# Y3RpZ28uY29tMA0GCSqGSIb3DQEBDAUAA4IBgQAEIsm4xnOd/tZMVrKwi3doAXvC
# wOA/RYQnFJD7R/bSQRu3wXEK4o9SIefye18B/q4fhBkhNAJuEvTQAGfqbbpxow03
# J5PrDTp1WPCWbXKX8Oz9vGWJFyJxRGftkdzZ57JE00synEMS8XCwLO9P32MyR9Z9
# URrpiLPJ9rQjfHMb1BUdvaNayomm7aWLAnD+X7jm6o8sNT5An1cwEAob7obWDM6s
# X93wphwJNBJAstH9Ozs6LwISOX6sKS7CKm9N3Kp8hOUue0ZHAtZdFl6o5u12wy+z
# zieGEI50fKnN77FfNKFOWKlS6OJwlArcbFegB5K89LcE5iNSmaM3VMB2ADV1FEcj
# GSHw4lTg1Wx+WMAMdl/7nbvfFxJ9uu5tNiT54B0s+lZO/HztwXYQUczdsFon3pjs
# Nrsk9ZlalBi5SHkIu+F6g7tWiEv3rtVApmJRnLkUr2Xq2a4nbslUCt4jKs5UX4V1
# nSX8OM++AXoyVGO+iTj7z+pl6XE9Gw/Td6WKKKsxggMaMIIDFgIBATBoMFQxCzAJ
# BgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNl
# Y3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYCEAec4OTRFH+FzTlzz3Yt
# N+swDQYJYIZIAWUDBAIBBQCggYQwGAYKKwYBBAGCNwIBDDEKMAigAoAAoQKAADAZ
# BgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgorBgEEAYI3AgELMQ4wDAYKKwYB
# BAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQgCLhQFnMma4yOC/XSIK6B9z07wu3aTMUv
# Jom6q1aYiOswDQYJKoZIhvcNAQEBBQAEggIAotF3QXoIX1bb829UwiRKhhY47dmr
# Ir17taiZn5Hlchiwd1QVTLrXQVzzxSScdwud0v7dHDPBi59KQxCNqaA1HZKYXNTE
# um3jDC8HxIYXY4L1XIaaQ6ASinedLecFF66v4F+P0APwTCwiqsUE/XGwQ1MfpLTI
# 5JpXdCBaSWBx0+EsdhrkVfi89zxn7+ya+vLHTVZB6w1yYQsmx+oHNLL0Te84rDRG
# oNyQ10LCWJHe1+P3xJpxxOHWsuQIHeG4KrLL8sk7Yj85TMlyEm02LUI8FTJcRpml
# n4mJNrlLh+LjF6Jz8sa3Nm4YUW/YiJdtoOA9lNoELey84v4MrvLR4T9Pjtw4iH/0
# nnnF/7dttsnEu0RQXW7hnieqho67b0IPdWQC1ULddeKyaMixhpwOUYKQVirYGEJT
# vzgZQaHZ+S69QywKE7So95S0RotQMVGpazA6rG4RxJoZ7HMz1hdrl3HOZ0w1Ez4Z
# AfNCzH39p2HKfajzZnH08pYRm4QY9QMI3eNzmArbNTUD8ZzAMBLBZaPVPJausMVq
# /OcmtO0MmAIgslNxRRZJupAguoKpM/TjVpHPtCb3HTTYjc/1sOhDf3+nnFBHI2VD
# ar7COZ5ufloOHvtmOvVF0MGy/VvuNAgz6fZEpv8NNE5wsOAjp3OBymoKA1GEJtq2
# Msg/VcTm6cNr3b8=
# SIG # End signature block