helpers/discovery/Setup-Nutanix-Discovery.ps1

<#
.SYNOPSIS
    Nutanix Discovery -- Discover clusters, hosts, and VMs from Nutanix Prism,
    then optionally push to WhatsUp Gold.
 
.DESCRIPTION
    Interactive script that discovers Nutanix infrastructure via the Prism v2.0
    REST API, then lets you choose what to do with the results:
 
      [1] Push monitors to WhatsUp Gold (creates devices + REST API monitors)
      [2] Export discovery plan to JSON
      [3] Export discovery plan to CSV
      [4] Show full plan table in console
      [5] Exit
 
    Architecture (when pushed to WUG):
      [Nutanix Host device in WUG]
          |-- "Nutanix Cluster Health - <cluster>" (Active Monitor)
          |-- "Nutanix Host Health - <host>" (Active Monitor)
          |-- "cpu_ppm - <host> (NutanixHost)" (Perf Monitor)
          |-- "memory_ppm - <host> (NutanixHost)" (Perf Monitor)
          '-- ...
      [Nutanix VM device in WUG]
          |-- "Nutanix VM Health - <vm>" (Active Monitor)
          |-- "cpu_ppm - <vm> (NutanixVM)" (Perf Monitor)
          '-- "memory_ppm - <vm> (NutanixVM)" (Perf Monitor)
 
    First Run:
      1. Prompts for Prism Element/Central host(s) and port
      2. Prompts for Nutanix credentials (username + password)
      3. Stores credentials in DPAPI vault (encrypted to user + machine)
      4. Discovers clusters, hosts, VMs
      5. Shows summary, then asks what to do
 
    Subsequent Runs:
      Loads credentials from vault automatically.
 
.PARAMETER Target
    Nutanix Prism host(s) -- IP address or FQDN. Accepts multiple values.
 
.PARAMETER ApiPort
    Nutanix Prism API port. Default: 9440.
 
.PARAMETER Action
    What to do with discovery results. When specified, skips the interactive menu.
    Valid values: PushToWUG, ExportJSON, ExportCSV, ShowTable, None.
 
.PARAMETER WUGServer
    WhatsUp Gold server address. Default: 192.168.74.74.
 
.PARAMETER WUGCredential
    PSCredential for WhatsUp Gold admin login (non-interactive WUG push).
 
.PARAMETER NonInteractive
    Suppress all prompts. Uses cached vault credentials and parameter defaults.
 
.PARAMETER OutputPath
    Output directory for discovery artifacts.
    Default: %APPDATA%\WhatsUpGoldPS\Discovery\Nutanix
 
.NOTES
    Nutanix Prism uses self-signed SSL certificates -- the provider handles
    SSL bypass automatically for PowerShell 5.1.
 
.EXAMPLE
    .\Setup-Nutanix-Discovery.ps1
    # Interactive mode.
 
.EXAMPLE
    .\Setup-Nutanix-Discovery.ps1 -Target '10.0.0.10' -Action PushToWUG -NonInteractive
    # Scheduled mode -- uses vault credentials, pushes to WUG.
 
.EXAMPLE
    .\Setup-Nutanix-Discovery.ps1 -Target '10.0.0.10','10.0.0.11' -Action ExportJSON
    # Discover multiple Prism hosts and export plan.
#>

[CmdletBinding()]
param(
    [string[]]$Target,

    [int]$ApiPort = 9440,

    [ValidateSet('PushToWUG', 'ExportJSON', 'ExportCSV', 'ShowTable', 'Dashboard', 'DashboardAndPush', 'None')]
    [string]$Action,

    [string]$WUGServer = '192.168.74.74',

    [PSCredential]$WUGCredential,

    [string]$OutputPath,

    [switch]$NonInteractive
)

# --- Output directory ---------------------------------------------------------
    function Resolve-WUGPathRoot {
        if ($env:APPDATA -and (Test-Path $env:APPDATA)) {
            return (Join-Path $env:APPDATA 'WhatsUpGoldPS')
        }
        if ($env:LOCALAPPDATA -and (Test-Path $env:LOCALAPPDATA)) {
            return (Join-Path $env:LOCALAPPDATA 'WhatsUpGoldPS')
        }
        if ($env:TEMP -and (Test-Path $env:TEMP)) {
            return (Join-Path $env:TEMP 'WhatsUpGoldPS')
        }
        return (Join-Path (Get-Location).Path 'WhatsUpGoldPS')
    }

    $wugPathRoot = Resolve-WUGPathRoot
    if (-not (Test-Path $wugPathRoot)) {
        New-Item -ItemType Directory -Path $wugPathRoot -Force | Out-Null
    }

if (-not $OutputPath) {
        $OutputPath = Join-Path $wugPathRoot 'Discovery\Nutanix'
}
if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null }
$OutputDir = $OutputPath

$DefaultHost  = '10.0.0.10'
$NutanixPort  = $ApiPort

# --- Load helpers -------------------------------------------------------------
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
. (Join-Path $scriptDir 'DiscoveryHelpers.ps1')
. (Join-Path $scriptDir 'DiscoveryProvider-Nutanix.ps1')

# Load dynamic dashboard generator
$dynDashPath = Join-Path (Split-Path $scriptDir -Parent) 'reports\Export-DynamicDashboardHtml.ps1'
if (Test-Path $dynDashPath) { . $dynDashPath }

# ==============================================================================
# STEP 1: Gather target info
# ==============================================================================
Write-Host "=== Nutanix Discovery ===" -ForegroundColor Cyan
Write-Host ""

if ($Target) {
    $NutanixHosts = @($Target)
}
elseif ($NonInteractive) {
    $NutanixHosts = @($DefaultHost)
}
else {
    Write-Host "Enter Nutanix Prism host(s) -- IP address or FQDN." -ForegroundColor Cyan
    Write-Host "For multiple hosts, separate with commas." -ForegroundColor Gray
    $hostInput = Read-Host -Prompt "Prism host(s) [default: $DefaultHost]"
    if ([string]::IsNullOrWhiteSpace($hostInput)) { $hostInput = $DefaultHost }
    $NutanixHosts = @($hostInput -split '\s*,\s*' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
if ($NutanixHosts.Count -eq 0) { Write-Error 'No valid host provided.'; return }
Write-Host "Targets: $($NutanixHosts -join ', ')" -ForegroundColor Cyan
Write-Host ""

# --- Port prompt ---
if (-not $PSBoundParameters.ContainsKey('ApiPort') -and -not $NonInteractive) {
    $portInput = Read-Host -Prompt "Nutanix Prism API port [default: $NutanixPort]"
    if ($portInput -and $portInput -match '^\d+$') { $NutanixPort = [int]$portInput }
}

# ==============================================================================
# STEP 2: Authentication (username + password for Basic auth)
# ==============================================================================
$vaultName = "Nutanix.$($NutanixHosts[0]).Credential"
$credSplat = @{ Name = $vaultName; CredType = 'PSCredential'; ProviderLabel = 'Nutanix' }
if ($NonInteractive) { $credSplat.NonInteractive = $true }
elseif ($Action) { $credSplat.AutoUse = $true }
$psCred = Resolve-DiscoveryCredential @credSplat

if (-not $psCred) {
    Write-Error 'No Nutanix credentials available. Exiting.'
    return
}

$nutanixCredential = @{
    Username = $psCred.UserName
    Password = $psCred.GetNetworkCredential().Password
}

Write-Host "Authenticated as: $($psCred.UserName)" -ForegroundColor Green
Write-Host ""

# ==============================================================================
# STEP 3: Discover -- query Nutanix Prism API
# ==============================================================================
Write-Host "Querying Nutanix Prism at $($NutanixHosts -join ', ')..." -ForegroundColor Cyan

$plan = Invoke-Discovery -ProviderName 'Nutanix' `
    -Target $NutanixHosts `
    -ApiPort $NutanixPort `
    -Credential $nutanixCredential `
    -IgnoreCertErrors $true

if (-not $plan -or $plan.Count -eq 0) {
    Write-Warning "No items discovered. Check Nutanix connectivity and credentials."
    return
}

if ($global:NutanixDiscoveryLastPopulationLog) {
    try {
        $apiLogPath = Join-Path $OutputDir 'nutanix-api-population-log.json'
        @($global:NutanixDiscoveryLastPopulationLog) | ConvertTo-Json -Depth 8 | Out-File -FilePath $apiLogPath -Encoding utf8
        Write-Host "API population log: $apiLogPath" -ForegroundColor DarkGray
    }
    catch {
        Write-Warning "Could not export Nutanix API population log: $_"
    }
}

if ($global:NutanixDiscoveryLastApiPathLog) {
    try {
        $apiPathLogPath = Join-Path $OutputDir 'nutanix-api-path-log.json'
        @($global:NutanixDiscoveryLastApiPathLog) | ConvertTo-Json -Depth 8 | Out-File -FilePath $apiPathLogPath -Encoding utf8
        Write-Host "API path log: $apiPathLogPath" -ForegroundColor DarkGray
    }
    catch {
        Write-Warning "Could not export Nutanix API path log: $_"
    }
}

# ==============================================================================
# STEP 4: Show the plan
# ==============================================================================
$devicePlan = [ordered]@{}

foreach ($item in $plan) {
    $type = $item.Attributes['Nutanix.DeviceType']
    switch ($type) {
        'Cluster' {
            $key  = "cluster:$($item.Attributes['Nutanix.ClusterName'])"
            $name = $item.Attributes['Nutanix.ClusterName']
            if (-not $name) { $name = $item.DeviceName }
            $ip   = $item.Attributes['Nutanix.ClusterIP']
            if (-not $ip) { $ip = $item.Attributes['Nutanix.IPAddress'] }
            if (-not $ip) { $ip = $item.DeviceIP }
        }
        'Host' {
            $key  = "host:$($item.Attributes['Nutanix.HostName'])"
            $name = $item.Attributes['Nutanix.HostName']
            if (-not $name) { $name = $item.DeviceName }
            $ip   = $item.Attributes['Nutanix.HostIP']
            if (-not $ip) { $ip = $item.Attributes['Nutanix.IPAddress'] }
            if (-not $ip) { $ip = $item.DeviceIP }
        }
        'VM' {
            $vmId = $item.Attributes['Nutanix.VMUUID']
            if (-not $vmId) { $vmId = $item.Attributes['Nutanix.VMUuid'] }
            if (-not $vmId) { $vmId = $item.Attributes['Nutanix.VMExtId'] }
            $key  = "vm:$vmId"
            $name = $item.Attributes['Nutanix.VMName']
            if (-not $name) { $name = $item.DeviceName }
            $ip   = $item.Attributes['Nutanix.VMIP']
            if (-not $ip) { $ip = $item.Attributes['Nutanix.IPAddress'] }
            if (-not $ip) { $ip = $item.DeviceIP }
        }
        'Disk' {
            $diskId = $item.Attributes['Nutanix.DiskExtId']
            if (-not $diskId) { $diskId = $item.Attributes['Nutanix.DiskUuid'] }
            if (-not $diskId) { $diskId = $item.UniqueKey }
            $key  = "disk:$diskId"
            $name = $item.Attributes['Nutanix.DiskName']
            if (-not $name) { $name = "Disk-$diskId" }
            $ip = '0.0.0.0'
        }
        'StorageContainer' {
            $scId = $item.Attributes['Nutanix.StorageContainerExtId']
            if (-not $scId) { $scId = $item.Attributes['Nutanix.ContainerExtId'] }
            if (-not $scId) { $scId = $item.UniqueKey }
            $key  = "storage-container:$scId"
            $name = $item.Attributes['Nutanix.StorageContainerName']
            if (-not $name) { $name = "StorageContainer-$scId" }
            $ip = '0.0.0.0'
        }
        default { continue }
    }
    if (-not $devicePlan.Contains($key)) {
        $devicePlan[$key] = @{
            Name  = $name
            IP    = $ip
            Type  = $type
            Attrs = $item.Attributes
            Items = [System.Collections.ArrayList]@()
        }
    }
    [void]$devicePlan[$key].Items.Add($item)
}

$clusterDevices = @($devicePlan.Values | Where-Object { $_.Type -eq 'Cluster' })
$hostDevices    = @($devicePlan.Values | Where-Object { $_.Type -eq 'Host' })
$vmDevices      = @($devicePlan.Values | Where-Object { $_.Type -eq 'VM' })
$diskDevices    = @($devicePlan.Values | Where-Object { $_.Type -eq 'Disk' })
$storageDevices = @($devicePlan.Values | Where-Object { $_.Type -eq 'StorageContainer' })
$vmWithIP       = @($vmDevices | Where-Object { $_.IP })
$vmNoIP         = @($vmDevices | Where-Object { -not $_.IP })

$activeTemplates = @($plan | Where-Object { $_.ItemType -eq 'ActiveMonitor' } | Select-Object -ExpandProperty Name -Unique)
$perfTemplates   = @($plan | Where-Object { $_.ItemType -eq 'PerformanceMonitor' } | Select-Object -ExpandProperty Name -Unique)

Write-Host ""
Write-Host "Discovery complete!" -ForegroundColor Green
Write-Host " Clusters: $($clusterDevices.Count)" -ForegroundColor White
Write-Host " Hosts: $($hostDevices.Count)" -ForegroundColor White
Write-Host " VMs: $($vmDevices.Count)" -ForegroundColor White
Write-Host " Disks: $($diskDevices.Count)" -ForegroundColor White
Write-Host " Storage Containers: $($storageDevices.Count)" -ForegroundColor White
Write-Host " VMs with IP: $($vmWithIP.Count)" -ForegroundColor White
Write-Host " VMs without IP: $($vmNoIP.Count)" -ForegroundColor White
Write-Host " Active monitor templates: $($activeTemplates.Count) ($(@($plan | Where-Object { $_.ItemType -eq 'ActiveMonitor' }).Count) assignments)" -ForegroundColor White
Write-Host " Perf monitor templates: $($perfTemplates.Count) ($(@($plan | Where-Object { $_.ItemType -eq 'PerformanceMonitor' }).Count) assignments)" -ForegroundColor White
Write-Host " Total plan items: $($plan.Count)" -ForegroundColor White
Write-Host ""

Write-Host "Monitor templates:" -ForegroundColor Cyan
foreach ($t in $activeTemplates) { Write-Host " [Active] $t" -ForegroundColor White }
foreach ($t in $perfTemplates)   { Write-Host " [Perf] $t" -ForegroundColor White }
Write-Host ""

if ($vmNoIP.Count -gt 0) {
    Write-Host "VMs without IP (will use 0.0.0.0):" -ForegroundColor Yellow
    foreach ($v in $vmNoIP) { Write-Host " $($v.Name)" -ForegroundColor DarkYellow }
    Write-Host ""
}

$devicePlan.Values | Sort-Object @{E={$_.Type}}, @{E={$_.Name}} |
    ForEach-Object { [PSCustomObject]@{
        Device   = $_.Name
        Type     = $_.Type
        IP       = if ($_.IP) { $_.IP } else { '(none)' }
        Monitors = $_.Items.Count
    }} |
    Format-Table -AutoSize

# ==============================================================================
# STEP 5: Export or push to WUG
# ==============================================================================
$choice = $null
if ($Action) {
    switch ($Action) {
        'PushToWUG'        { $choice = '1' }
        'ExportJSON'       { $choice = '2' }
        'ExportCSV'        { $choice = '3' }
        'ShowTable'        { $choice = '4' }
        'Dashboard'        { $choice = '5' }
        'None'             { $choice = '6' }
        'DashboardAndPush' { $choice = '7' }
    }
}

if (-not $choice) {
    Write-Host "What would you like to do?" -ForegroundColor Cyan
    Write-Host " [1] Push monitors to WhatsUp Gold"
    Write-Host " [2] Export plan to JSON file"
    Write-Host " [3] Export plan to CSV file"
    Write-Host " [4] Show full plan table"
    Write-Host " [5] Generate HTML dashboard"
    Write-Host " [6] Exit (do nothing)"
    Write-Host " [7] Dashboard + Push to WUG"
    Write-Host ""
    $choice = Read-Host -Prompt "Choice [1-7]"
}

# Handle DashboardAndPush: run Dashboard then PushToWUG sequentially
if ($choice -eq '7') {
    $actionsToRun = @('5', '1')
} else {
    $actionsToRun = @($choice)
}

foreach ($currentChoice in $actionsToRun) {
switch ($currentChoice) {
    '1' {
        # --- Push to WUG ---
        Write-Host "Loading WhatsUpGoldPS module..." -ForegroundColor Cyan
        try {
            $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
            $repoPsd1 = Join-Path $repoRoot 'WhatsUpGoldPS.psd1'
            if (Test-Path $repoPsd1) { Import-Module $repoPsd1 -Force -ErrorAction Stop }
            else { Import-Module WhatsUpGoldPS -ErrorAction Stop }
        }
        catch { Write-Error "Could not load WhatsUpGoldPS module: $_"; return }
        # Dot-source internal helper so scripts can call Get-WUGAPIResponse directly
        $apiResponsePath = Join-Path $PSScriptRoot '..\..\functions\Get-WUGAPIResponse.ps1'
        if (Test-Path $apiResponsePath) { . $apiResponsePath }

        if ($WUGCredential) {
            Connect-WUGServer -serverUri $WUGServer -Credential $WUGCredential -IgnoreSSLErrors
        }
        else {
            Connect-WUGServer -AutoConnect -IgnoreSSLErrors
        }

        $stats = @{
            HealthCreated = 0; HealthSkipped = 0; HealthFailed = 0
            PerfCreated = 0; PerfSkipped = 0; PerfUpdated = 0; PerfFailed = 0
            DevicesCreated = 0; DevicesFound = 0; CredsAssigned = 0
        }
        $wugDeviceMap = @{}
        $deviceKeys = @($devicePlan.Keys | Sort-Object)
        $devTotal = $deviceKeys.Count

        # ---- 1. Create/find REST API credential ---
        Write-Host ""
        Write-Host "Setting up Nutanix REST API credential in WUG..." -ForegroundColor Cyan
        # Host-scoped name avoids reusing an older generic credential that may be OAuth/client-credentials.
        $credName = "Nutanix Prism API ($($NutanixHosts[0]))"
        $nutanixCredId = $null

        try {
            $existingCreds = @(Get-WUGCredential -Type restapi -SearchValue $credName -View basic)
            if ($existingCreds.Count -eq 0) { $existingCreds = @(Get-WUGCredential -SearchValue $credName -View basic) }
            if ($existingCreds.Count -gt 0) {
                $matchCred = $existingCreds | Where-Object { $_.name -eq $credName } | Select-Object -First 1
                if ($matchCred) { $nutanixCredId = $matchCred.id; Write-Host " Found existing credential (ID: $nutanixCredId)" -ForegroundColor Green }
            }
        }
        catch { }

        if (-not $nutanixCredId) {
            Write-Host " Creating credential '$credName'..." -ForegroundColor Yellow
            try {
                $credResult = Add-WUGCredential -Name $credName `
                    -Description "Nutanix Prism API credential (auto-created by discovery)" `
                    -Type restapi `
                    -RestApiUsername $psCred.UserName `
                    -RestApiPassword ($psCred.GetNetworkCredential().Password) `
                    -RestApiAuthType '0' `
                    -RestApiIgnoreCertErrors 'True'
                if ($credResult) {
                    if ($credResult.PSObject.Properties['data']) { $nutanixCredId = $credResult.data.idMap.resultId }
                    elseif ($credResult.PSObject.Properties['resourceId']) { $nutanixCredId = $credResult.resourceId }
                    elseif ($credResult.PSObject.Properties['id']) { $nutanixCredId = $credResult.id }
                    if ($nutanixCredId) { Write-Host " Created credential (ID: $nutanixCredId)" -ForegroundColor Green }
                }
            }
            catch { Write-Verbose "Credential creation failed: $_" }
            if (-not $nutanixCredId) {
                try {
                    $recheck = @(Get-WUGCredential -SearchValue $credName -View basic)
                    $match = $recheck | Where-Object { $_.name -eq $credName } | Select-Object -First 1
                    if ($match) { $nutanixCredId = $match.id }
                }
                catch { }
            }
            if (-not $nutanixCredId) { Write-Warning "Could not create REST API credential. Create manually in WUG." }
        }

        # ---- 2a. Create active monitors ---
        Write-Host ""
        Write-Host " Creating active monitors in library..." -ForegroundColor Cyan

        $uniqueActiveMonitors = @{}
        foreach ($key in $deviceKeys) {
            foreach ($actItem in @($devicePlan[$key].Items | Where-Object { $_.ItemType -eq 'ActiveMonitor' })) {
                if ($actItem.Name -and -not $uniqueActiveMonitors.ContainsKey($actItem.Name)) {
                    $uniqueActiveMonitors[$actItem.Name] = $actItem
                }
            }
        }

        $existingActiveNames = @{}
        foreach ($actName in @($uniqueActiveMonitors.Keys)) {
            try {
                $found = @(Get-WUGActiveMonitor -Search $actName)
                $exact = $found | Where-Object { $_.name -eq $actName } | Select-Object -First 1
                if ($exact) { $existingActiveNames[$actName] = [int]$exact.id }
            }
            catch { }
        }

        $toCreateActive = @($uniqueActiveMonitors.Keys | Where-Object { -not $existingActiveNames.ContainsKey($_) })
        $stats.HealthSkipped = $uniqueActiveMonitors.Count - $toCreateActive.Count

        if ($toCreateActive.Count -gt 0) {
            Write-Host " Creating $($toCreateActive.Count) new active monitors (bulk)..." -ForegroundColor DarkGray

            $activeTemplateArr = @()
            $actTplIdMap = @{}
            $actTplIdx = 0
            foreach ($actName in $toCreateActive) {
                $actItem = $uniqueActiveMonitors[$actName]
                $mp = $actItem.MonitorParams
                $tplId = "act_$actTplIdx"; $actTplIdMap[$tplId] = $actName; $actTplIdx++

                $bags = @(
                    @{ name = 'MonRestApi:RestUrl';                value = "$($mp.RestApiUrl)" }
                    @{ name = 'MonRestApi:HttpMethod';             value = if ($mp.RestApiMethod) { "$($mp.RestApiMethod)" } else { 'GET' } }
                    @{ name = 'MonRestApi:HttpTimeoutMs';          value = if ($mp.RestApiTimeoutMs) { "$($mp.RestApiTimeoutMs)" } else { '10000' } }
                    @{ name = 'MonRestApi:IgnoreCertErrors';       value = if ($mp.RestApiIgnoreCertErrors) { "$($mp.RestApiIgnoreCertErrors)" } else { '1' } }
                    @{ name = 'MonRestApi:UseAnonymousAccess';     value = if ($mp.RestApiUseAnonymous) { "$($mp.RestApiUseAnonymous)" } else { '0' } }
                    @{ name = 'MonRestApi:CustomHeader';           value = if ($mp.RestApiCustomHeader) { "$($mp.RestApiCustomHeader)" } else { '' } }
                    @{ name = 'MonRestApi:DownIfResponseCodeIsIn'; value = if ($mp.RestApiDownIfResponseCodeIsIn) { "$($mp.RestApiDownIfResponseCodeIsIn)" } else { '[]' } }
                    @{ name = 'MonRestApi:ComparisonList';         value = if ($mp.RestApiComparisonList) { "$($mp.RestApiComparisonList)" } else { '[]' } }
                    @{ name = 'Cred:Type';                        value = '8192' }
                )

                $activeTemplateArr += @{
                    templateId      = $tplId
                    name            = $actName
                    description     = 'Nutanix RestApi active monitor'
                    useInDiscovery  = $false
                    monitorTypeInfo = @{ baseType = 'active'; classId = 'f0610672-d515-4268-bd21-ac5ebb1476ff' }
                    propertyBags    = $bags
                }
            }

            try {
                $batchSize = 50
                for ($bi = 0; $bi -lt $activeTemplateArr.Count; $bi += $batchSize) {
                    $batchEnd = [Math]::Min($bi + $batchSize - 1, $activeTemplateArr.Count - 1)
                    $actBatch = @($activeTemplateArr[$bi..$batchEnd])
                    if ($activeTemplateArr.Count -gt $batchSize) {
                        $batchNum = [Math]::Floor($bi / $batchSize) + 1
                        $totalBatches = [Math]::Ceiling($activeTemplateArr.Count / $batchSize)
                        Write-Host " Batch $batchNum/$totalBatches ($($actBatch.Count) monitors)..." -ForegroundColor DarkGray
                    }

                    $bulkActResult = Add-WUGMonitorTemplate -ActiveMonitors $actBatch
                    if ($bulkActResult.idMap) {
                        foreach ($mapping in $bulkActResult.idMap) {
                            if ($actTplIdMap.ContainsKey($mapping.templateId) -and $mapping.resultId) {
                                $existingActiveNames[$actTplIdMap[$mapping.templateId]] = [int]$mapping.resultId
                                $stats.HealthCreated++
                            }
                        }
                    }
                    if ($bulkActResult.errors) {
                        foreach ($err in $bulkActResult.errors) {
                            $msg = $err.messages -join '; '
                            Write-Warning "Active monitor error: $msg"

                            if ($msg -match 'already exists') {
                                $nameMatch = [regex]::Match($msg, 'named\s+(.+?)\s+already exists')
                                if ($nameMatch.Success) {
                                    $dupName = $nameMatch.Groups[1].Value.Trim()
                                    try {
                                        $foundDup = @(Get-WUGActiveMonitor -Search $dupName)
                                        $exactDup = $foundDup | Where-Object { $_.name -eq $dupName } | Select-Object -First 1
                                        if ($exactDup) {
                                            $existingActiveNames[$dupName] = [int]$exactDup.id
                                            $stats.HealthSkipped++
                                            continue
                                        }
                                    }
                                    catch { }
                                }
                            }

                            $stats.HealthFailed++
                        }
                    }
                    if ($batchEnd -lt $activeTemplateArr.Count - 1) { Start-Sleep -Seconds 2 }
                }
            }
            catch { Write-Warning "Bulk active monitor creation failed: $_"; $stats.HealthFailed += $toCreateActive.Count }
        }
        Write-Host " Active monitors: $($stats.HealthCreated) created, $($stats.HealthSkipped) existing, $($stats.HealthFailed) failed" -ForegroundColor DarkGray

        # ---- 2b. Create perf monitors ---
        Write-Host " Creating performance monitors in library..." -ForegroundColor Cyan

        $uniquePerfMonitors = @{}
        foreach ($key in $deviceKeys) {
            foreach ($perfItem in @($devicePlan[$key].Items | Where-Object { $_.ItemType -eq 'PerformanceMonitor' })) {
                if ($perfItem.Name -and -not $uniquePerfMonitors.ContainsKey($perfItem.Name)) {
                    $uniquePerfMonitors[$perfItem.Name] = $perfItem
                }
            }
        }

        $existingPerfNames = @{}
        $existingPerfMonitors = @{}
        foreach ($monName in @($uniquePerfMonitors.Keys)) {
            try {
                $found = @(Get-WUGPerformanceMonitor -Search $monName)
                $exact = $found | Where-Object { $_.name -eq $monName } | Select-Object -First 1
                if ($exact) {
                    $existingPerfNames[$monName] = "$($exact.id)"
                    $existingPerfMonitors[$monName] = $exact
                }
            }
            catch { }
        }

        $toCreatePerf = @($uniquePerfMonitors.Keys | Where-Object { -not $existingPerfNames.ContainsKey($_) })
        $stats.PerfSkipped = $uniquePerfMonitors.Count - $toCreatePerf.Count

        if ($toCreatePerf.Count -gt 0) {
            Write-Host " Creating $($toCreatePerf.Count) new perf monitors (bulk)..." -ForegroundColor DarkGray

            $perfTemplateArr = @()
            $perfTplIdMap = @{}
            $perfTplIdx = 0
            foreach ($monName in $toCreatePerf) {
                $perfItem = $uniquePerfMonitors[$monName]
                $mp = $perfItem.MonitorParams
                $tplId = "perf_$perfTplIdx"; $perfTplIdMap[$tplId] = $monName; $perfTplIdx++

                $bags = @(
                    @{ name = 'RdcRestApi:RestUrl';            value = "$($mp.RestApiUrl)" }
                    @{ name = 'RdcRestApi:JsonPath';           value = "$($mp.RestApiJsonPath)" }
                    @{ name = 'RdcRestApi:HttpMethod';         value = if ($mp.RestApiHttpMethod) { "$($mp.RestApiHttpMethod)" } else { 'GET' } }
                    @{ name = 'RdcRestApi:HttpTimeoutMs';      value = if ($mp.RestApiHttpTimeoutMs) { "$($mp.RestApiHttpTimeoutMs)" } else { '10000' } }
                    @{ name = 'RdcRestApi:IgnoreCertErrors';   value = if ($mp.RestApiIgnoreCertErrors) { "$($mp.RestApiIgnoreCertErrors)" } else { '1' } }
                    @{ name = 'RdcRestApi:UseAnonymousAccess'; value = if ($mp.RestApiUseAnonymousAccess) { "$($mp.RestApiUseAnonymousAccess)" } else { '0' } }
                    @{ name = 'RdcRestApi:CustomHeader';       value = if ($mp.RestApiCustomHeader) { "$($mp.RestApiCustomHeader)" } else { '' } }
                )

                $perfTemplateArr += @{
                    templateId      = $tplId
                    name            = $monName
                    description     = 'Nutanix RestApi performance monitor'
                    monitorTypeInfo = @{ baseType = 'performance'; classId = '987bb6a4-70f4-4f46-97c6-1c9dd1766437' }
                    propertyBags    = $bags
                }
            }

            try {
                $batchSize = 50
                for ($bi = 0; $bi -lt $perfTemplateArr.Count; $bi += $batchSize) {
                    $batchEnd = [Math]::Min($bi + $batchSize - 1, $perfTemplateArr.Count - 1)
                    $perfBatch = @($perfTemplateArr[$bi..$batchEnd])
                    $batchNum = [Math]::Floor($bi / $batchSize) + 1
                    $totalBatches = [Math]::Ceiling($perfTemplateArr.Count / $batchSize)
                    Write-Host " Batch $batchNum/$totalBatches ($($perfBatch.Count) monitors)..." -ForegroundColor DarkGray

                    $bulkPerfResult = Add-WUGMonitorTemplate -PerformanceMonitors $perfBatch
                    if ($bulkPerfResult.idMap) {
                        foreach ($mapping in $bulkPerfResult.idMap) {
                            if ($perfTplIdMap.ContainsKey($mapping.templateId) -and $mapping.resultId) {
                                $existingPerfNames[$perfTplIdMap[$mapping.templateId]] = "$($mapping.resultId)"
                                $stats.PerfCreated++
                            }
                        }
                    }
                    if ($bulkPerfResult.errors) {
                        foreach ($err in $bulkPerfResult.errors) {
                            Write-Warning "Perf monitor error: $($err.messages -join '; ')"
                            $stats.PerfFailed++
                        }
                    }
                    if ($batchEnd -lt $perfTemplateArr.Count - 1) { Start-Sleep -Seconds 2 }
                }
            }
            catch { Write-Warning "Bulk perf monitor creation failed: $_"; $stats.PerfFailed += $toCreatePerf.Count }
        }

        foreach ($monName in @($existingPerfNames.Keys)) {
            if (-not $uniquePerfMonitors.ContainsKey($monName)) { continue }

            $perfItem = $uniquePerfMonitors[$monName]
            $mp = $perfItem.MonitorParams
            $desiredBags = @(
                @{ name = 'RdcRestApi:RestUrl';            value = "$($mp.RestApiUrl)" }
                @{ name = 'RdcRestApi:JsonPath';           value = "$($mp.RestApiJsonPath)" }
                @{ name = 'RdcRestApi:HttpMethod';         value = if ($mp.RestApiHttpMethod) { "$($mp.RestApiHttpMethod)" } else { 'GET' } }
                @{ name = 'RdcRestApi:HttpTimeoutMs';      value = if ($mp.RestApiHttpTimeoutMs) { "$($mp.RestApiHttpTimeoutMs)" } else { '10000' } }
                @{ name = 'RdcRestApi:IgnoreCertErrors';   value = if ($mp.RestApiIgnoreCertErrors) { "$($mp.RestApiIgnoreCertErrors)" } else { '1' } }
                @{ name = 'RdcRestApi:UseAnonymousAccess'; value = if ($mp.RestApiUseAnonymousAccess) { "$($mp.RestApiUseAnonymousAccess)" } else { '0' } }
                @{ name = 'RdcRestApi:CustomHeader';       value = if ($mp.RestApiCustomHeader) { "$($mp.RestApiCustomHeader)" } else { '' } }
            )

            $currentBagMap = @{}
            $existingPerf = $existingPerfMonitors[$monName]
            foreach ($bag in @($existingPerf.PropertyBags)) {
                if ($bag.name) { $currentBagMap[$bag.name] = "$($bag.value)" }
            }

            $needsUpdate = $false
            foreach ($bag in $desiredBags) {
                if (-not $currentBagMap.ContainsKey($bag.name) -or "$($currentBagMap[$bag.name])" -ne "$($bag.value)") {
                    $needsUpdate = $true
                    break
                }
            }

            if (-not $needsUpdate) { continue }

            try {
                Set-WUGPerformanceMonitor -MonitorId $existingPerfNames[$monName] -PropertyBags $desiredBags -Name $monName -Description 'Nutanix RestApi performance monitor' | Out-Null
                $stats.PerfUpdated++
            }
            catch {
                Write-Warning "Perf monitor update failed for '$monName': $_"
                $stats.PerfFailed++
            }
        }

        Write-Host " Perf monitors: $($stats.PerfCreated) created, $($stats.PerfSkipped) existing, $($stats.PerfUpdated) updated, $($stats.PerfFailed) failed" -ForegroundColor DarkGray

        # ---- 2c. Check existing vs new devices ---
        Write-Host " Checking for existing devices..." -ForegroundColor Cyan
        $existingDevices = @{}
        $newDeviceKeys = [System.Collections.Generic.List[string]]::new()
        $devIdx = 0

        foreach ($key in $deviceKeys) {
            $devIdx++
            $dev = $devicePlan[$key]
            $addIP = if ($dev.IP -and $dev.IP -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') { $dev.IP } else { '0.0.0.0' }
            $displayName = "$($dev.Name) ($($dev.Type))"

            Write-Progress -Activity 'Checking existing devices' `
                -Status "$devIdx / $devTotal - $($dev.Name)" `
                -PercentComplete ([Math]::Round(($devIdx / $devTotal) * 100))

            $deviceId = $null
            try {
                $searchResults = @(Get-WUGDevice -SearchValue $dev.Name)
                if ($searchResults.Count -gt 0) {
                    $match = $searchResults | Where-Object { $_.displayName -eq $dev.Name -or $_.displayName -eq $displayName -or $_.hostName -eq $dev.Name } | Select-Object -First 1
                    if ($match) { $deviceId = $match.id }
                }
                if (-not $deviceId -and $dev.IP) {
                    $searchResults = @(Get-WUGDevice -SearchValue $addIP)
                    $match = $searchResults | Where-Object { $_.networkAddress -eq $addIP } | Select-Object -First 1
                    if ($match) { $deviceId = $match.id }
                }
            }
            catch { }

            if ($deviceId) { $existingDevices[$key] = $deviceId; $wugDeviceMap[$key] = $deviceId; $stats.DevicesFound++ }
            else { $newDeviceKeys.Add($key) }
        }
        Write-Progress -Activity 'Checking existing devices' -Completed
        Write-Host " Found $($stats.DevicesFound) existing, $($newDeviceKeys.Count) new" -ForegroundColor DarkGray

        # ---- 2d. Create new devices ---
        if ($newDeviceKeys.Count -gt 0) {
            Write-Host " Creating $($newDeviceKeys.Count) devices..." -ForegroundColor Yellow
            $devIdx = 0
            foreach ($key in $newDeviceKeys) {
                $devIdx++
                $dev = $devicePlan[$key]
                $addIP = if ($dev.IP -and $dev.IP -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') { $dev.IP } else { '0.0.0.0' }
                $displayName = "$($dev.Name) ($($dev.Type))"

                Write-Progress -Activity 'Creating devices' `
                    -Status "$devIdx / $($newDeviceKeys.Count) - $displayName" `
                    -PercentComplete ([Math]::Round(($devIdx / $newDeviceKeys.Count) * 100))

                $devAttrs = @()
                foreach ($attrName in $dev.Attrs.Keys) {
                    if ($dev.Attrs[$attrName]) { $devAttrs += @{ name = $attrName; value = "$($dev.Attrs[$attrName])" } }
                }

                $actNames = @()
                $seenAct = @{}
                foreach ($actItem in @($dev.Items | Where-Object { $_.ItemType -eq 'ActiveMonitor' })) {
                    if ($actItem.Name -and $existingActiveNames.ContainsKey($actItem.Name) -and -not $seenAct.ContainsKey($actItem.Name)) {
                        $actNames += $actItem.Name; $seenAct[$actItem.Name] = $true
                    }
                }

                # Perf monitors assigned separately for polling interval control
                if ($actNames.Count -eq 0) {
                    # Check if there are perf monitors at least
                    $hasPerfMons = @($dev.Items | Where-Object { $_.ItemType -eq 'PerformanceMonitor' }).Count -gt 0
                    if (-not $hasPerfMons) { continue }
                }

                $splat = @{
                    displayName            = $displayName
                    DeviceAddress          = $addIP
                    Hostname               = $dev.Name
                    Brand                  = 'Nutanix'
                    Note                   = "Nutanix $($dev.Type) (auto-created by discovery)"
                    NoDefaultActiveMonitor = $true
                }
                if ($devAttrs.Count -gt 0) { $splat['Attributes'] = $devAttrs }
                if ($nutanixCredId) { $splat['CredentialRestApi'] = $credName }
                if ($actNames.Count -gt 0) { $splat['ActiveMonitors'] = $actNames }

                try {
                    $devResult = Add-WUGDeviceTemplate @splat
                    if ($devResult -and -not $devResult.error) {
                        $newDeviceId = $null
                        if ($devResult.idMap) { $newDeviceId = ($devResult.idMap | Select-Object -First 1).resultId }
                        elseif ($devResult.PSObject.Properties['resultId']) { $newDeviceId = $devResult.resultId }
                        if ($newDeviceId) { $wugDeviceMap[$key] = $newDeviceId }
                        $stats.DevicesCreated++
                    }
                }
                catch { Write-Warning "Error creating device '$displayName': $_" }
            }
            Write-Progress -Activity 'Creating devices' -Completed
            Write-Host " Devices created: $($stats.DevicesCreated)" -ForegroundColor Green
        }

        # ---- 2e. Assign perf monitors separately (new + existing devices) ---
        Write-Host " Assigning performance monitors to devices..." -ForegroundColor Cyan
        foreach ($key in $deviceKeys) {
            $deviceId = $wugDeviceMap[$key]
            if (-not $deviceId) { continue }
            $deviceId = [int]$deviceId
            $dev = $devicePlan[$key]

            # Assign credential
            if ($nutanixCredId -and $existingDevices.ContainsKey($key)) {
                try { $null = Set-WUGDeviceCredential -DeviceId $deviceId -CredentialId $nutanixCredId -Assign; $stats.CredsAssigned++ }
                catch { }
            }

            # Assign active monitors (for existing devices)
            if ($existingDevices.ContainsKey($key)) {
                $actMonitorIds = @()
                foreach ($actItem in @($dev.Items | Where-Object { $_.ItemType -eq 'ActiveMonitor' })) {
                    if ($actItem.Name -and $existingActiveNames.ContainsKey($actItem.Name)) { $actMonitorIds += $existingActiveNames[$actItem.Name] }
                }
                if ($actMonitorIds.Count -gt 0) {
                    try { Add-WUGActiveMonitorToDevice -DeviceId $deviceId -MonitorId $actMonitorIds -ErrorAction Stop } catch { }
                }
            }

            # Assign perf monitors with polling interval (all devices)
            if ($existingDevices.ContainsKey($key)) {
                # Existing devices are assumed to have prior monitor assignments; skip re-adding to avoid duplicate-assignment errors.
                continue
            }

            $perfMonitorIds = @()
            foreach ($perfItem in @($dev.Items | Where-Object { $_.ItemType -eq 'PerformanceMonitor' })) {
                if ($perfItem.Name -and $existingPerfNames.ContainsKey($perfItem.Name)) { $perfMonitorIds += [int]$existingPerfNames[$perfItem.Name] }
            }
            if ($perfMonitorIds.Count -gt 0) {
                try { Add-WUGPerformanceMonitorToDevice -DeviceId $deviceId -MonitorId $perfMonitorIds -PollingIntervalMinutes 5 -ErrorAction Stop }
                catch { }
            }
        }

        # ---- Summary ---
        Write-Host ""
        Write-Host "Push complete!" -ForegroundColor Green
        Write-Host " Active monitors: $($stats.HealthCreated) created, $($stats.HealthSkipped) existing, $($stats.HealthFailed) failed" -ForegroundColor White
        Write-Host " Perf monitors: $($stats.PerfCreated) created, $($stats.PerfSkipped) existing, $($stats.PerfUpdated) updated, $($stats.PerfFailed) failed" -ForegroundColor White
        Write-Host " Devices: $($stats.DevicesCreated) created, $($stats.DevicesFound) existing" -ForegroundColor White
        Write-Host " Creds assigned: $($stats.CredsAssigned)" -ForegroundColor White
    }
    '2' {
        $jsonPath = Join-Path $OutputDir 'nutanix-discovery-plan.json'
        $plan | Export-DiscoveryPlan -Format JSON -Path $jsonPath -IncludeParams
        Write-Host "Exported to: $jsonPath" -ForegroundColor Green
    }
    '3' {
        $csvPath = Join-Path $OutputDir 'nutanix-discovery-plan.csv'
        $plan | Export-DiscoveryPlan -Format CSV -Path $csvPath
        Write-Host "Exported to: $csvPath" -ForegroundColor Green
    }
    '4' {
        $plan | Export-DiscoveryPlan -Format Table
    }
    '5' {
        # ----------------------------------------------------------------
        # Generate Nutanix HTML Dashboard from discovery data
        # ----------------------------------------------------------------
        Write-Host ""
        Write-Host "Building Nutanix dashboard from discovery data..." -ForegroundColor Cyan

        $dashboardRows = @()
        foreach ($key in $devicePlan.Keys) {
            $dev = $devicePlan[$key]
            $activeItems = @($dev.Items | Where-Object { $_.ItemType -eq 'ActiveMonitor' })
            $perfItems   = @($dev.Items | Where-Object { $_.ItemType -eq 'PerformanceMonitor' })

            $devIp = if ($dev.IP -and $dev.IP -match '^\d{1,3}(\.\d{1,3}){3}$') { $dev.IP } else { '0.0.0.0' }
            $clusterName = $dev.Attrs['Nutanix.ClusterName']
            if (-not $clusterName) { $clusterName = '(unknown)' }
            $state = $dev.Attrs['Nutanix.VMState']
            if (-not $state) { $state = 'Discovered' }

            $dashboardRows += [PSCustomObject]@{
                Category      = 'Entity'
                Device        = $dev.Name
                EntityType    = $dev.Type
                Cluster       = $clusterName
                IP            = $devIp
                Status        = $state
                ActiveMonitors = $activeItems.Count
                PerfMonitors   = $perfItems.Count
                LastDiscovery = (Get-Date).ToString('yyyy-MM-dd HH:mm')
            }

            foreach ($item in ($activeItems + $perfItems)) {
                $metric = ''
                if ($item.MonitorParams -and $item.MonitorParams['_MetricDisplayName']) {
                    $metric = $item.MonitorParams['_MetricDisplayName']
                }
                $endpoint = ''
                if ($item.MonitorParams -and $item.MonitorParams['RestApiUrl']) {
                    $endpoint = $item.MonitorParams['RestApiUrl']
                }
                $dashboardRows += [PSCustomObject]@{
                    Category      = 'Monitor'
                    Device        = $dev.Name
                    EntityType    = $dev.Type
                    Cluster       = $clusterName
                    IP            = $devIp
                    Monitor       = $item.Name -replace '\s*\[.*\]$', ''
                    Type          = $item.ItemType
                    Metric        = $metric
                    Endpoint      = $endpoint
                    Status        = 'Discovered'
                    LastDiscovery = (Get-Date).ToString('yyyy-MM-dd HH:mm')
                }
            }
        }

        if ($dashboardRows.Count -eq 0) {
            Write-Warning "No data to generate dashboard."
        }
        else {
            $dashPath = Join-Path $OutputDir 'Nutanix-Dashboard.html'

            if (Get-Command -Name 'Export-DynamicDashboardHtml' -ErrorAction SilentlyContinue) {
                Export-DynamicDashboardHtml -Data $dashboardRows `
                    -OutputPath $dashPath `
                    -ReportTitle 'Nutanix Discovery Dashboard' `
                    -CardField 'Category','EntityType','Cluster' `
                    -StatusField 'Status'
            }
            else {
                Write-Warning "No dashboard function available. Exporting as JSON instead."
                $jsonPath = Join-Path $OutputDir "Nutanix-Plan-$(Get-Date -Format yyyyMMdd-HHmmss).json"
                $plan | Export-DiscoveryPlan -Format JSON -Path $jsonPath
                Write-Host "JSON exported: $jsonPath" -ForegroundColor Green
                break
            }

            Write-Host ""
            Write-Host "Dashboard generated: $dashPath" -ForegroundColor Green
            Write-Host " Devices: $($devicePlan.Count) | Monitors: $($dashboardRows.Count)" -ForegroundColor White

            $nmConsolePaths = @(
                "${env:ProgramFiles(x86)}\Ipswitch\WhatsUp\Html\NmConsole"
                "${env:ProgramFiles}\Ipswitch\WhatsUp\Html\NmConsole"
            )
            $nmConsolePath = $nmConsolePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
            if ($nmConsolePath) {
                $wugDashDir = Join-Path $nmConsolePath 'dashboards'
                if (-not (Test-Path $wugDashDir)) { New-Item -ItemType Directory -Path $wugDashDir -Force | Out-Null }
                $wugDashPath = Join-Path $wugDashDir 'Nutanix-Dashboard.html'
                try {
                    Copy-Item -Path $dashPath -Destination $wugDashPath -Force
                    Write-Host "Copied to WUG: $wugDashPath" -ForegroundColor Green
                    Write-Host " Access via WUG web UI: /NmConsole/dashboards/Nutanix-Dashboard.html" -ForegroundColor Cyan
                }
                catch {
                    Write-Warning "Could not copy to NmConsole (run as admin?): $_"
                }
                Deploy-DashboardWebConfig -Path $wugDashDir
            }
        }
    }
    '6' {
        Write-Host "No action taken." -ForegroundColor Gray
    }
    default {
        Write-Host "Invalid choice." -ForegroundColor Red
    }
}
} # end foreach actionsToRun

Write-Host ""
Write-Host "Re-run anytime to discover new Nutanix clusters/hosts/VMs." -ForegroundColor Cyan

# SIG # Begin signature block
# MIIVlwYJKoZIhvcNAQcCoIIViDCCFYQCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAIY0xhh867asXX
# t0uFzQPTWeLYr961HAiwMpBUQySypKCCEdMwggVvMIIEV6ADAgECAhBI/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
# BAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQg5IDeeNTjbjqUVwPnpVs3zKBBi1mOpg9j
# oFFJaG2hFiMwDQYJKoZIhvcNAQEBBQAEggIA3ZBUTYwBtFPgZrfdfbB+K8CGJcmM
# X/niVQSZJArb8pUYfXjub1UeNd6v4Akdq3HeciTwRG1aqKzHnuGi0ptMdGvS6Vi9
# GHummvuhX+n5hyfQ46e5MfZB72+MTUziciXaaqiLP1Ti9vAXdvTAfFXddlXc+ACH
# 5bmV22wYT8DhtuyB9tnJwA0O311aMW4eFJeNnZNmcajyU6U3q/xzGBlNONonIPe3
# WdsqwOK/pJpbunXBQDXilDqamn1v+rTKWuKx26U9beZaFSnglkxKsK68bpihlj95
# NfhJcSPkC00gURcF3JttTRrY+mAMJNnP4FRETz60grjm81vTw8DUu9I9Xwq1hM96
# GJ5DqBVPRfRqTiQjeUDjgPEh8+R9BVaqEID8r7TaX7YfO2edgMQlszvL4OCVFMek
# 6zdOw/RlVtraVel/fQ2TeYECqQZrNTO9MJQ69bRyfvz0aCUSKjvf+P0blJlBht10
# pteDj18qrb2hm67OBOkXU7pe8OWDoa3j3R+AHGXi6mr3652aoB9aqqxbP/gyQZca
# ztcv3RmEV97ZF4cgt3nwH63Ph3nsVyGutXjlfjz4nN5ilJ483radH75jV+Dfhp2u
# K3M+sjHZRyvimJQct7z6tsUurIYBfpbfkBLVh2fxF3ZZiq8UMjZQRdtXJ3B6JW40
# YUD8hks9yjAZHDw=
# SIG # End signature block