examples/Setup-SQLClusterDevices.ps1

#requires -Version 5.1
<#
.SYNOPSIS
    Sets up WhatsUp Gold devices for a Windows Server Failover Cluster (WSFC) that hosts
    SQL Server, so that shared cluster resources are monitored on their virtual IP and
    node-local resources are monitored on the physical node.
 
.DESCRIPTION
    Monitoring a failover cluster correctly requires the WUG device layout to mirror the
    cluster layout. This script audits and remediates that layout.
 
    THE MODEL
      - Each PHYSICAL NODE is its own device, addressed by the node IP. It owns node-local
        resources: CPU, memory, local disks, NIC, OS services, cluster service health, and
        any non-clustered SQL instance installed on that node.
      - Each SHARED (clustered) ROLE is its own STANDALONE device, addressed by the role's
        VIRTUAL IP and named after its virtual network name (VNN). It owns the shared
        resources: the SQL Failover Cluster Instance, its databases, and the role's
        availability. Because the role floats, the virtual device keeps reporting through a
        failover while the node devices do not.
      - Availability Group listeners get the same treatment as a role.
      - The cluster name object (CNO / "Cluster Group") can optionally get its own device so
        that core cluster health is visible independently of any node.
 
    WHAT THIS SCRIPT DOES
      1. Probes one or more cluster nodes over WMI (root\MSCluster) and builds the full
         cluster topology: nodes, resource groups, network names, virtual IPs, SQL FCIs and
         Availability Groups.
      2. Audits WhatsUp Gold: which nodes exist as devices, which virtual roles already have
         a standalone device, and which are missing.
      3. Creates the missing standalone virtual devices (VNN + VIP), tagged with cluster
         attributes so they can be found later or used in dynamic groups.
      4. CLEANUP -- flags a virtual IP that has been attached as a secondary interface on a
         node device. That is the most common mis-setup: the VIP answers ping through the
         node device, so failovers look healthy when they are not. Optionally removes the
         stray interface.
      5. CLEANUP -- finds shared-resource monitors that are assigned to the PHYSICAL NODE
         devices and reports, disables, or removes them. Those monitors go unknown/down on
         whichever node does not currently own the role, which is the usual source of false
         alarms on a cluster.
 
    Run it once in report mode, review, then re-run with -NodeCleanupAction Disable.
 
    After this script has established the device layout, run the SQL discovery helper
    (helpers/discovery/Setup-MSSQL-Discovery.ps1) against the VIRTUAL device to attach the
    database and instance performance monitors to the right place.
 
.PARAMETER ClusterNode
    One or more cluster node names or IP addresses to probe over WMI. Any single reachable
    node returns the whole cluster topology; supplying several just adds resilience.
 
.PARAMETER Credential
    PSCredential with WMI/administrative access to the cluster node(s). Omit when the
    current user already has access, or when probing the local machine.
 
.PARAMETER DeviceGroupName
    Optional WUG device group to place newly created virtual devices into.
 
.PARAMETER Brand
    Brand/vendor string stamped on created virtual devices. Default: 'Microsoft'.
 
.PARAMETER WindowsCredentialName
    Optional name of an existing WUG Windows credential to associate with created virtual
    devices, so WMI-based monitors can poll them.
 
.PARAMETER IncludeClusterCore
    Also create a standalone device for the cluster name object (CNO / core cluster group).
 
.PARAMETER IncludeOtherRoles
    Also create standalone devices for non-SQL clustered roles that own a network name and a
    virtual IP (file server, generic service, DTC, and so on).
 
.PARAMETER SkipAvailabilityGroups
    Do not create devices for Always On Availability Group listeners.
 
.PARAMETER SkipDeviceCreation
    Audit and clean up only. Never create devices.
 
.PARAMETER NodeCleanupAction
    What to do with shared-resource monitors found on the PHYSICAL NODE devices.
      Report - (default) list them and make no changes.
      Disable - set the assignment to disabled. Reversible, keeps history.
      Remove - delete the monitor assignment from the node.
 
.PARAMETER SharedMonitorPattern
    Extra wildcard patterns identifying shared-resource monitors on node devices. The script
    always looks for monitors naming a discovered virtual server, AG listener, or clustered
    SQL instance; use this to catch monitors that follow a local naming convention.
    Example: 'MSSQL*','SQLServer:*'
 
.PARAMETER RemoveVipInterfaceFromNodes
    Remove a virtual IP that is attached as a secondary interface on a node device. Without
    this switch the condition is only reported.
 
.PARAMETER WUGServer
    WhatsUp Gold server hostname or IP. If omitted, the script assumes Connect-WUGServer has
    already been run.
 
.PARAMETER WUGPort
    WhatsUp Gold API port. Default: 9644.
 
.PARAMETER WUGCredential
    PSCredential used to connect to WhatsUp Gold when -WUGServer is supplied.
 
.PARAMETER IgnoreSSLErrors
    Ignore certificate errors when connecting to WhatsUp Gold.
 
.EXAMPLE
    .\Setup-SQLClusterDevices.ps1 -ClusterNode 'SQLNODE1' -WhatIf
 
    Preview: show the cluster topology, what devices would be created, and what cleanup is
    outstanding, without changing anything.
 
.EXAMPLE
    .\Setup-SQLClusterDevices.ps1 -ClusterNode 'SQLNODE1' -Credential (Get-Credential)
 
    Report mode. Creates the missing virtual devices and lists the node-level cleanup that
    still needs to be done.
 
.EXAMPLE
    .\Setup-SQLClusterDevices.ps1 -ClusterNode 'SQLNODE1' -NodeCleanupAction Disable `
        -DeviceGroupName 'SQL Clusters'
 
    Creates the virtual devices in the 'SQL Clusters' group and disables shared-resource
    monitors that are currently assigned to the physical nodes.
 
.EXAMPLE
    .\Setup-SQLClusterDevices.ps1 -ClusterNode 'SQLNODE1' -NodeCleanupAction Disable `
        -SharedMonitorPattern 'MSSQL*','SQL Agent*' -RemoveVipInterfaceFromNodes
 
    Full remediation, including detaching virtual IPs that were added as extra interfaces on
    the node devices.
 
.EXAMPLE
    .\Setup-SQLClusterDevices.ps1 -ClusterNode 'SQLNODE1' -IncludeClusterCore -IncludeOtherRoles
 
    Also give the cluster name object and every other clustered role with a VIP its own device.
 
.NOTES
    Requires: WhatsUpGoldPS module, WMI access to the cluster node(s)
    Compatible with PowerShell 5.1+
    Author: jayyx2 + Copilot
 
.LINK
    https://docs.ipswitch.com/NM/WhatsUpGold2026/02_Guides/rest_api/index.html
#>


[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'WindowsCredentialName',
    Justification = 'This is the name of a credential already sto red in WhatsUp Gold, not a secret.')]
[CmdletBinding(SupportsShouldProcess = $true)]
param(
    [Parameter(Mandatory = $true, Position = 0)]
    [string[]]$ClusterNode,

    [Parameter()]
    [PSCredential]$Credential,

    [Parameter()]
    [string]$DeviceGroupName,

    [Parameter()]
    [string]$Brand = 'Microsoft',

    [Parameter()]
    [string]$WindowsCredentialName,

    [Parameter()]
    [switch]$IncludeClusterCore,

    [Parameter()]
    [switch]$IncludeOtherRoles,

    [Parameter()]
    [switch]$SkipAvailabilityGroups,

    [Parameter()]
    [switch]$SkipDeviceCreation,

    [Parameter()]
    [ValidateSet('Report', 'Disable', 'Remove')]
    [string]$NodeCleanupAction = 'Report',

    [Parameter()]
    [string[]]$SharedMonitorPattern,

    [Parameter()]
    [switch]$RemoveVipInterfaceFromNodes,

    [Parameter()]
    [string]$WUGServer,

    [Parameter()]
    [int]$WUGPort = 9644,

    [Parameter()]
    [PSCredential]$WUGCredential,

    [Parameter()]
    [switch]$IgnoreSSLErrors
)

#region Module import and connection
if (-not (Get-Module -Name WhatsUpGoldPS)) {
    Import-Module WhatsUpGoldPS -ErrorAction Stop
}

if ($WUGServer) {
    $connSplat = @{ ServerUri = $WUGServer; Port = $WUGPort }
    if ($WUGCredential)   { $connSplat.Credential = $WUGCredential }
    if ($IgnoreSSLErrors) { $connSplat.IgnoreSSLErrors = $true }
    Connect-WUGServer @connSplat
}

if (-not $global:WUGBearerHeaders -or -not $global:WhatsUpServerBaseURI) {
    Write-Error "Not connected to WhatsUp Gold. Run Connect-WUGServer first, or supply -WUGServer."
    return
}
#endregion

#region Helper functions

function Get-WSFCTopology {
    <#
    .SYNOPSIS
        Returns the failover cluster topology visible from a node, via root\MSCluster WMI.
    .DESCRIPTION
        Produces the cluster name, node list, and one entry per resource group that owns a
        network name and/or a virtual IP. Each role is classified as SqlFci,
        SqlAvailabilityGroup, ClusterCore, or Other.
 
        Returns $null when the host is not a cluster node (root\MSCluster absent).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$ComputerName,
        [Parameter()][PSCredential]$NodeCredential
    )

    $isLocal = ($ComputerName -eq 'localhost' -or $ComputerName -eq '.' -or
                $ComputerName -eq '127.0.0.1' -or $ComputerName -eq $env:COMPUTERNAME)

    $wmiSplat = @{ Namespace = 'root\MSCluster'; ErrorAction = 'Stop' }
    if (-not $isLocal) {
        $wmiSplat.ComputerName = $ComputerName
        if ($NodeCredential) { $wmiSplat.Credential = $NodeCredential }
    }

    try {
        $cluster = Get-WmiObject -Class MSCluster_Cluster @wmiSplat | Select-Object -First 1
    }
    catch {
        Write-Verbose "root\MSCluster not available on ${ComputerName}: $($_.Exception.Message)"
        return $null
    }
    if (-not $cluster) { return $null }

    $topology = [ordered]@{
        ClusterName = [string]$cluster.Name
        ProbedFrom  = $ComputerName
        Nodes       = @()
        Roles       = @()
    }

    try {
        $topology.Nodes = @(Get-WmiObject -Class MSCluster_Node @wmiSplat |
            ForEach-Object { [PSCustomObject]@{ Name = [string]$_.Name; State = [int]$_.State } })
    }
    catch {
        Write-Verbose "Could not enumerate MSCluster_Node: $($_.Exception.Message)"
    }

    try {
        $resources = @(Get-WmiObject -Class MSCluster_Resource @wmiSplat)
    }
    catch {
        Write-Verbose "Could not enumerate MSCluster_Resource: $($_.Exception.Message)"
        return $topology
    }

    # A role's virtual identity lives on sibling resources in the same OwnerGroup.
    $groups = @{}
    foreach ($res in $resources) {
        $grp = [string]$res.OwnerGroup
        if (-not $grp) { continue }
        if (-not $groups.ContainsKey($grp)) {
            $groups[$grp] = [ordered]@{
                GroupName    = $grp
                Kind         = 'Other'
                NetworkNames = [System.Collections.Generic.List[string]]::new()
                VirtualIPs   = [System.Collections.Generic.List[string]]::new()
                SqlInstances = [System.Collections.Generic.List[string]]::new()
                AGNames      = [System.Collections.Generic.List[string]]::new()
                OwnerNode    = [string]$res.OwnerNode
            }
        }
        $g = $groups[$grp]
        $resType = [string]$res.Type

        if ($resType -like '*IP Address*') {
            $addr = $null
            try { $addr = [string]$res.PrivateProperties.Address } catch { }
            if ($addr -and -not $g.VirtualIPs.Contains($addr)) { $g.VirtualIPs.Add($addr) }
        }
        elseif ($resType -like '*Network Name*') {
            $nn = $null
            try { $nn = [string]$res.PrivateProperties.DnsName } catch { }
            if (-not $nn) { try { $nn = [string]$res.PrivateProperties.Name } catch { } }
            if (-not $nn) { $nn = [string]$res.Name }
            if ($nn -and -not $g.NetworkNames.Contains($nn)) { $g.NetworkNames.Add($nn) }
            if ([string]$res.Name -eq 'Cluster Name') { $g.Kind = 'ClusterCore' }
        }
        elseif ($resType -eq 'SQL Server') {
            $inst = $null
            $vs   = $null
            try { $inst = [string]$res.PrivateProperties.InstanceName } catch { }
            try { $vs = [string]$res.PrivateProperties.VirtualServerName } catch { }
            if (-not $inst) { $inst = 'MSSQLSERVER' }
            if (-not $g.SqlInstances.Contains($inst)) { $g.SqlInstances.Add($inst) }
            if ($vs -and -not $g.NetworkNames.Contains($vs)) { $g.NetworkNames.Add($vs) }
            $g.Kind = 'SqlFci'
        }
        elseif ($resType -like '*Availability Group*') {
            $agName = [string]$res.Name
            if ($agName -and -not $g.AGNames.Contains($agName)) { $g.AGNames.Add($agName) }
            if ($g.Kind -ne 'SqlFci') { $g.Kind = 'SqlAvailabilityGroup' }
        }
    }

    $roleList = [System.Collections.Generic.List[object]]::new()
    foreach ($grpName in ($groups.Keys | Sort-Object)) {
        $g = $groups[$grpName]
        if ($g.NetworkNames.Count -eq 0 -and $g.VirtualIPs.Count -eq 0) { continue }
        $roleList.Add([PSCustomObject]@{
            GroupName    = $g.GroupName
            Kind         = $g.Kind
            VirtualName  = if ($g.NetworkNames.Count -gt 0) { $g.NetworkNames[0] } else { $null }
            NetworkNames = @($g.NetworkNames)
            VirtualIPs   = @($g.VirtualIPs)
            SqlInstances = @($g.SqlInstances)
            AGNames      = @($g.AGNames)
            OwnerNode    = $g.OwnerNode
        })
    }
    $topology.Roles = @($roleList)

    return $topology
}

function Find-WUGDeviceMatch {
    <#
    .SYNOPSIS
        Finds an existing WUG device whose address or name exactly matches a candidate.
    #>

    [CmdletBinding()]
    param(
        [string[]]$Address,
        [string[]]$Name
    )

    $candidates = [System.Collections.Generic.List[string]]::new()
    foreach ($a in @($Address)) {
        if ($a -and -not $candidates.Contains([string]$a)) { $candidates.Add([string]$a) }
    }
    foreach ($n in @($Name)) {
        if (-not $n) { continue }
        if (-not $candidates.Contains([string]$n)) { $candidates.Add([string]$n) }
        if ($n -match '^([^.]+)\.') {
            $short = $Matches[1]
            if (-not $candidates.Contains($short)) { $candidates.Add($short) }
        }
    }

    foreach ($c in $candidates) {
        $found = $null
        try { $found = @(Get-WUGDevice -SearchValue $c) }
        catch {
            Write-Verbose "Device search for '$c' failed: $($_.Exception.Message)"
            continue
        }
        if (-not $found -or $found.Count -eq 0) { continue }

        $exact = $found | Where-Object {
            $_.networkAddress -eq $c -or $_.hostName -eq $c -or $_.displayName -eq $c
        } | Select-Object -First 1
        if ($exact) { return $exact }
    }
    return $null
}

function Get-DeviceMonitorAssignment {
    <#
    .SYNOPSIS
        Returns active and performance monitor assignments for a device in one shape.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][int]$DeviceId
    )

    $list = [System.Collections.Generic.List[object]]::new()

    try {
        foreach ($m in @(Get-WUGActiveMonitor -DeviceId $DeviceId)) {
            $aid = if ($m.DeviceMonitorAssignmentId) { $m.DeviceMonitorAssignmentId } else { $m.AssignmentId }
            if (-not $aid) { continue }
            $list.Add([PSCustomObject]@{
                AssignmentId = $aid
                Name         = if ($m.Name) { $m.Name } else { $m.MonitorTypeName }
                Kind         = 'Active'
                Enabled      = $m.Enabled
            })
        }
    }
    catch {
        Write-Verbose "Could not list active monitors for device ${DeviceId}: $($_.Exception.Message)"
    }

    try {
        foreach ($m in @(Get-WUGPerformanceMonitor -DeviceId $DeviceId -View 'basic')) {
            $aid = if ($m.AssignmentId) { $m.AssignmentId } else { $m.DeviceMonitorAssignmentId }
            if (-not $aid) { continue }
            $list.Add([PSCustomObject]@{
                AssignmentId = $aid
                Name         = if ($m.Name) { $m.Name } else { $m.MonitorTypeName }
                Kind         = 'Performance'
                Enabled      = $m.Enabled
            })
        }
    }
    catch {
        Write-Verbose "Could not list performance monitors for device ${DeviceId}: $($_.Exception.Message)"
    }

    return @($list)
}

function Set-DeviceMonitorEnabled {
    <#
    .SYNOPSIS
        Enables or disables a single monitor assignment on a device.
    .DESCRIPTION
        PUT /api/v1/devices/{deviceId}/monitors/{assignmentId} is generic across monitor
        types, so this covers both active and performance assignments.
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory = $true)][int]$DeviceId,
        [Parameter(Mandatory = $true)][string]$AssignmentId,
        [Parameter(Mandatory = $true)][bool]$Enabled
    )

    $uri  = "${global:WhatsUpServerBaseURI}/api/v1/devices/${DeviceId}/monitors/${AssignmentId}"
    $body = @{ enabled = $Enabled } | ConvertTo-Json -Depth 3

    if (-not $PSCmdlet.ShouldProcess("assignment ${AssignmentId} on device ${DeviceId}", "Set enabled=${Enabled}")) { return }

    return Get-WUGAPIResponse -Uri $uri -Method PUT -Body $body
}

#endregion

#region STEP 1 -- Probe cluster topology

Write-Host ""
Write-Host "=== WSFC / SQL Cluster Device Setup ===" -ForegroundColor Cyan
Write-Host ""
Write-Host "STEP 1: Probing cluster topology..." -ForegroundColor Cyan

$topology = $null
foreach ($node in $ClusterNode) {
    Write-Host " Querying $node ..." -ForegroundColor Gray
    $topology = Get-WSFCTopology -ComputerName $node -NodeCredential $Credential
    if ($topology) {
        Write-Host " Cluster '$($topology.ClusterName)' reported by $node." -ForegroundColor Green
        break
    }
    Write-Host " $node did not return cluster information." -ForegroundColor Yellow
}

if (-not $topology) {
    Write-Error "None of the supplied hosts returned failover cluster information. Verify WMI access and that root\MSCluster exists (the host must be a cluster node)."
    return
}

Write-Host ""
Write-Host " Cluster: $($topology.ClusterName)" -ForegroundColor White
Write-Host " Nodes: $(@($topology.Nodes | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor White
Write-Host " Roles: $(@($topology.Roles).Count)" -ForegroundColor White
Write-Host ""
foreach ($role in $topology.Roles) {
    $ipText = if ($role.VirtualIPs.Count -gt 0) { $role.VirtualIPs -join ', ' } else { '<no VIP>' }
    Write-Host " [$($role.Kind)] $($role.GroupName)" -ForegroundColor White
    Write-Host " Virtual name : $($role.VirtualName)" -ForegroundColor Gray
    Write-Host " Virtual IP : $ipText" -ForegroundColor Gray
    if ($role.SqlInstances.Count -gt 0) {
        Write-Host " SQL instance : $($role.SqlInstances -join ', ')" -ForegroundColor Gray
    }
    if ($role.AGNames.Count -gt 0) {
        Write-Host " AG : $($role.AGNames -join ', ')" -ForegroundColor Gray
    }
    Write-Host " Current owner: $($role.OwnerNode)" -ForegroundColor Gray
}
Write-Host ""

#endregion

#region STEP 2 -- Select the roles that should own a standalone device

$rolesToSetup = [System.Collections.Generic.List[object]]::new()
foreach ($role in $topology.Roles) {
    $include = $false
    switch ($role.Kind) {
        'SqlFci'               { $include = $true }
        'SqlAvailabilityGroup' { $include = -not $SkipAvailabilityGroups }
        'ClusterCore'          { $include = [bool]$IncludeClusterCore }
        default                { $include = [bool]$IncludeOtherRoles }
    }
    if (-not $include) { continue }

    if (-not $role.VirtualName) {
        Write-Warning "Role '$($role.GroupName)' has no network name resource. Skipping."
        continue
    }

    # Add-WUGDeviceTemplate accepts IPv4 addresses only.
    $ipv4 = @($role.VirtualIPs | Where-Object { $_ -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$' })
    if ($ipv4.Count -eq 0) {
        Write-Warning "Role '$($role.GroupName)' ($($role.VirtualName)) has no IPv4 virtual IP. Add its device manually."
        continue
    }

    $rolesToSetup.Add([PSCustomObject]@{
        Role      = $role
        PrimaryIP = $ipv4[0]
        AllIPv4   = $ipv4
    })
}

if ($rolesToSetup.Count -eq 0) {
    Write-Warning "No clustered roles selected for device setup. Use -IncludeClusterCore / -IncludeOtherRoles to widen the selection."
}

#endregion

#region STEP 3 -- Audit and create the virtual devices

Write-Host "STEP 2: Auditing WhatsUp Gold devices..." -ForegroundColor Cyan

# Node devices -- required, because node-local resources belong to them.
$nodeDevices = @{}   # node name -> device object
foreach ($n in $topology.Nodes) {
    $nodeDev = Find-WUGDeviceMatch -Name @($n.Name)
    if ($nodeDev) {
        $nodeDevices[$n.Name] = $nodeDev
        Write-Host " Node '$($n.Name)' -> device #$($nodeDev.id) '$($nodeDev.displayName)'" -ForegroundColor Green
    }
    else {
        Write-Host " Node '$($n.Name)' -> NO DEVICE IN WUG" -ForegroundColor Yellow
        Write-Warning "Node '$($n.Name)' has no WUG device. Node-local resources (CPU, memory, disk, cluster service) cannot be monitored until you add it."
    }
}
Write-Host ""

Write-Host "STEP 3: Reconciling virtual (shared) devices..." -ForegroundColor Cyan

$createdDevices  = [System.Collections.Generic.List[object]]::new()
$existingDevices = [System.Collections.Generic.List[object]]::new()
$virtualDevices  = @{}   # virtual name -> device id

foreach ($entry in $rolesToSetup) {
    $role      = $entry.Role
    $vName     = $role.VirtualName
    $vIP       = $entry.PrimaryIP
    $shortName = if ($vName -match '^([^.]+)\.') { $Matches[1] } else { $vName }

    $existing = Find-WUGDeviceMatch -Address $entry.AllIPv4 -Name @($vName, $shortName)

    if ($existing) {
        $virtualDevices[$vName] = [int]$existing.id
        $existingDevices.Add([PSCustomObject]@{ Name = $vName; IP = $vIP; DeviceId = [int]$existing.id })
        Write-Host " [EXISTS] $vName ($vIP) -> device #$($existing.id) '$($existing.displayName)'" -ForegroundColor Gray

        if ($existing.networkAddress -and $existing.networkAddress -ne $vIP) {
            Write-Warning " Device #$($existing.id) matched '$vName' but its address is '$($existing.networkAddress)', not the virtual IP '$vIP'. Verify it is the standalone virtual device and not a node."
        }
        continue
    }

    if ($SkipDeviceCreation) {
        Write-Host " [MISSING] $vName ($vIP) -- creation skipped (-SkipDeviceCreation)" -ForegroundColor Yellow
        continue
    }

    $attrs = @(
        @{ name = 'Cluster.Name';      value = "$($topology.ClusterName)" }
        @{ name = 'Cluster.Role';      value = "$($role.Kind)" }
        @{ name = 'Cluster.Group';     value = "$($role.GroupName)" }
        @{ name = 'Cluster.Nodes';     value = "$(@($topology.Nodes | ForEach-Object { $_.Name }) -join ',')" }
        @{ name = 'Cluster.VirtualIP'; value = "$($entry.AllIPv4 -join ',')" }
    )
    if ($role.SqlInstances.Count -gt 0) {
        $attrs += @{ name = 'Cluster.SqlInstance'; value = "$($role.SqlInstances -join ',')" }
    }
    if ($role.AGNames.Count -gt 0) {
        $attrs += @{ name = 'Cluster.AvailabilityGroup'; value = "$($role.AGNames -join ',')" }
    }

    $note = "Standalone device for clustered role '$($role.GroupName)' in WSFC '$($topology.ClusterName)'. " +
            "This device owns SHARED cluster resources and follows the role across failover. " +
            "Do not monitor node-local resources here; use the node devices for that. " +
            "Created by Setup-SQLClusterDevices.ps1 on $(Get-Date -Format 'yyyy-MM-dd')."

    $splat = @{
        displayName   = $vName
        DeviceAddress = $vIP
        Hostname      = $vName
        Brand         = $Brand
        OS            = 'Windows'
        Note          = $note
        Attributes    = $attrs
    }
    if ($DeviceGroupName)       { $splat['GroupName'] = $DeviceGroupName }
    if ($WindowsCredentialName) { $splat['CredentialWindows'] = $WindowsCredentialName }

    if (-not $PSCmdlet.ShouldProcess("$vName ($vIP)", 'Create standalone WUG device for clustered role')) {
        Write-Host " [WHATIF] Would create $vName ($vIP)" -ForegroundColor Yellow
        continue
    }

    Write-Host " [CREATE] $vName ($vIP)" -ForegroundColor White
    try {
        $null = Add-WUGDeviceTemplate @splat

        $newDev = Find-WUGDeviceMatch -Address @($vIP) -Name @($vName, $shortName)
        if ($newDev) {
            $virtualDevices[$vName] = [int]$newDev.id
            $createdDevices.Add([PSCustomObject]@{ Name = $vName; IP = $vIP; DeviceId = [int]$newDev.id })
            Write-Host " Created device #$($newDev.id)" -ForegroundColor Green
        }
        else {
            Write-Warning " Created '$vName' but could not resolve its device ID afterwards."
        }
    }
    catch {
        Write-Error " Failed to create device for '$vName' ($vIP): $_"
    }
}
Write-Host ""

#endregion

#region STEP 4 -- Cleanup: virtual IPs attached as interfaces on node devices

Write-Host "STEP 4: Checking for virtual IPs attached to node devices..." -ForegroundColor Cyan

$vipSet = @{}
foreach ($entry in $rolesToSetup) {
    foreach ($ip in $entry.AllIPv4) { $vipSet[$ip] = $entry.Role.VirtualName }
}

$strayInterfaceCount   = 0
$removedInterfaceCount = 0

foreach ($nodeName in ($nodeDevices.Keys | Sort-Object)) {
    $nodeDev = $nodeDevices[$nodeName]
    $nodeId  = [int]$nodeDev.id

    $interfaces = @()
    try { $interfaces = @(Get-WUGDeviceInterface -DeviceId $nodeId) }
    catch {
        Write-Verbose "Could not list interfaces for node device ${nodeId}: $($_.Exception.Message)"
        continue
    }

    foreach ($iface in $interfaces) {
        $ifAddr = if ($iface.address) { [string]$iface.address } else { [string]$iface.networkAddress }
        $ifId   = if ($iface.interfaceId) { [string]$iface.interfaceId } else { [string]$iface.id }
        if (-not $ifAddr -or -not $vipSet.ContainsKey($ifAddr)) { continue }

        $strayInterfaceCount++
        Write-Host " [STRAY] Node '$nodeName' (#$nodeId) carries virtual IP $ifAddr (role '$($vipSet[$ifAddr])') as an interface." -ForegroundColor Yellow
        Write-Host " While the node owns the role this looks healthy even when the role has failed." -ForegroundColor DarkGray

        if (-not $RemoveVipInterfaceFromNodes) { continue }
        if (-not $ifId) {
            Write-Warning " Could not determine the interface ID; remove it manually."
            continue
        }

        if (-not $PSCmdlet.ShouldProcess("interface $ifAddr on node device #$nodeId ($nodeName)", 'Remove virtual IP interface')) { continue }

        try {
            Remove-WUGDeviceInterface -DeviceId $nodeId -InterfaceId $ifId -Confirm:$false | Out-Null
            $removedInterfaceCount++
            Write-Host " Removed." -ForegroundColor Green
        }
        catch {
            Write-Warning " Failed to remove interface $ifAddr from node device #${nodeId}: $_"
        }
    }
}

if ($strayInterfaceCount -eq 0) {
    Write-Host " None found." -ForegroundColor Green
}
elseif (-not $RemoveVipInterfaceFromNodes) {
    Write-Host " Re-run with -RemoveVipInterfaceFromNodes to detach them." -ForegroundColor Cyan
}
Write-Host ""

#endregion

#region STEP 5 -- Cleanup: shared-resource monitors sitting on the physical nodes

Write-Host "STEP 5: Checking node devices for shared-resource monitors ($NodeCleanupAction mode)..." -ForegroundColor Cyan

# Build the match patterns from what we actually discovered, plus anything the caller added.
$patterns = [System.Collections.Generic.List[string]]::new()
foreach ($entry in $rolesToSetup) {
    $role = $entry.Role
    foreach ($nn in $role.NetworkNames) {
        if ($nn) { $patterns.Add("*$nn*") }
    }
    foreach ($inst in $role.SqlInstances) {
        if ($inst -and $inst -ne 'MSSQLSERVER') { $patterns.Add("*$inst*") }
    }
    foreach ($ag in $role.AGNames) {
        if ($ag) { $patterns.Add("*$ag*") }
    }
    foreach ($ip in $entry.AllIPv4) { $patterns.Add("*$ip*") }
}
foreach ($p in @($SharedMonitorPattern)) {
    if ($p) { $patterns.Add($p) }
}

$uniquePatterns = @($patterns | Sort-Object -Unique)
if ($uniquePatterns.Count -gt 0) {
    Write-Host " Matching monitor names against: $($uniquePatterns -join ', ')" -ForegroundColor Gray
}

$flaggedCount  = 0
$changedCount  = 0
$failedCount   = 0
$cleanupReport = [System.Collections.Generic.List[object]]::new()

if ($uniquePatterns.Count -eq 0) {
    Write-Host " No patterns to match. Supply -SharedMonitorPattern to search node devices." -ForegroundColor Yellow
}
else {
    foreach ($nodeName in ($nodeDevices.Keys | Sort-Object)) {
        $nodeDev = $nodeDevices[$nodeName]
        $nodeId  = [int]$nodeDev.id

        foreach ($mon in (Get-DeviceMonitorAssignment -DeviceId $nodeId)) {
            $monName = [string]$mon.Name
            if (-not $monName) { continue }

            $matched = $false
            foreach ($pat in $uniquePatterns) {
                if ($monName -like $pat) { $matched = $true; break }
            }
            if (-not $matched) { continue }

            $flaggedCount++
            $cleanupReport.Add([PSCustomObject]@{
                Node         = $nodeName
                DeviceId     = $nodeId
                Kind         = $mon.Kind
                Monitor      = $monName
                AssignmentId = $mon.AssignmentId
                Enabled      = $mon.Enabled
            })

            $stateText = if ($mon.Enabled -eq $false) { 'already disabled' } else { 'enabled' }
            Write-Host " [SHARED] $nodeName (#$nodeId) $($mon.Kind): '$monName' ($stateText)" -ForegroundColor Yellow

            if ($NodeCleanupAction -eq 'Report') { continue }

            if ($NodeCleanupAction -eq 'Disable') {
                if ($mon.Enabled -eq $false) { continue }
                if (-not $PSCmdlet.ShouldProcess("'$monName' on node device #$nodeId ($nodeName)", 'Disable shared-resource monitor')) { continue }
                try {
                    $null = Set-DeviceMonitorEnabled -DeviceId $nodeId -AssignmentId $mon.AssignmentId -Enabled $false -Confirm:$false
                    $changedCount++
                    Write-Host " Disabled." -ForegroundColor Green
                }
                catch {
                    $failedCount++
                    Write-Warning " Failed to disable '$monName' on node device #${nodeId}: $_"
                }
            }
            else {
                if (-not $PSCmdlet.ShouldProcess("'$monName' on node device #$nodeId ($nodeName)", 'Remove shared-resource monitor assignment')) { continue }
                try {
                    Remove-WUGDeviceMonitor -DeviceId $nodeId -AssignmentId $mon.AssignmentId -Confirm:$false | Out-Null
                    $changedCount++
                    Write-Host " Removed." -ForegroundColor Green
                }
                catch {
                    $failedCount++
                    Write-Warning " Failed to remove '$monName' from node device #${nodeId}: $_"
                }
            }
        }
    }

    if ($flaggedCount -eq 0) {
        Write-Host " No shared-resource monitors found on the node devices." -ForegroundColor Green
    }
}
Write-Host ""

#endregion

#region STEP 6 -- Summary

Write-Host "=== Summary ===" -ForegroundColor Cyan
Write-Host " Cluster: $($topology.ClusterName)" -ForegroundColor White
Write-Host " Nodes with a WUG device: $($nodeDevices.Count) of $(@($topology.Nodes).Count)" -ForegroundColor White
Write-Host " Virtual devices already set: $($existingDevices.Count)" -ForegroundColor White
Write-Host " Virtual devices created: $($createdDevices.Count)" -ForegroundColor $(if ($createdDevices.Count -gt 0) { 'Green' } else { 'Gray' })
Write-Host " Stray VIP interfaces on nodes: $strayInterfaceCount (removed: $removedInterfaceCount)" -ForegroundColor $(if ($strayInterfaceCount -gt 0) { 'Yellow' } else { 'Gray' })
Write-Host " Shared monitors on nodes: $flaggedCount (changed: $changedCount, failed: $failedCount)" -ForegroundColor $(if ($flaggedCount -gt 0) { 'Yellow' } else { 'Gray' })
Write-Host ""

if ($cleanupReport.Count -gt 0 -and $NodeCleanupAction -eq 'Report') {
    Write-Host " Outstanding cleanup:" -ForegroundColor Yellow
    $cleanupReport | Format-Table Node, DeviceId, Kind, Monitor, AssignmentId, Enabled -AutoSize
    Write-Host " Re-run with -NodeCleanupAction Disable to disable these on the nodes." -ForegroundColor Cyan
    Write-Host ""
}

if ($virtualDevices.Count -gt 0) {
    Write-Host " Next step -- attach SQL monitoring to the virtual device(s):" -ForegroundColor Cyan
    foreach ($vn in ($virtualDevices.Keys | Sort-Object)) {
        Write-Host " ..\helpers\discovery\Setup-MSSQL-Discovery.ps1 -DeviceId $($virtualDevices[$vn]) -Action PushToWUG # $vn" -ForegroundColor Gray
    }
    Write-Host ""
    Write-Host " Keep node-local monitoring (CPU, memory, disk, Cluster Service) on the node devices." -ForegroundColor Gray
    Write-Host ""
}

#endregion

# SIG # Begin signature block
# MIIr+wYJKoZIhvcNAQcCoIIr7DCCK+gCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDrsLxLnM81EFP4
# sR+C72RK7dJ2VbWzVPRONgDCtdXodKCCJQ0wggVvMIIEV6ADAgECAhBI/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/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggWNMIIEdaADAgECAhAOmxiO
# +dAt5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYD
# VQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAi
# BgNVBAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAw
# MDBaFw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdp
# Q2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERp
# Z2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
# AgoCggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsb
# hA3EMB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iT
# cMKyunWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGb
# NOsFxl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclP
# XuU15zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCr
# VYJBMtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFP
# ObURWBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTv
# kpI6nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWM
# cCxBYKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls
# 5Q5SUUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBR
# a2+xq4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6
# MIIBNjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qY
# rhwPTzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8E
# BAMCAYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5k
# aWdpY2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0
# LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDig
# NoY0aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9v
# dENBLmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCg
# v0NcVec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQT
# SnovLbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh
# 65ZyoUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSw
# uKFWjuyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAO
# QGPFmCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjD
# TZ9ztwGpn1eqXijiuZQwggYaMIIEAqADAgECAhBiHW0MUgGeO5B5FSCJIRwKMA0G
# CSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExp
# bWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBSb290
# IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEyMzU5NTlaMFQxCzAJBgNVBAYT
# AkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNlY3RpZ28g
# UHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGiMA0GCSqGSIb3DQEBAQUAA4IB
# jwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shTUxjIztNsfvxYB5UXeWUzCxEe
# AEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2A2NVDgFigOMYzB2OKhdqfWGV
# oYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtbam+/36F09fy1tsB8je/RV0mIk
# 8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP05ZwmRmTnAO5/arnY83jeNzh
# P06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9SJDm+qxp4VqpB3MV/h53yl41
# aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4TpxtwfvjsUedyz8rNyfQJy/aOs5b4s+
# ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj8pz44MPZ1f9+YEQIQty/NQd/
# 2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2oQBMdlyh2n5HirY4jKnFH/9g
# Rvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1UdIwQYMBaAFDLrkpr/NZZILyhA
# QnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9IritUpimqF6TNDDAOBgNVHQ8B
# Af8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNVHSUEDDAKBggrBgEFBQcD
# AzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEsGA1UdHwREMEIwQKA+oDyG
# Omh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVTaWduaW5n
# Um9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYGCCsGAQUFBzAChjpodHRwOi8v
# Y3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2RlU2lnbmluZ1Jvb3RSNDYu
# cDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG
# 9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6YdURhw1aVcdGRP4Wh60BAscjW
# 4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIBD0ZdOaWTsyNyBBsMLHqafvIh
# rCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOtBajjcw5+w/KeFvPYfLF/ldYp
# mlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0OhNcWbWDRF/3sBp6fWXhz7Dc
# ML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMbOalOhOfCipnx8CaLZeVme5yE
# Lg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxAMcJszkyeiaerlphwoKx1uHRz
# NyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVSZ2z76mKnzAfZxCl/3dq3dUNw
# 4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN/Y5JKdGvspbOrTfOXyXvmPL6
# E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjUYbHHj95Ejza63zdrEcxWLDX6
# xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8X8z2Bev6SivBBOHY+uqiirZt
# g0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1oDE5/L9Uo2bC5a4CH2RwwggY+
# MIIEpqADAgECAhAHnODk0RR/hc05c892LTfrMA0GCSqGSIb3DQEBDAUAMFQxCzAJ
# BgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNl
# Y3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwHhcNMjYwMjA5MDAwMDAw
# WhcNMjkwNDIxMjM1OTU5WjBVMQswCQYDVQQGEwJVUzEUMBIGA1UECAwLQ29ubmVj
# dGljdXQxFzAVBgNVBAoMDkphc29uIEFsYmVyaW5vMRcwFQYDVQQDDA5KYXNvbiBB
# bGJlcmlubzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPN6aN4B1yYW
# kI5b5TBj3I0VV/peETrHb6EY4BHGxt8Ap+eT+WpEpJyEtRYPxEmNJL3A38Bkg7mw
# zPE3/1NK570ZBCuBjSAn4mSDIgIuXZnvyBO9W1OQs5d67MlJLUAEufl18tOr3ST1
# DeO9gSjQSAE5Nql0QDxPnm93OZBon+Fz3CmE+z3MwAe2h4KdtRAnCqwM+/V7iBdb
# w+JOxolpx+7RVjGyProTENIG3pe/hKvPb501lf8uBAADLdjZr5ip8vIWbf857Yw1
# Bu10nVI7HW3eE8Cl5//d1ribHlzTzQLfttW+k+DaFsKZBBL56l4YAlIVRsrOiE1k
# dHYYx6IGrEA809R7+TZA9DzGqyFiv9qmJAbL4fDwetDeyIq+Oztz1LvEdy8Rcd0J
# BY+J4S0eDEFIA3X0N8VcLeAwabKb9AjulKXwUeqCJLvN79CJ90UTZb2+I+tamj0d
# n+IKMEsJ4v4Ggx72sxFr9+6XziodtTg5Luf2xd6+PhhamOxF2px9LObhBLLEMyRs
# CHZIzVZOFKu9BpHQH7ufGB+Sa80Tli0/6LEyn9+bMYWi2ttn6lLOPThXMiQaooRU
# q6q2u3+F4SaPlxVFLI7OJVMhar6nW6joBvELTJPmANSMjDSRFDfHRCdGbZsL/keE
# LJNy+jZctF6VvxQEjFM8/bazu6qYhrA7AgMBAAGjggGJMIIBhTAfBgNVHSMEGDAW
# gBQPKssghyi47G9IritUpimqF6TNDDAdBgNVHQ4EFgQU6YF0o0D5AVhKHbVocr8G
# aSIBibAwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYI
# KwYBBQUHAwMwSgYDVR0gBEMwQTA1BgwrBgEEAbIxAQIBAwIwJTAjBggrBgEFBQcC
# ARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EMAQQBMEkGA1UdHwRCMEAw
# PqA8oDqGOGh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVT
# aWduaW5nQ0FSMzYuY3JsMHkGCCsGAQUFBwEBBG0wazBEBggrBgEFBQcwAoY4aHR0
# cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGljQ29kZVNpZ25pbmdDQVIz
# Ni5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMA0GCSqG
# SIb3DQEBDAUAA4IBgQAEIsm4xnOd/tZMVrKwi3doAXvCwOA/RYQnFJD7R/bSQRu3
# wXEK4o9SIefye18B/q4fhBkhNAJuEvTQAGfqbbpxow03J5PrDTp1WPCWbXKX8Oz9
# vGWJFyJxRGftkdzZ57JE00synEMS8XCwLO9P32MyR9Z9URrpiLPJ9rQjfHMb1BUd
# vaNayomm7aWLAnD+X7jm6o8sNT5An1cwEAob7obWDM6sX93wphwJNBJAstH9Ozs6
# LwISOX6sKS7CKm9N3Kp8hOUue0ZHAtZdFl6o5u12wy+zzieGEI50fKnN77FfNKFO
# WKlS6OJwlArcbFegB5K89LcE5iNSmaM3VMB2ADV1FEcjGSHw4lTg1Wx+WMAMdl/7
# nbvfFxJ9uu5tNiT54B0s+lZO/HztwXYQUczdsFon3pjsNrsk9ZlalBi5SHkIu+F6
# g7tWiEv3rtVApmJRnLkUr2Xq2a4nbslUCt4jKs5UX4V1nSX8OM++AXoyVGO+iTj7
# z+pl6XE9Gw/Td6WKKKswgga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0G
# CSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
# IFRydXN0ZWQgUm9vdCBHNDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTla
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEy
# NTYgMjAyNSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHT
# CphBcr48RsAcrHXbo0ZodLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPh
# of6pvF4uGjwjqNjfEvUi6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mA
# xAHeHYNnQxqXmRinvuNgxVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBv
# MgEdgkFiDNYiOTx4OtiFcMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps
# 0wjUjsZvkgFkriK9tUKJm/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF
# 83bRVFLeGkuAhHiGPMvSGmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXi
# UOeSLsJygoLPp66bkDX1ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOM
# CZIVNSaz7BX8VtYGqLt9MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydP
# pOjL6s36czwzsucuoKs7Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrU
# G2ZdSoQbU2rMkpLiQ6bGRinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+
# sdFUeEY0qVjPKOWug/G6X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0T
# AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYD
# VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG
# A1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV
# HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU
# cnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1s
# BwEwDQYJKoZIhvcNAQELBQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WI
# GjB/T8ObXAZz8OjuhUxjaaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+
# IQhQE7jU/kXjjytJgnn0hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8M
# yb9rEVKChHyfpzee5kH0F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2
# th9y1IsA0QF8dTXqvcnTmpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjaj
# V/gxdEkMx1NKU4uHQcKfZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2
# Lr3ty9qIijanrUR3anzEwlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFze
# GxcytL5TTLL4ZaoBdqbhOhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG
# 7uEBYTptMSbhdhGQDpOXgpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+N
# Jpud/v4+7RWsWCiKi9EOLLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckT
# etiSuEtQvLsNz3Qbp7wGWqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszW
# kPZPubdcMIIG7TCCBNWgAwIBAgIQCE/cM09+RU7bww+P+ZIYNTANBgkqhkiG9w0B
# AQsFADBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/
# BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYg
# U0hBMjU2IDIwMjUgQ0ExMB4XDTI2MDgwNTAwMDAwMFoXDTM3MTEwNDIzNTk1OVow
# YzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQD
# EzJEaWdpQ2VydCBTSEEyNTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIw
# MjYgMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALZ7pvLJ/s1K+NSb
# TGWz/TjGMPh8CQ6RucZCLv5anHzWJjF/NWJrFIhy24fcpKXlgRiky4WAawDfU3YP
# 0BMxt9l3Dm5oCG5Z69AqEN1kgHg2epx+l+lZBcmJCcN0ASURML5uFIS80sZsDwO3
# BSkUxDjLJhBI+qiZP3aixAC/qEGLjsBNlLol9VZ7pfGEXiMlneJIC5/YKuizVzNF
# KZZEeoy/0B8Zm+nzKBgSWG52lCO1w+nCg6XpCtklTJXeIg283hw7TmmsZXR+SMbj
# brEOvZ3fP2VxIgeR28Y90ZStd3F9VuA5RVynb/whITPAo9b75Zr4Ta6Mj3URm26Q
# ZYMn/FnbuTegcoRcFEZ9FOqM5T6MTdtr/n74lIT/ug0eeOzmZ6QTFg33otX+bFRs
# IolvykE1jive4PuESaT8zzVeFWDAMDtozNgLctkGD1ZjkEyZtJrLl5ya0m5doH/S
# cpaZCZVl6pNUOCybMc/kxC6EAmSJY24L0yYKD1Nkddsnb/ItVKi/2nXpQNMu1PT5
# prW83vV8d67WowuUs0HdY4H8AMLGvdL/WHEj3ZnqMqAQQP9u3Ai9t+5eQ02GDwy0
# ODjdzi0xlp70W+ow63/0++YDEX1M0iwgUHwbrJvfpklkZQvw3+kv3vUPItdwrocz
# k9icflf55W1zOEKAcJVAIXpcMCU9AgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAA
# MB0GA1UdDgQWBBQUyWOKMC7USvtulPPm40B+9ezN4jAfBgNVHSMEGDAWgBTvb1NK
# 6eQGfHrK4pBW9i/USezLTjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYI
# KwYBBQUHAwgwgZUGCCsGAQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8v
# b2NzcC5kaWdpY2VydC5jb20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRp
# Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZT
# SEEyNTYyMDI1Q0ExLmNydDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5k
# aWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2
# U0hBMjU2MjAyNUNBMS5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9
# bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQCNxTphHp1SCt+ZrAmAfn0oQLFr0mLywSLa
# DXQIENoyKqxrFbJblzCVP/pkXmwXOdrOpWygLzlT12os5ipDCy35RBCg2UMeApEt
# rfGhz45F4Wt4WGdNdIbRWt3YTYJmpR+b7lr4d7Uwn+H600u4D7RnOGf8Wj4UNgAd
# ZkfHhHv1mx9EVh71SJelcEN/oORSjXzdjfw1iZH9d8Nh/thn6hH23d+VsPAr6GAY
# yzSA02nXD1nYLI7Ijmiv+xLCiYC41DSFYL3GhTiy0PxpawPtGRyaBVGzq+UiTfM8
# pD7KVyF5aQyWP4KhVGUUTnmm/RlYJoW3TiXA/+t0YcT2oRVBm3JETjajHug2AL+v
# 5jhtKVnd3D0rbHXEu27o+Q8p4sEWPMqKDB+qbceb6T/6WcwTwXmQ9lOCLLYcsQeS
# WmvKqzpAec9etE14jOQAzLKWdE3w/TCaKtLRaRT7LCkRYVnhA2D73FLje1O5b3HR
# 5eHs0NzU/+xX7NbEdcofy0W3Wdwd1XOqtlpg/JgwtKfZM5dqO94lbUveOiJBI+xZ
# EbGRsMNbXmMREUTgu+Oca7Y73MPWcslIx2VhkSKSXjDbD6rgg39H5Mh7QfieAIjW
# agkJNt68Yfim6cjEzVSiLSeZfdkr5dtFPTW6jATlWJdYeeDRGCyatf8R1hSjzSvd
# N8yWQPT9gzGCBkQwggZAAgEBMGgwVDELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1Nl
# Y3RpZ28gTGltaXRlZDErMCkGA1UEAxMiU2VjdGlnbyBQdWJsaWMgQ29kZSBTaWdu
# aW5nIENBIFIzNgIQB5zg5NEUf4XNOXPPdi036zANBglghkgBZQMEAgEFAKCBhDAY
# BgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3
# AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEi
# BCA1TV5+JxJY5GC3WznQLDQot6d7IFGFwGIQZ6aQCW1Y9zANBgkqhkiG9w0BAQEF
# AASCAgApO8K1TzAFFxTFx+gQi/aj8kgJ6+h5BbDU7hXGD8J0K+Kjg4ZP3KEO/Nx0
# mw2xiew3cshYsUfBrmNLFs8nJjfQho5+rfzBtDn3igtziI2Z4ASwzEvZ4twHipKj
# v6wvv3Gva9/Y/arS2BaEA1R2cWGjbgpKkPORUl4SXcVPsQieW5MOAgZIDjpDjU8g
# ReQr+Z9FJQppGavSS07yLLuIMGnScWaBBhr9AJR2UaGlHmCd15nQZyXZW8yttR+Z
# 4GOqJ2JgRHLoPCAxe23iTfB9z/lfQt43RG1/oT2dCxAHHfYScXYzXXC9uzSSv5/Z
# j+COcSDBtiMYzK9wFEBUAuQh2nf2RCYN4CRa0WqUNpA+tdoVBzr+DN6cF5OxH/DT
# NK7v5yGouUjGsc8Y3Ub2JpsMiJLT9QVzNMymzV3G5mIQVptI/xvs6Jz3ObyOH710
# yyoIclmDHiywodxiY87sucqL8UVrkVjUwke3duI3jV3cFVRlVYAKnRAAIeh2wPCh
# R8yYllW4AanpnQQZeaRw3s9nGaJoY7ljjxwb2t1KDS69VTBhtZvS96WOPKrMGlSL
# Yll61Bs88xMIZkWu3uqY92N6slSPGoqZL8P+IXyKtuPpXnSBDzkgPf3fToZ1D3YK
# e5wRGRvGR5I4xBoFlvNRqU91G8vrJ0s0CgviUjH3jQU54m1BEKGCAyYwggMiBgkq
# hkiG9w0BCQYxggMTMIIDDwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5E
# aWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1l
# U3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTECEAhP3DNPfkVO28MPj/mS
# GDUwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwG
# CSqGSIb3DQEJBTEPFw0yNjA5MDYxNjM2MjFaMC8GCSqGSIb3DQEJBDEiBCC4AIJ0
# sdQ534V95TMklYOwE83NSZCgCC7SetErn1mB9TANBgkqhkiG9w0BAQEFAASCAgBX
# Vo6lS8aZL9jen10Ugd6HvkSXn1B6uK7LYyIy/UxjaltaxqHhp5V8hR0niIYYB+EU
# nrMjPoa39kdrc9SLLLkIisdi7TfIQijXgeGnkN/nJnJgXxP+dwob5dPeU1Xtx37p
# n5/vL6rvi/R8cMgcXc08gqMEzS/0CY2JXqncd4caUQKHHZhobzHC/SfcuWdlrBPB
# Nmf0c7hYY7stfaMrN9UvQMRfuxZ/NVVtjHfSQA3pqjUxFMeGUUPATMaavFiCTYYP
# gjQYg5AiawT3rFdnXo/tY2O09x3ShdEkOif0l0RMkMNIIfXa2sqFlShYJBNdbGVu
# 9kMO3kDAKEypaxsJEpsa1s71UEcVoXPvXpgHpfMBXI4aWsSKIX1rL23eZ67vr4tV
# K9EUucGNYeHd6QJ4KsckyT+/abhl7JUBVzypQVYaWB4OJ4CKOfU/nUR19kHe6sE7
# X1BFCJqLN152QnI1LgdCKL684djE6AXS95tGLTzOfNjyJ+oVWSZFwMKcsI+xQJSS
# HtI3VbkN+IhRJrqYNMSNHt0gkdnUndN4/0zbH40jVLbmnSZn3lZofloppHMg9kYL
# KhHqZZYExxMHSSsgcNy4ljEWKHKTBuWb1gsHFS/nPAh2Z3J4H/CxV2Gv5AnAuE8t
# HIxPj2akbGj9TYMdDoI7021wAT36JloYrlW9UaX2tQ==
# SIG # End signature block