helpers/f5/F5Helpers.ps1

# =============================================================================
# F5 BIG-IP Helpers for WhatsUpGoldPS
# Uses the F5 iControl REST API (BIG-IP 11.5+).
# No additional modules required - uses Invoke-RestMethod directly.
#
# Typical workflow:
# 1. Connect-F5Server (authenticates, stores token/headers in script scope)
# 2. Get-F5VirtualServers / Get-F5Pools / Get-F5PoolMembers (query data)
# 3. Get-F5Dashboard (builds a combined view of VS + pools + members)
# 4. Export-F5DashboardHtml (renders an HTML report)
# =============================================================================

# ---------------------------------------------------------------------------
# Script-scoped state - keeps auth context between calls
# ---------------------------------------------------------------------------
$script:F5Session = @{
    BaseUri  = $null
    Headers  = $null
    Token    = $null
}

# ---------------------------------------------------------------------------
# Connect-F5Server
# ---------------------------------------------------------------------------
function Connect-F5Server {
    <#
    .SYNOPSIS
        Authenticates to an F5 BIG-IP appliance via iControl REST.
    .DESCRIPTION
        Obtains an authentication token from /mgmt/shared/authn/login and
        stores it for subsequent helper calls. Falls back to Basic auth if
        token-based auth is unavailable.
    .PARAMETER F5Host
        Hostname or IP address of the BIG-IP management interface.
    .PARAMETER Credential
        PSCredential for a user with at least read access to the BIG-IP.
    .PARAMETER Port
        Management port. Defaults to 443.
    .PARAMETER IgnoreSSLErrors
        Skip certificate validation (self-signed certs on the BIG-IP).
    .EXAMPLE
        $cred = Get-Credential
        Connect-F5Server -F5Host "bigip01.domain.com" -Credential $cred
        # Connects using default port 443 with certificate validation.
    .EXAMPLE
        Connect-F5Server -F5Host "10.0.0.50" -Credential $cred -Port 8443 -IgnoreSSLErrors
        # Connects on port 8443 and skips self-signed cert validation.
    .EXAMPLE
        $cred = Get-Credential -Message "F5 Admin"
        Connect-F5Server -F5Host "bigip-ha01" -Credential $cred -IgnoreSSLErrors
        Get-F5SystemInfo
        # Connect then immediately verify with system info.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$F5Host,
        [Parameter(Mandatory)][PSCredential]$Credential,
        [int]$Port = 443,
        [switch]$IgnoreSSLErrors
    )

    if ($IgnoreSSLErrors) {
        # PowerShell 7+
        if ($PSVersionTable.PSVersion.Major -ge 7) {
            $script:F5SkipCert = $true
        }
        else {
            # Windows PowerShell 5.1
            try {
                Add-Type @"
using System.Net;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
public class TrustAllCerts {
    public static void Ignore() {
        ServicePointManager.ServerCertificateValidationCallback =
            delegate { return true; };
    }
}
"@

                [TrustAllCerts]::Ignore()
            }
            catch {
                # Type may already be loaded
            }
        }
    }

    $script:F5Session.BaseUri = "https://${F5Host}:${Port}"

    # Attempt token-based auth
    $loginBody = @{
        username          = $Credential.UserName
        password          = $Credential.GetNetworkCredential().Password
        loginProviderName = "tmos"
    } | ConvertTo-Json

    $splat = @{
        Uri         = "$($script:F5Session.BaseUri)/mgmt/shared/authn/login"
        Method      = "POST"
        Body        = $loginBody
        ContentType = "application/json"
        ErrorAction = "Stop"
    }
    if ($script:F5SkipCert) { $splat["SkipCertificateCheck"] = $true }

    try {
        $response = Invoke-RestMethod @splat
        $script:F5Session.Token = $response.token.token
        $script:F5Session.Headers = @{
            "X-F5-Auth-Token" = $script:F5Session.Token
            "Content-Type"    = "application/json"
        }
        Write-Verbose "Authenticated to F5 $F5Host via token."
    }
    catch {
        # Fallback: basic auth
        Write-Verbose "Token auth failed ($($_.Exception.Message)). Falling back to Basic auth."
        $pair = "$($Credential.UserName):$($Credential.GetNetworkCredential().Password)"
        $encoded = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($pair))
        $script:F5Session.Headers = @{
            "Authorization" = "Basic $encoded"
            "Content-Type"  = "application/json"
        }
        $script:F5Session.Token = $null
    }

    # Validate connectivity
    $testSplat = @{
        Uri         = "$($script:F5Session.BaseUri)/mgmt/tm/sys/version"
        Headers     = $script:F5Session.Headers
        Method      = "GET"
        ErrorAction = "Stop"
    }
    if ($script:F5SkipCert) { $testSplat["SkipCertificateCheck"] = $true }

    try {
        $version = Invoke-RestMethod @testSplat
        $entry = $version.entries.PSObject.Properties | Select-Object -First 1
        $ver = $entry.Value.nestedStats.entries.Version.description
        $build = $entry.Value.nestedStats.entries.Build.description
        Write-Verbose "Connected to F5 BIG-IP version $ver build $build"
    }
    catch {
        throw "Failed to connect to F5 BIG-IP at $F5Host : $($_.Exception.Message)"
    }
}

# ---------------------------------------------------------------------------
# Internal: Invoke-F5RestMethod
# ---------------------------------------------------------------------------
function Invoke-F5RestMethod {
    <#
    .SYNOPSIS
        Internal wrapper for REST calls to the F5 BIG-IP.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Endpoint,
        [string]$Method = "GET"
    )

    if (-not $script:F5Session.BaseUri) {
        throw "Not connected to an F5 BIG-IP. Run Connect-F5Server first."
    }

    $uri = "$($script:F5Session.BaseUri)$Endpoint"
    $splat = @{
        Uri         = $uri
        Headers     = $script:F5Session.Headers
        Method      = $Method
        ErrorAction = "Stop"
    }
    if ($script:F5SkipCert) { $splat["SkipCertificateCheck"] = $true }

    Invoke-RestMethod @splat
}

# ---------------------------------------------------------------------------
# Get-F5SystemInfo
# ---------------------------------------------------------------------------
function Get-F5SystemInfo {
    <#
    .SYNOPSIS
        Returns basic system information for the connected BIG-IP.
    .DESCRIPTION
        Queries /mgmt/tm/sys/global-settings and /mgmt/tm/sys/version to
        return hostname, version, build, base MAC, etc.
    .EXAMPLE
        Get-F5SystemInfo
        # Returns hostname, version, build, edition, and base MAC.
    .EXAMPLE
        $info = Get-F5SystemInfo
        Write-Host "Connected to $($info.Hostname) running v$($info.Version)"
    #>

    [CmdletBinding()]
    param()

    $gs = Invoke-F5RestMethod -Endpoint "/mgmt/tm/sys/global-settings"
    $ver = Invoke-F5RestMethod -Endpoint "/mgmt/tm/sys/version"
    $entry = $ver.entries.PSObject.Properties | Select-Object -First 1
    $v = $entry.Value.nestedStats.entries

    [PSCustomObject]@{
        Hostname    = $gs.hostname
        Version     = $v.Version.description
        Build       = $v.Build.description
        Edition     = $v.Edition.description
        Product     = $v.Product.description
        BaseMac     = if ($gs.baseMac) { $gs.baseMac } else { "N/A" }
        ConsoleIP   = $script:F5Session.BaseUri
    }
}

# ---------------------------------------------------------------------------
# Get-F5VirtualServers
# ---------------------------------------------------------------------------
function Get-F5VirtualServers {
    <#
    .SYNOPSIS
        Returns all LTM virtual servers from the BIG-IP.
    .DESCRIPTION
        Queries /mgmt/tm/ltm/virtual and returns key properties for each VS
        including destination address:port, status, pool, profiles, etc.
    .PARAMETER Partition
        Filter by partition name. Defaults to all partitions.
    .PARAMETER ExpandSubcollections
        Expand profiles and other sub-collections. Defaults to $true.
    .EXAMPLE
        Get-F5VirtualServers
        # Returns all virtual servers across all partitions.
    .EXAMPLE
        Get-F5VirtualServers -Partition "Common"
        # Returns only virtual servers in the Common partition.
    .EXAMPLE
        Get-F5VirtualServers | Select-Object Name, Address, Port, Pool, Enabled | Format-Table
        # Quick overview of all VS destinations and assigned pools.
    .EXAMPLE
        Get-F5VirtualServers -ExpandSubcollections $false
        # Skip profile expansion for faster results.
    #>

    [CmdletBinding()]
    param(
        [string]$Partition,
        [bool]$ExpandSubcollections = $true
    )

    $endpoint = "/mgmt/tm/ltm/virtual"
    if ($ExpandSubcollections) {
        $endpoint += '?expandSubcollections=true'
    }

    $response = Invoke-F5RestMethod -Endpoint $endpoint

    foreach ($vs in $response.items) {
        # Skip if partition filter doesn't match
        if ($Partition -and $vs.partition -ne $Partition) { continue }

        # Parse destination e.g. "/Common/10.0.0.1:443" or "/Common/10.0.0.1%1:443"
        $dest = $vs.destination -replace '^/[^/]+/', ''
        $destParts = $dest -split ':'
        $vsAddress = ($destParts[0] -replace '%\d+$', '')
        $vsPort = if ($destParts.Count -gt 1) { $destParts[1] } else { "any" }

        # Pool name (strip partition prefix)
        $poolName = if ($vs.pool) { ($vs.pool -replace '^/[^/]+/', '') } else { "None" }
        $poolPath = if ($vs.pool) { $vs.pool } else { $null }

        # Profiles - detailed with context (clientside / serverside / all)
        $profiles = @()
        $profilesDetailed = @()
        if ($vs.profilesReference -and $vs.profilesReference.items) {
            $profiles = $vs.profilesReference.items | ForEach-Object {
                [PSCustomObject]@{
                    Name    = $_.name
                    Context = $_.context
                    FullPath = $_.fullPath
                }
            }
            $profilesDetailed = $vs.profilesReference.items | ForEach-Object {
                "$($_.name)($($_.context))"
            }
        }
        $profileNames = ($profiles | ForEach-Object { $_.Name }) -join ", "
        $profilesDetailedStr = $profilesDetailed -join ", "

        # Persistence + fallback persistence
        $persistence = if ($vs.persist) {
            ($vs.persist | ForEach-Object { $_.name }) -join ", "
        } else { "None" }
        $fallbackPersistence = if ($vs.fallbackPersistence) {
            ($vs.fallbackPersistence -replace '^/[^/]+/', '')
        } else { "None" }

        # iRules (ordered)
        $irules = if ($vs.rules) {
            ($vs.rules | ForEach-Object { ($_ -replace '^/[^/]+/', '') }) -join ", "
        } else { "None" }

        # SNAT type + pool
        $snatType = if ($vs.sourceAddressTranslation) {
            $vs.sourceAddressTranslation.type
        } else { "None" }
        $snatPool = if ($vs.sourceAddressTranslation -and $vs.sourceAddressTranslation.pool) {
            ($vs.sourceAddressTranslation.pool -replace '^/[^/]+/', '')
        } else { "N/A" }

        # LTM Policies
        $policies = @()
        if ($vs.policiesReference -and $vs.policiesReference.items) {
            $policies = $vs.policiesReference.items | ForEach-Object { $_.name }
        }
        $policiesStr = if ($policies.Count -gt 0) { $policies -join ", " } else { "None" }

        # VLANs
        $vlansEnabled = if ($vs.vlansEnabled -eq $true) { $true } else { $false }
        $vlansDisabled = if ($vs.vlansDisabled -eq $true) { $true } else { $false }
        $vlans = if ($vs.vlans) {
            ($vs.vlans | ForEach-Object { ($_ -replace '^/[^/]+/', '') }) -join ", "
        } else { "None" }

        # Security / firewall
        $fwEnforcedPolicy = if ($vs.fwEnforcedPolicy) {
            ($vs.fwEnforcedPolicy -replace '^/[^/]+/', '')
        } else { "None" }
        $fwStagedPolicy = if ($vs.fwStagedPolicy) {
            ($vs.fwStagedPolicy -replace '^/[^/]+/', '')
        } else { "None" }
        $securityLogProfiles = if ($vs.securityLogProfiles) {
            ($vs.securityLogProfiles | ForEach-Object { ($_ -replace '^/[^/]+/', '') }) -join ", "
        } else { "None" }
        $ipIntelligencePolicy = if ($vs.ipIntelligencePolicy) {
            ($vs.ipIntelligencePolicy -replace '^/[^/]+/', '')
        } else { "None" }

        # Additional configuration flags
        $enabled = if ($vs.enabled -eq $true) { "Enabled" } else { "Disabled" }
        $autoLasthop = if ($vs.autoLasthop) { $vs.autoLasthop } else { "default" }
        $cmpEnabled = if ($vs.cmpEnabled) { $vs.cmpEnabled } else { "N/A" }
        $mirror = if ($vs.mirror) { $vs.mirror } else { "disabled" }
        $nat64 = if ($vs.nat64) { $vs.nat64 } else { "disabled" }
        $sourcePort = if ($vs.sourcePort) { $vs.sourcePort } else { "preserve" }
        $vsIndex = if ($vs.vsIndex) { $vs.vsIndex } else { 0 }
        $gtmScore = if ($vs.gtmScore) { $vs.gtmScore } else { 0 }
        $serviceDownImmediateAction = if ($vs.serviceDownImmediateAction) {
            $vs.serviceDownImmediateAction
        } else { "none" }
        $lastHopPool = if ($vs.lastHopPool) {
            ($vs.lastHopPool -replace '^/[^/]+/', '')
        } else { "None" }
        $clonePools = if ($vs.clonePools) {
            ($vs.clonePools | ForEach-Object { $_.name }) -join ", "
        } else { "None" }
        $addressStatus = if ($vs.addressStatus) { $vs.addressStatus } else { "N/A" }

        # Rate-limiting detail
        $rateLimitMode = if ($vs.rateLimitMode) { $vs.rateLimitMode } else { "object" }
        $rateLimitDstMask = if ($vs.rateLimitDstMask) { $vs.rateLimitDstMask } else { 0 }
        $rateLimitSrcMask = if ($vs.rateLimitSrcMask) { $vs.rateLimitSrcMask } else { 0 }

        # Metadata (key/value pairs attached to the VS)
        $metadata = if ($vs.metadata) {
            ($vs.metadata | ForEach-Object { "$($_.name)=$($_.value)" }) -join "; "
        } else { "" }

        # Eviction
        $flowEvictionPolicy = if ($vs.flowEvictionPolicy) {
            ($vs.flowEvictionPolicy -replace '^/[^/]+/', '')
        } else { "None" }
        $evictionProtected = if ($vs.evictionProtected) { $vs.evictionProtected } else { "disabled" }

        # Type / subtype flags
        $vsType = if ($vs.kind) { ($vs.kind -replace 'tm:ltm:virtual:', '' -replace 'state$', '') } else { "standard" }
        $ipForward = if ($vs.ipForward -eq $true) { "Yes" } else { "No" }
        $internal = if ($vs.internal -eq $true) { "Yes" } else { "No" }
        $reject = if ($vs.reject -eq $true) { "Yes" } else { "No" }
        $l2Forward = if ($vs.l2Forward -eq $true) { "Yes" } else { "No" }
        $stateless = if ($vs.stateless -eq $true) { "Yes" } else { "No" }

        # HA / traffic group
        $trafficGroup = if ($vs.trafficGroup) {
            ($vs.trafficGroup -replace '^/[^/]+/', '')
        } else { "N/A" }

        # iApp association
        $appService = if ($vs.appService) {
            ($vs.appService -replace '^/[^/]+/', '')
        } else { "None" }
        $subPath = if ($vs.subPath) { $vs.subPath } else { "N/A" }

        # Generation (config revision)
        $generation = if ($vs.generation) { $vs.generation } else { 0 }

        # Bandwidth controller policy
        $bwcPolicy = if ($vs.bwcPolicy) {
            ($vs.bwcPolicy -replace '^/[^/]+/', '')
        } else { "None" }

        # PVA hardware acceleration
        $pvaAcceleration = if ($vs.pvaAcceleration) { $vs.pvaAcceleration } else { "none" }

        # Security NAT policy
        $securityNatPolicy = if ($vs.securityNatPolicy) {
            ($vs.securityNatPolicy -replace '^/[^/]+/', '')
        } else { "None" }

        # Service policy
        $servicePolicy = if ($vs.servicePolicy) {
            ($vs.servicePolicy -replace '^/[^/]+/', '')
        } else { "None" }

        # Per-flow APM access policy
        $perFlowRequestAccessPolicy = if ($vs.perFlowRequestAccessPolicy) {
            ($vs.perFlowRequestAccessPolicy -replace '^/[^/]+/', '')
        } else { "None" }

        # HTTP MRF routing
        $httpMrfRoutingEnabled = if ($vs.httpMrfRoutingEnabled) { $vs.httpMrfRoutingEnabled } else { "disabled" }

        # Traffic matching criteria (BIG-IP 14.1+ alternative to destination)
        $trafficMatchingCriteria = if ($vs.trafficMatchingCriteria) {
            ($vs.trafficMatchingCriteria -replace '^/[^/]+/', '')
        } else { "N/A" }

        # Inline firewall rules
        $fwRules = if ($vs.fwRules) {
            ($vs.fwRules | ForEach-Object { if ($_.name) { $_.name } else { $_ } }) -join ", "
        } else { "None" }

        # Creation / modification timestamps
        $creationTime = if ($vs.creationTime) { $vs.creationTime } else { "N/A" }
        $lastModifiedTime = if ($vs.lastModifiedTime) { $vs.lastModifiedTime } else { "N/A" }

        [PSCustomObject]@{
            Name                       = $vs.name
            FullPath                   = $vs.fullPath
            Partition                  = $vs.partition
            Description                = if ($vs.description) { $vs.description } else { "" }
            Destination                = $dest
            Address                    = $vsAddress
            Port                       = $vsPort
            Protocol                   = if ($vs.ipProtocol) { $vs.ipProtocol.ToUpper() } else { "N/A" }
            Pool                       = $poolName
            PoolPath                   = $poolPath
            Enabled                    = $enabled
            Profiles                   = $profileNames
            ProfilesDetailed           = $profilesDetailedStr
            Persistence                = $persistence
            FallbackPersistence        = $fallbackPersistence
            iRules                     = $irules
            Policies                   = $policiesStr
            SNATType                   = $snatType
            SNATPool                   = $snatPool
            Source                     = if ($vs.source) { $vs.source } else { "0.0.0.0/0" }
            Mask                       = if ($vs.mask) { $vs.mask } else { "N/A" }
            ConnectionLimit            = if ($vs.connectionLimit) { $vs.connectionLimit } else { 0 }
            RateLimit                  = if ($vs.rateLimit) { $vs.rateLimit } else { "disabled" }
            RateLimitMode              = $rateLimitMode
            RateLimitDstMask           = $rateLimitDstMask
            RateLimitSrcMask           = $rateLimitSrcMask
            TranslateAddress           = if ($vs.translateAddress) { $vs.translateAddress } else { "N/A" }
            TranslatePort              = if ($vs.translatePort) { $vs.translatePort } else { "N/A" }
            AutoLasthop                = $autoLasthop
            CMPEnabled                 = $cmpEnabled
            Mirror                     = $mirror
            NAT64                      = $nat64
            SourcePort                 = $sourcePort
            AddressStatus              = $addressStatus
            VSIndex                    = $vsIndex
            GTMScore                   = $gtmScore
            ServiceDownAction          = $serviceDownImmediateAction
            LastHopPool                = $lastHopPool
            ClonePools                 = $clonePools
            VLANs                      = $vlans
            VLANsEnabled               = $vlansEnabled
            VLANsDisabled              = $vlansDisabled
            FWEnforcedPolicy           = $fwEnforcedPolicy
            FWStagedPolicy             = $fwStagedPolicy
            FWRules                    = $fwRules
            SecurityLogProfiles        = $securityLogProfiles
            IPIntelligencePolicy       = $ipIntelligencePolicy
            SecurityNatPolicy          = $securityNatPolicy
            FlowEvictionPolicy         = $flowEvictionPolicy
            EvictionProtected          = $evictionProtected
            Metadata                   = $metadata
            IPForward                  = $ipForward
            Internal                   = $internal
            Reject                     = $reject
            L2Forward                  = $l2Forward
            Stateless                  = $stateless
            VSType                     = $vsType
            TrafficGroup               = $trafficGroup
            AppService                 = $appService
            SubPath                    = $subPath
            Generation                 = $generation
            BwcPolicy                  = $bwcPolicy
            PvaAcceleration            = $pvaAcceleration
            ServicePolicy              = $servicePolicy
            PerFlowRequestAccessPolicy = $perFlowRequestAccessPolicy
            HttpMrfRoutingEnabled      = $httpMrfRoutingEnabled
            TrafficMatchingCriteria    = $trafficMatchingCriteria
            CreationTime               = $creationTime
            LastModifiedTime           = $lastModifiedTime
        }
    }
}

# ---------------------------------------------------------------------------
# Get-F5VirtualServerStats
# ---------------------------------------------------------------------------
function Get-F5VirtualServerStats {
    <#
    .SYNOPSIS
        Returns real-time statistics for all virtual servers.
    .DESCRIPTION
        Queries /mgmt/tm/ltm/virtual/stats to retrieve connection counts,
        bytes in/out, availability state, etc.
    .EXAMPLE
        Get-F5VirtualServerStats
        # Returns stats for all virtual servers.
    .EXAMPLE
        Get-F5VirtualServerStats | Where-Object { $_.ClientsideCurConns -gt 0 } | Select-Object Name, ClientsideCurConns, AvailabilityState
        # Show only VS with active connections.
    .EXAMPLE
        Get-F5VirtualServerStats | Sort-Object ClientsideTotConns -Descending | Select-Object -First 10 Name, ClientsideTotConns
        # Top 10 virtual servers by total connection count.
    #>

    [CmdletBinding()]
    param()

    $response = Invoke-F5RestMethod -Endpoint "/mgmt/tm/ltm/virtual/stats"
    $results = @()

    foreach ($prop in $response.entries.PSObject.Properties) {
        $stats = $prop.Value.nestedStats.entries

        $vsName = $stats.'tmName'.description -replace '^/[^/]+/', ''

        # Helper to safely read a stat value
        $readLong = { param($key) if ($stats.$key) { [long]$stats.$key.value } else { 0 } }
        $readInt  = { param($key) if ($stats.$key) { [int]$stats.$key.value } else { 0 } }
        $readDesc = { param($key) if ($stats.$key) { $stats.$key.description } else { "N/A" } }

        $results += [PSCustomObject]@{
            Name                       = $vsName
            Destination                = & $readDesc 'destination'
            # --- Status ---
            AvailabilityState          = $stats.'status.availabilityState'.description
            EnabledState               = $stats.'status.enabledState'.description
            StatusReason               = $stats.'status.statusReason'.description
            # --- Client-side traffic ---
            ClientsideBitsIn           = [long]$stats.'clientside.bitsIn'.value
            ClientsideBitsOut          = [long]$stats.'clientside.bitsOut'.value
            ClientsideCurConns         = [int]$stats.'clientside.curConns'.value
            ClientsideMaxConns         = [int]$stats.'clientside.maxConns'.value
            ClientsideTotConns         = [long]$stats.'clientside.totConns'.value
            ClientsidePktsIn           = [long]$stats.'clientside.pktsIn'.value
            ClientsidePktsOut          = [long]$stats.'clientside.pktsOut'.value
            ClientsideEvictedConns     = & $readLong 'clientside.evictedConns'
            ClientsideSlowKilled       = & $readLong 'clientside.slowKilled'
            # --- Ephemeral traffic ---
            EphemeralBitsIn            = & $readLong 'ephemeral.bitsIn'
            EphemeralBitsOut           = & $readLong 'ephemeral.bitsOut'
            EphemeralCurConns          = & $readInt  'ephemeral.curConns'
            EphemeralMaxConns          = & $readInt  'ephemeral.maxConns'
            EphemeralTotConns          = & $readLong 'ephemeral.totConns'
            EphemeralPktsIn            = & $readLong 'ephemeral.pktsIn'
            EphemeralPktsOut           = & $readLong 'ephemeral.pktsOut'
            EphemeralEvictedConns      = & $readLong 'ephemeral.evictedConns'
            EphemeralSlowKilled        = & $readLong 'ephemeral.slowKilled'
            # --- Request / response ---
            TotalRequests              = & $readLong 'totRequests'
            # --- Connection duration ---
            CsMeanConnDuration         = & $readLong 'csMeanConnDur'
            CsMaxConnDuration          = & $readLong 'csMaxConnDur'
            CsMinConnDuration          = & $readLong 'csMinConnDur'
            # --- Usage ratios ---
            FiveSecAvgUsageRatio       = & $readInt 'fiveSecAvgUsageRatio'
            OneMinAvgUsageRatio        = & $readInt 'oneMinAvgUsageRatio'
            FiveMinAvgUsageRatio       = & $readInt 'fiveMinAvgUsageRatio'
            # --- SYN cookies ---
            SyncookieStatus            = & $readDesc 'syncookieStatus'
            SyncookieAccepts           = & $readLong 'syncookie.accepts'
            SyncookieRejects           = & $readLong 'syncookie.rejects'
            SyncookieSyncacheCurr      = & $readInt  'syncookie.syncacheCurr'
            SyncookieSyncacheOver      = & $readLong 'syncookie.syncacheOver'
            SyncookieSwTotal           = & $readLong 'syncookie.swsyncookieInstance'
            # --- Hardware SYN cookies ---
            SyncookieHwAccepts         = & $readLong 'syncookieHw.accepts'
            SyncookieHwRejects         = & $readLong 'syncookieHw.rejects'
            SyncookieHwSyncookies      = & $readLong 'syncookieHw.syncookies'
            # --- Misc counters ---
            TotPvaAssistConn           = & $readLong 'totPvaAssistConn'
            CmpEnableMode              = & $readDesc 'cmpEnableMode'
            CmpEnabled                 = & $readDesc 'cmpEnabled'
            VSType                     = & $readDesc 'vsType'
            # --- Additional counters ---
            ClientsideTotRequests      = & $readLong 'clientside.totRequests'
            EphemeralTotRequests       = & $readLong 'ephemeral.totRequests'
            StatusCount                = & $readLong 'status.statusCount'
        }
    }

    return $results
}

# ---------------------------------------------------------------------------
# Get-F5Pools
# ---------------------------------------------------------------------------
function Get-F5Pools {
    <#
    .SYNOPSIS
        Returns all LTM pools from the BIG-IP.
    .DESCRIPTION
        Queries /mgmt/tm/ltm/pool and returns key properties including
        load balancing mode, monitor, active/total member counts.
    .PARAMETER Partition
        Filter by partition name. Defaults to all partitions.
    .EXAMPLE
        Get-F5Pools
        # Returns all pools across all partitions.
    .EXAMPLE
        Get-F5Pools -Partition "Common" | Select-Object Name, LoadBalancingMode, Monitor, ActiveMemberCount
        # List pools in the Common partition with their LB mode and health monitors.
    .EXAMPLE
        Get-F5Pools | Where-Object { $_.ActiveMemberCount -eq 0 }
        # Find pools with no active members (potential outage).
    #>

    [CmdletBinding()]
    param(
        [string]$Partition
    )

    $response = Invoke-F5RestMethod -Endpoint "/mgmt/tm/ltm/pool"

    foreach ($pool in $response.items) {
        if ($Partition -and $pool.partition -ne $Partition) { continue }

        $monitor = if ($pool.monitor) {
            ($pool.monitor -replace ' and ', ', ') -replace '/Common/', ''
        } else { "None" }

        [PSCustomObject]@{
            Name              = $pool.name
            FullPath          = $pool.fullPath
            Partition         = $pool.partition
            Description       = if ($pool.description) { $pool.description } else { "" }
            LoadBalancingMode = if ($pool.loadBalancingMode) { $pool.loadBalancingMode } else { "round-robin" }
            Monitor           = $monitor
            ActiveMemberCount = if ($null -ne $pool.activeMemberCnt) { $pool.activeMemberCnt } else { 0 }
            MembersTotal      = if ($pool.membersReference -and $pool.membersReference.items) { $pool.membersReference.items.Count } else { 0 }
            MinActiveMembers  = if ($pool.minActiveMembers) { $pool.minActiveMembers } else { 0 }
            SlowRampTime      = if ($pool.slowRampTime) { $pool.slowRampTime } else { 10 }
        }
    }
}

# ---------------------------------------------------------------------------
# Get-F5PoolMembers
# ---------------------------------------------------------------------------
function Get-F5PoolMembers {
    <#
    .SYNOPSIS
        Returns the members (real servers) of one or all pools.
    .DESCRIPTION
        Queries /mgmt/tm/ltm/pool/~{partition}~{poolName}/members for
        each pool. Returns address, port, state, session, ratio, etc.
    .PARAMETER PoolName
        Name of a specific pool to query. If omitted, queries all pools.
    .PARAMETER Partition
        Partition name. Defaults to Common.
    .EXAMPLE
        Get-F5PoolMembers
        # Returns all members across all pools in the Common partition.
    .EXAMPLE
        Get-F5PoolMembers -PoolName "web_pool" | Select-Object MemberName, Address, Port, State, Session
        # List members of a specific pool with their health state.
    .EXAMPLE
        Get-F5PoolMembers -Partition "Production" | Where-Object { $_.State -ne 'up' }
        # Find unhealthy pool members in the Production partition.
    #>

    [CmdletBinding()]
    param(
        [string]$PoolName,
        [string]$Partition = "Common"
    )

    if ($PoolName) {
        $pools = @([PSCustomObject]@{ Name = $PoolName; Partition = $Partition })
    }
    else {
        $pools = Get-F5Pools -Partition $Partition
    }

    foreach ($pool in $pools) {
        $endpoint = "/mgmt/tm/ltm/pool/~$($pool.Partition)~$($pool.Name)/members"
        try {
            $response = Invoke-F5RestMethod -Endpoint $endpoint
        }
        catch {
            Write-Verbose "Could not retrieve members for pool $($pool.Name): $($_.Exception.Message)"
            continue
        }

        foreach ($member in $response.items) {
            # Parse name "server1:80"
            $nameParts = $member.name -split ':'
            $nodeName = $nameParts[0]
            $nodePort = if ($nameParts.Count -gt 1) { $nameParts[1] } else { "any" }

            [PSCustomObject]@{
                PoolName      = $pool.Name
                MemberName    = $member.name
                NodeName      = $nodeName
                Address       = if ($member.address) { ($member.address -replace '%\d+$', '') } else { "N/A" }
                Port          = $nodePort
                State         = if ($member.state) { $member.state } else { "N/A" }
                Session       = if ($member.session) { $member.session } else { "N/A" }
                MonitorStatus = if ($member.monitor) { $member.monitor } else { "default" }
                Ratio         = if ($member.ratio) { $member.ratio } else { 1 }
                Priority      = if ($member.priorityGroup) { $member.priorityGroup } else { 0 }
                ConnectionLimit = if ($member.connectionLimit) { $member.connectionLimit } else { 0 }
                Description   = if ($member.description) { $member.description } else { "" }
            }
        }
    }
}

# ---------------------------------------------------------------------------
# Get-F5PoolMemberStats
# ---------------------------------------------------------------------------
function Get-F5PoolMemberStats {
    <#
    .SYNOPSIS
        Returns live statistics for members of one or all pools.
    .DESCRIPTION
        Queries the stats sub-collection for pool members to get current
        connections, availability, bytes in/out, etc.
    .PARAMETER PoolName
        Name of a specific pool. If omitted, queries all pools.
    .PARAMETER Partition
        Partition name. Defaults to Common.
    .EXAMPLE
        Get-F5PoolMemberStats
        # Returns stats for all pool members in the Common partition.
    .EXAMPLE
        Get-F5PoolMemberStats -PoolName "web_pool" | Select-Object MemberName, CurConns, AvailabilityState
        # Check active connections and health for a specific pool's members.
    .EXAMPLE
        Get-F5PoolMemberStats | Sort-Object CurConns -Descending | Select-Object -First 5 PoolName, MemberName, CurConns
        # Top 5 busiest pool members.
    #>

    [CmdletBinding()]
    param(
        [string]$PoolName,
        [string]$Partition = "Common"
    )

    if ($PoolName) {
        $pools = @([PSCustomObject]@{ Name = $PoolName; Partition = $Partition })
    }
    else {
        $pools = Get-F5Pools -Partition $Partition
    }

    $results = @()
    foreach ($pool in $pools) {
        $endpoint = "/mgmt/tm/ltm/pool/~$($pool.Partition)~$($pool.Name)/members/stats"
        try {
            $response = Invoke-F5RestMethod -Endpoint $endpoint
        }
        catch {
            Write-Verbose "Could not retrieve member stats for pool $($pool.Name): $($_.Exception.Message)"
            continue
        }

        foreach ($prop in $response.entries.PSObject.Properties) {
            $stats = $prop.Value.nestedStats.entries
            $memberName = $stats.'tmName'.description -replace '^/[^/]+/', ''
            $nodeName = $stats.'nodeName'.description -replace '^/[^/]+/', ''

            $results += [PSCustomObject]@{
                PoolName              = $pool.Name
                MemberName            = $memberName
                NodeName              = $nodeName
                Address               = $stats.'addr'.description
                Port                  = [int]$stats.'port'.value
                AvailabilityState     = $stats.'status.availabilityState'.description
                EnabledState          = $stats.'status.enabledState'.description
                StatusReason          = $stats.'status.statusReason'.description
                CurrentSessions       = [int]$stats.'curSessions'.value
                ServersideBitsIn      = [long]$stats.'serverside.bitsIn'.value
                ServersideBitsOut     = [long]$stats.'serverside.bitsOut'.value
                ServersideCurConns    = [int]$stats.'serverside.curConns'.value
                ServersideMaxConns    = [int]$stats.'serverside.maxConns'.value
                ServersideTotConns    = [long]$stats.'serverside.totConns'.value
                TotalRequests         = if ($stats.'totRequests') { [long]$stats.'totRequests'.value } else { 0 }
            }
        }
    }

    return $results
}

# ---------------------------------------------------------------------------
# Get-F5Nodes
# ---------------------------------------------------------------------------
function Get-F5Nodes {
    <#
    .SYNOPSIS
        Returns all LTM nodes from the BIG-IP.
    .DESCRIPTION
        Queries /mgmt/tm/ltm/node for every node and returns address,
        FQDN, state, monitor, etc.
    .EXAMPLE
        Get-F5Nodes
        # Returns all nodes.
    .EXAMPLE
        Get-F5Nodes | Select-Object Name, Address, FQDN, State | Format-Table
        # Quick overview of all node addresses and health.
    .EXAMPLE
        Get-F5Nodes | Where-Object { $_.State -ne 'up' }
        # Find nodes that are not in an 'up' state.
    #>

    [CmdletBinding()]
    param()

    $response = Invoke-F5RestMethod -Endpoint "/mgmt/tm/ltm/node"

    foreach ($node in $response.items) {
        $fqdn = if ($node.fqdn -and $node.fqdn.tmName) { $node.fqdn.tmName } else { "N/A" }

        [PSCustomObject]@{
            Name      = $node.name
            FullPath  = $node.fullPath
            Partition = $node.partition
            Address   = if ($node.address) { ($node.address -replace '%\d+$', '') } else { "N/A" }
            FQDN      = $fqdn
            State     = if ($node.state) { $node.state } else { "N/A" }
            Session   = if ($node.session) { $node.session } else { "N/A" }
            Monitor   = if ($node.monitor) { ($node.monitor -replace '/Common/', '') } else { "default" }
            Ratio     = if ($node.ratio) { $node.ratio } else { 1 }
        }
    }
}

# ---------------------------------------------------------------------------
# Get-F5Dashboard
# ---------------------------------------------------------------------------
function Get-F5Dashboard {
    <#
    .SYNOPSIS
        Builds a comprehensive dashboard combining VS, pool, and member data.
    .DESCRIPTION
        Correlates virtual servers with their pools and pool members to
        produce a flat collection of objects suitable for HTML table rendering.
        Each row represents one pool member within the context of its VS.
        Virtual servers with no pool still appear as a single row.
    .PARAMETER IncludeStats
        Whether to include live statistics (connections, bytes, etc.).
        Defaults to $true.
    .PARAMETER Partition
        Filter by partition. Defaults to all partitions.
    .EXAMPLE
        $data = Get-F5Dashboard
        $data | Select-Object VSName, VSStatus, PoolName, MemberName, MemberState | Format-Table
        # Full dashboard with stats - VS, pool, and member health at a glance.
    .EXAMPLE
        Get-F5Dashboard -IncludeStats $false
        # Skip live stats for faster results (config data only).
    .EXAMPLE
        Get-F5Dashboard -Partition "Production" | Where-Object { $_.VSStatus -match 'Offline' }
        # Find offline virtual servers in the Production partition.
    .EXAMPLE
        $data = Get-F5Dashboard
        $data | Group-Object VSStatus | Select-Object Name, Count
        # Summary of VS health statuses.
    #>

    [CmdletBinding()]
    param(
        [bool]$IncludeStats = $true,
        [string]$Partition
    )

    # Gather all virtual servers
    $virtualServers = @(Get-F5VirtualServers -Partition $Partition)

    # Discover every partition that has a pool referenced by a VS
    $partitions = @($virtualServers | ForEach-Object { $_.Partition } | Sort-Object -Unique)
    if ($partitions.Count -eq 0) { $partitions = @("Common") }

    # Gather all pools and members across all relevant partitions
    $allPools   = @()
    $allMembers = @()
    foreach ($part in $partitions) {
        $allPools   += @(Get-F5Pools -Partition $part)
        $allMembers += @(Get-F5PoolMembers -Partition $part)
    }

    # Index pools by name for fast lookup (key = "partition/poolName")
    $poolIndex = @{}
    foreach ($p in $allPools) {
        $poolIndex["$($p.Partition)/$($p.Name)"] = $p
        $poolIndex[$p.Name] = $p           # also index by bare name for convenience
    }

    $vsStats = @{}
    $memberStats = @{}

    if ($IncludeStats) {
        $vsStatsRaw = @(Get-F5VirtualServerStats)
        foreach ($s in $vsStatsRaw) { $vsStats[$s.Name] = $s }

        foreach ($part in $partitions) {
            $memberStatsRaw = @(Get-F5PoolMemberStats -Partition $part)
            foreach ($s in $memberStatsRaw) {
                $key = "$($s.PoolName)::$($s.MemberName)"
                $memberStats[$key] = $s
            }
        }
    }

    # Resolve node hostnames
    $nodes = @{}
    try {
        $nodeList = @(Get-F5Nodes)
        foreach ($n in $nodeList) {
            $nodes[$n.Name] = $n
        }
    }
    catch {
        Write-Verbose "Could not retrieve nodes: $($_.Exception.Message)"
    }

    $dashboard = @()

    foreach ($vs in $virtualServers) {
        # VS-level stats
        $vsStat = if ($vsStats.ContainsKey($vs.Name)) { $vsStats[$vs.Name] } else { $null }
        $vsAvail = if ($vsStat) { $vsStat.AvailabilityState } else { "unknown" }
        $vsEnabledState = if ($vsStat) { $vsStat.EnabledState } else { $vs.Enabled }
        $vsStatusReason = if ($vsStat) { $vsStat.StatusReason } else { "" }
        $vsCurrentConns = if ($vsStat) { $vsStat.ClientsideCurConns } else { 0 }
        $vsTotalConns = if ($vsStat) { $vsStat.ClientsideTotConns } else { 0 }
        $vsBitsIn = if ($vsStat) { $vsStat.ClientsideBitsIn } else { 0 }
        $vsBitsOut = if ($vsStat) { $vsStat.ClientsideBitsOut } else { 0 }
        $vsMaxConns = if ($vsStat) { $vsStat.ClientsideMaxConns } else { 0 }
        $vsPktsIn = if ($vsStat) { $vsStat.ClientsidePktsIn } else { 0 }
        $vsPktsOut = if ($vsStat) { $vsStat.ClientsidePktsOut } else { 0 }
        $vsEvictedConns = if ($vsStat) { $vsStat.ClientsideEvictedConns } else { 0 }
        $vsSlowKilled = if ($vsStat) { $vsStat.ClientsideSlowKilled } else { 0 }
        $vsTotalRequests = if ($vsStat) { $vsStat.TotalRequests } else { 0 }
        $vsMeanConnDur = if ($vsStat) { $vsStat.CsMeanConnDuration } else { 0 }
        $vsMaxConnDur = if ($vsStat) { $vsStat.CsMaxConnDuration } else { 0 }
        $vsMinConnDur = if ($vsStat) { $vsStat.CsMinConnDuration } else { 0 }
        $vs5secAvg = if ($vsStat) { $vsStat.FiveSecAvgUsageRatio } else { 0 }
        $vs1minAvg = if ($vsStat) { $vsStat.OneMinAvgUsageRatio } else { 0 }
        $vs5minAvg = if ($vsStat) { $vsStat.FiveMinAvgUsageRatio } else { 0 }
        $vsSyncookieStatus = if ($vsStat) { $vsStat.SyncookieStatus } else { "N/A" }
        # Ephemeral stats
        $vsEphBitsIn = if ($vsStat) { $vsStat.EphemeralBitsIn } else { 0 }
        $vsEphBitsOut = if ($vsStat) { $vsStat.EphemeralBitsOut } else { 0 }
        $vsEphCurConns = if ($vsStat) { $vsStat.EphemeralCurConns } else { 0 }
        $vsEphTotConns = if ($vsStat) { $vsStat.EphemeralTotConns } else { 0 }

        # Determine a combined VS status indicator
        $vsStatus = Get-F5StatusIndicator -AvailabilityState $vsAvail -EnabledState $vsEnabledState

        # Build a reusable hashtable of all VS-level columns
        $vsColumns = [ordered]@{
            # --- Identity ---
            VSName                     = $vs.Name
            VSFullPath                 = $vs.FullPath
            VSPartition                = $vs.Partition
            VSDescription              = $vs.Description
            VSType                     = $vs.VSType
            VSIndex                    = $vs.VSIndex
            VSCreationTime             = $vs.CreationTime
            VSLastModifiedTime         = $vs.LastModifiedTime
            # --- Destination ---
            VSAddress                  = $vs.Address
            VSPort                     = $vs.Port
            VSProtocol                 = $vs.Protocol
            VSDestination              = $vs.Destination
            VSSource                   = $vs.Source
            VSMask                     = $vs.Mask
            # --- Status ---
            VSStatus                   = $vsStatus
            VSAvailability             = $vsAvail
            VSEnabled                  = $vsEnabledState
            VSStatusReason             = $vsStatusReason
            # --- Traffic stats: client-side ---
            VSCurrentConns             = $vsCurrentConns
            VSMaxConns                 = $vsMaxConns
            VSTotalConns               = $vsTotalConns
            VSTotalRequests            = $vsTotalRequests
            VSBitsIn                   = Format-F5Bytes -Bits $vsBitsIn
            VSBitsOut                  = Format-F5Bytes -Bits $vsBitsOut
            VSPktsIn                   = $vsPktsIn
            VSPktsOut                  = $vsPktsOut
            VSEvictedConns             = $vsEvictedConns
            VSSlowKilled               = $vsSlowKilled
            # --- Connection duration ---
            VSMeanConnDuration         = $vsMeanConnDur
            VSMaxConnDuration          = $vsMaxConnDur
            VSMinConnDuration          = $vsMinConnDur
            # --- Usage ratios ---
            VS5SecAvgUsage             = $vs5secAvg
            VS1MinAvgUsage             = $vs1minAvg
            VS5MinAvgUsage             = $vs5minAvg
            # --- Ephemeral traffic ---
            VSEphBitsIn                = Format-F5Bytes -Bits $vsEphBitsIn
            VSEphBitsOut               = Format-F5Bytes -Bits $vsEphBitsOut
            VSEphCurrentConns          = $vsEphCurConns
            VSEphTotalConns            = $vsEphTotConns
            # --- SYN cookie ---
            VSSyncookieStatus          = $vsSyncookieStatus
            # --- Configuration ---
            VSProfiles                 = $vs.Profiles
            VSProfilesDetailed         = $vs.ProfilesDetailed
            VSPersistence              = $vs.Persistence
            VSFallbackPersistence      = $vs.FallbackPersistence
            VSiRules                   = $vs.iRules
            VSPolicies                 = $vs.Policies
            VSSNATType                 = $vs.SNATType
            VSSNATPool                 = $vs.SNATPool
            VSTranslateAddress         = $vs.TranslateAddress
            VSTranslatePort            = $vs.TranslatePort
            VSSourcePort               = $vs.SourcePort
            VSConnectionLimit          = $vs.ConnectionLimit
            VSRateLimit                = $vs.RateLimit
            VSRateLimitMode            = $vs.RateLimitMode
            VSRateLimitDstMask         = $vs.RateLimitDstMask
            VSRateLimitSrcMask         = $vs.RateLimitSrcMask
            # --- Networking ---
            VSAutoLasthop              = $vs.AutoLasthop
            VSLastHopPool              = $vs.LastHopPool
            VSCMPEnabled               = $vs.CMPEnabled
            VSMirror                   = $vs.Mirror
            VSNAT64                    = $vs.NAT64
            VSVLANs                    = $vs.VLANs
            VSVLANsEnabled             = $vs.VLANsEnabled
            VSVLANsDisabled            = $vs.VLANsDisabled
            VSAddressStatus            = $vs.AddressStatus
            # --- Security ---
            VSFWEnforcedPolicy         = $vs.FWEnforcedPolicy
            VSFWStagedPolicy           = $vs.FWStagedPolicy
            VSSecurityLogProfiles      = $vs.SecurityLogProfiles
            VSIPIntelligencePolicy     = $vs.IPIntelligencePolicy
            # --- Advanced ---
            VSGTMScore                 = $vs.GTMScore
            VSServiceDownAction        = $vs.ServiceDownAction
            VSClonePools               = $vs.ClonePools
            VSFlowEvictionPolicy       = $vs.FlowEvictionPolicy
            VSEvictionProtected        = $vs.EvictionProtected
            VSIPForward                = $vs.IPForward
            VSInternal                 = $vs.Internal
            VSReject                   = $vs.Reject
            VSL2Forward                = $vs.L2Forward
            VSStateless                = $vs.Stateless
            VSMetadata                 = $vs.Metadata
            # --- HA / iApp ---
            VSTrafficGroup             = $vs.TrafficGroup
            VSAppService               = $vs.AppService
            VSSubPath                  = $vs.SubPath
            VSGeneration               = $vs.Generation
            # --- Bandwidth / accel ---
            VSBwcPolicy                = $vs.BwcPolicy
            VSPvaAcceleration          = $vs.PvaAcceleration
            # --- Additional security ---
            VSSecurityNatPolicy        = $vs.SecurityNatPolicy
            VSFWRules                  = $vs.FWRules
            # --- Policies / routing ---
            VSServicePolicy            = $vs.ServicePolicy
            VSPerFlowAccessPolicy      = $vs.PerFlowRequestAccessPolicy
            VSHttpMrfRoutingEnabled    = $vs.HttpMrfRoutingEnabled
            VSTrafficMatchingCriteria  = $vs.TrafficMatchingCriteria
        }

        # Members for this VS's pool
        $poolMembers = @($allMembers | Where-Object { $_.PoolName -eq $vs.Pool })

        # Look up pool-level info from the pre-cached index
        $poolInfo = if ($poolIndex.ContainsKey("$($vs.Partition)/$($vs.Pool)")) {
            $poolIndex["$($vs.Partition)/$($vs.Pool)"]
        } elseif ($poolIndex.ContainsKey($vs.Pool)) {
            $poolIndex[$vs.Pool]
        } else { $null }

        if ($poolMembers.Count -eq 0) {
            # VS with no pool or no members - emit a single row with all VS columns
            $row = [ordered]@{}
            $row["TrafficChain"]           = "$($vs.Name) -> $($vs.Pool) -> (no members)"
            foreach ($k in $vsColumns.Keys) { $row[$k] = $vsColumns[$k] }
            $row["PoolName"]              = $vs.Pool
            $row["PoolFullPath"]          = if ($vs.PoolPath) { $vs.PoolPath } else { "" }
            $row["PoolLBMode"]            = if ($poolInfo) { $poolInfo.LoadBalancingMode } else { "" }
            $row["PoolMonitor"]           = if ($poolInfo) { $poolInfo.Monitor } else { "" }
            $row["PoolActiveMembers"]     = if ($poolInfo) { $poolInfo.ActiveMemberCount } else { 0 }
            $row["PoolTotalMembers"]      = if ($poolInfo) { $poolInfo.MembersTotal } else { 0 }
            $row["PoolMinActiveMembers"]  = if ($poolInfo) { $poolInfo.MinActiveMembers } else { 0 }
            $row["PoolSlowRampTime"]      = if ($poolInfo) { $poolInfo.SlowRampTime } else { 0 }
            $row["MemberName"]            = "N/A"
            $row["MemberAddress"]         = "N/A"
            $row["MemberPort"]            = "N/A"
            $row["MemberHostname"]        = "N/A"
            $row["MemberState"]           = "N/A"
            $row["MemberSession"]         = "N/A"
            $row["MemberMonitor"]         = "N/A"
            $row["MemberRatio"]           = 0
            $row["MemberPriority"]        = 0
            $row["MemberConnLimit"]       = 0
            $row["MemberDescription"]     = ""
            $row["MemberStatus"]          = "N/A"
            $row["MemberAvailability"]    = "N/A"
            $row["MemberCurrentConns"]    = 0
            $row["MemberMaxConns"]        = 0
            $row["MemberTotalConns"]      = 0
            $row["MemberTotalRequests"]   = 0
            $row["MemberBitsIn"]          = "0 B"
            $row["MemberBitsOut"]         = "0 B"
            $row["MemberCurrentSessions"] = 0
            $row["MemberStatusReason"]    = ""
            $dashboard += [PSCustomObject]$row
        }
        else {
            foreach ($member in $poolMembers) {
                $mKey = "$($member.PoolName)::$($member.MemberName)"
                $mStat = if ($memberStats.ContainsKey($mKey)) { $memberStats[$mKey] } else { $null }
                $mAvail = if ($mStat) { $mStat.AvailabilityState } else { "unknown" }
                $mEnabled = if ($mStat) { $mStat.EnabledState } else { $member.Session }
                $mCurConns = if ($mStat) { $mStat.ServersideCurConns } else { 0 }
                $mMaxConns = if ($mStat) { $mStat.ServersideMaxConns } else { 0 }
                $mTotConns = if ($mStat) { $mStat.ServersideTotConns } else { 0 }
                $mTotReqs = if ($mStat) { $mStat.TotalRequests } else { 0 }
                $mBitsIn = if ($mStat) { $mStat.ServersideBitsIn } else { 0 }
                $mBitsOut = if ($mStat) { $mStat.ServersideBitsOut } else { 0 }
                $mCurSessions = if ($mStat) { $mStat.CurrentSessions } else { 0 }
                $mStatusReason = if ($mStat) { $mStat.StatusReason } else { "" }
                $mStatus = Get-F5StatusIndicator -AvailabilityState $mAvail -EnabledState $mEnabled

                # Resolve hostname from node data
                $hostname = "N/A"
                if ($nodes.ContainsKey($member.NodeName)) {
                    $nodeObj = $nodes[$member.NodeName]
                    if ($nodeObj.FQDN -and $nodeObj.FQDN -ne "N/A") {
                        $hostname = $nodeObj.FQDN
                    }
                    else {
                        $hostname = $nodeObj.Name
                    }
                }

                $row = [ordered]@{}
                $row["TrafficChain"]           = "$($vs.Name) -> $($vs.Pool) -> $($member.NodeName):$($member.Port)"
                foreach ($k in $vsColumns.Keys) { $row[$k] = $vsColumns[$k] }
                $row["PoolName"]              = $vs.Pool
                $row["PoolFullPath"]          = if ($vs.PoolPath) { $vs.PoolPath } else { "" }
                $row["PoolLBMode"]            = if ($poolInfo) { $poolInfo.LoadBalancingMode } else { "" }
                $row["PoolMonitor"]           = if ($poolInfo) { $poolInfo.Monitor } else { "" }
                $row["PoolActiveMembers"]     = if ($poolInfo) { $poolInfo.ActiveMemberCount } else { 0 }
                $row["PoolTotalMembers"]      = if ($poolInfo) { $poolInfo.MembersTotal } else { 0 }
                $row["PoolMinActiveMembers"]  = if ($poolInfo) { $poolInfo.MinActiveMembers } else { 0 }
                $row["PoolSlowRampTime"]      = if ($poolInfo) { $poolInfo.SlowRampTime } else { 0 }
                $row["MemberName"]            = $member.NodeName
                $row["MemberAddress"]         = $member.Address
                $row["MemberPort"]            = $member.Port
                $row["MemberHostname"]        = $hostname
                $row["MemberState"]           = $member.State
                $row["MemberSession"]         = $member.Session
                $row["MemberMonitor"]         = $member.MonitorStatus
                $row["MemberRatio"]           = $member.Ratio
                $row["MemberPriority"]        = $member.Priority
                $row["MemberConnLimit"]       = $member.ConnectionLimit
                $row["MemberDescription"]     = $member.Description
                $row["MemberStatus"]          = $mStatus
                $row["MemberAvailability"]    = $mAvail
                $row["MemberCurrentConns"]    = $mCurConns
                $row["MemberMaxConns"]        = $mMaxConns
                $row["MemberTotalConns"]      = $mTotConns
                $row["MemberTotalRequests"]   = $mTotReqs
                $row["MemberBitsIn"]          = Format-F5Bytes -Bits $mBitsIn
                $row["MemberBitsOut"]         = Format-F5Bytes -Bits $mBitsOut
                $row["MemberCurrentSessions"] = $mCurSessions
                $row["MemberStatusReason"]    = $mStatusReason
                $dashboard += [PSCustomObject]$row
            }
        }
    }

    return $dashboard
}

# ---------------------------------------------------------------------------
# Get-F5StatusIndicator
# ---------------------------------------------------------------------------
function Get-F5StatusIndicator {
    <#
    .SYNOPSIS
        Converts F5 availability and enabled states into a human-readable indicator.
    .EXAMPLE
        Get-F5StatusIndicator -AvailabilityState "available" -EnabledState "enabled"
        # Returns: "Available (Green)"
    .EXAMPLE
        Get-F5StatusIndicator -AvailabilityState "offline" -EnabledState "enabled"
        # Returns: "Offline (Red)"
    .EXAMPLE
        Get-F5StatusIndicator -AvailabilityState "available" -EnabledState "disabled"
        # Returns: "Disabled"
    #>

    [CmdletBinding()]
    param(
        [string]$AvailabilityState,
        [string]$EnabledState
    )

    if ($EnabledState -match 'disabled') {
        return "Disabled"
    }

    switch ($AvailabilityState) {
        "available" { return "Available (Green)" }
        "offline"   { return "Offline (Red)" }
        "unknown"   { return "Unknown (Blue)" }
        default     { return "$AvailabilityState" }
    }
}

# ---------------------------------------------------------------------------
# Format-F5Bytes
# ---------------------------------------------------------------------------
function Format-F5Bytes {
    <#
    .SYNOPSIS
        Formats a bit count into a human-readable byte string (KB/MB/GB/TB).
    .EXAMPLE
        Format-F5Bytes -Bits 8388608
        # Returns: "1.00 MB" (8388608 bits = 1 MB)
    .EXAMPLE
        Format-F5Bytes -Bits 0
        # Returns: "0 B"
    #>

    [CmdletBinding()]
    param(
        [long]$Bits
    )

    $bytes = $Bits / 8
    if ($bytes -ge 1TB) { return "{0:N2} TB" -f ($bytes / 1TB) }
    if ($bytes -ge 1GB) { return "{0:N2} GB" -f ($bytes / 1GB) }
    if ($bytes -ge 1MB) { return "{0:N2} MB" -f ($bytes / 1MB) }
    if ($bytes -ge 1KB) { return "{0:N2} KB" -f ($bytes / 1KB) }
    return "$bytes B"
}

# ---------------------------------------------------------------------------
# Export-F5DashboardHtml
# ---------------------------------------------------------------------------
function Export-F5DashboardHtml {
    <#
    .SYNOPSIS
        Renders the F5 dashboard data into a self-contained HTML file.
    .DESCRIPTION
        Takes the output of Get-F5Dashboard and generates a Bootstrap-based
        HTML report with sortable, searchable, and exportable tables. Uses
        colour-coded status indicators matching the F5 UI conventions.
    .PARAMETER DashboardData
        Array of objects from Get-F5Dashboard.
    .PARAMETER OutputPath
        File path for the output HTML file.
    .PARAMETER ReportTitle
        Title shown in the report header. Defaults to "F5 BIG-IP Dashboard".
    .PARAMETER TemplatePath
        Optional path to a custom HTML template. If omitted, uses the
        built-in template at helpers/f5/F5-Dashboard-Template.html.
    .EXAMPLE
        $data = Get-F5Dashboard
        Export-F5DashboardHtml -DashboardData $data -OutputPath "C:\Reports\f5.html"
        # Generates an HTML dashboard at the specified path.
    .EXAMPLE
        $data = Get-F5Dashboard
        Export-F5DashboardHtml -DashboardData $data -OutputPath "$env:TEMP\f5.html" -ReportTitle "Production F5 Status"
        # Custom report title.
    .EXAMPLE
        # Full end-to-end: connect -> gather -> render -> open
        Connect-F5Server -F5Host "bigip01" -Credential (Get-Credential) -IgnoreSSLErrors
        $data = Get-F5Dashboard
        $outPath = "$env:TEMP\F5-Dashboard.html"
        Export-F5DashboardHtml -DashboardData $data -OutputPath $outPath
        Start-Process $outPath
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$DashboardData,
        [Parameter(Mandatory)][string]$OutputPath,
        [string]$ReportTitle = "F5 BIG-IP Dashboard",
        [string]$TemplatePath
    )

    if (-not $TemplatePath) {
        $TemplatePath = Join-Path $PSScriptRoot "F5-Dashboard-Template.html"
    }

    if (-not (Test-Path $TemplatePath)) {
        throw "HTML template not found at $TemplatePath"
    }

    # Build column definitions
    $firstObj = $DashboardData | Select-Object -First 1
    $columns = @()
    foreach ($prop in $firstObj.PSObject.Properties) {
        $col = @{
            field      = $prop.Name
            title      = ($prop.Name -creplace '([A-Z])', ' $1').Trim()
            sortable   = $true
            searchable = $true
        }
        # Apply formatters for status columns
        if ($prop.Name -match 'Status$|Availability$') {
            $col.formatter = 'formatStatus'
        }
        $columns += $col
    }

    $columnsJson = $columns | ConvertTo-Json -Depth 5 -Compress
    $dataJson = $DashboardData | ConvertTo-Json -Depth 5 -Compress

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


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

    Set-Content -Path $OutputPath -Value $html -Encoding UTF8
    Write-Verbose "F5 Dashboard HTML written to $OutputPath"
}

# SIG # Begin signature block
# MIIVlwYJKoZIhvcNAQcCoIIViDCCFYQCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCD904VLE/4jQqrc
# pgaxqzMGCQNYoecxnzIcR0kXaleY+qCCEdMwggVvMIIEV6ADAgECAhBI/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
# BAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQgInPrITX9X9gl2pmmpRzVm7r/oG2BKRdw
# eQPlAiT3g28wDQYJKoZIhvcNAQEBBQAEggIAhmd4yE915xJHee55+MnRuxS3+/+4
# y9GVjmLJ6DAjIiUfTLp3wW1WFOqTS9BKN3vExSKvumWOl8jj/swomWPt21htmR2N
# 2MMa/31SpHx8NH5tnJZyxvjWHzvdQe6LANxEcN4ZzPnbQEsRf4+lxyHgnEOqzK30
# LeG2qeiFhx84/tH0QD0DffZqDtQ93WQr1A5tNQ55thcl+ky3qIYO0If/EdsvB0yx
# o3juQ+Gjqvs2BDeTAKpNH2VgitWnAZsS9WjczP0K2JpQFiZupu07lA7Y30M+82OY
# zCYeUVYn8818K5aDduNcfCGVNat9Y+vMFDierxTIIQTA4lfYmgAX/d4oU5VIrhMa
# 6qL3//DI652nFk60hLTyGpIbFSldav9Ae3SMCcPeDgsHk59SzGY6lr3SkO/w7ICG
# WvIbcCKuRTpa0VESAYe8lsAoI5r+uvuOhyvWGQAFk5LgiTdBZubjYlkHdETAYnxk
# 9IDI2/12ZuNggsEqK2SdDEFSarGbn7jiIc/QUxsHtlPWQQXBa4yOaWuvyNsgUa/T
# 03WdfUTX+hroEnM44IMidYl8ZGVF05cgl7y28ZJUh+rNU5xtTricS/L9DhhtNDjv
# IwEcWwvf+Sy8Ztrsh8kWvz6PdFizioOy7e4NvxZXbI3tI0N9gUJMlzofZ8393xTG
# BqOICbX2Bgm0XrY=
# SIG # End signature block