Private/Kinds/RemoteDesktop.ps1

# The RemoteDesktop Kind: the Remote Desktop connections from this machine that failed.
#
# A Technician works on a Customer's server by Remote Desktop, and "it would not connect
# twice last night" is a complaint about the network that no other Check sees: the SMB
# client logs file shares, and a session to a server is none. The Remote Desktop client
# keeps a log of its own, Microsoft-Windows-TerminalServices-RDPClient/Operational, and
# writes every connection into it: the server it was asked for (1024), the address it
# went to (1102), that it came about (1025), and why it ended (1026). The events of one
# connection share an activity id, which is what makes one connection of them. Verified
# against the event manifest: docs/research/network-protocol-events.md.
#
# What the reason of an ending means, Windows says itself: the Remote Desktop component
# (MSTscAx) gives the sentence its own dialog would show. That sentence stands beside the
# number.
#
# Every connection is reported, of every account of the machine, with when it began and
# how long it lasted: the ones that worked are what the ones that failed are read
# against, and a session that ended in order minutes before one that would not come
# about says which side the fault is on. This is the exception to ADR-0006 that its
# amendment of 2026-10-12 records. The hash of the user name the log carries (1029) is
# not read: the event names the account by its SID.
#
# What the Gatherer returns:
# Days, Since, UnreadableLogs, ScannedLogs, LogCoverage as the NetworkDisruption Kind
# Connections { Server, Address, Outcome, Start, Seconds, Reason, Warning, Account }:
# Outcome is Failed, Dropped, Ended or Cancelled
# ReasonTexts { Reason, Text }: Windows' sentence for each reason met

$script:RemoteDesktopLog = 'Microsoft-Windows-TerminalServices-RDPClient/Operational'
$script:RemoteDesktopDefaultDays = 30

# How many of the newest connections the table of all of them lists.
$script:RemoteDesktopSectionRows = 200

# The reasons of an ending that are no error. 1 and 2 are documented as such: the
# connection was closed here, or there by the person signed in. 3 is the server ending
# the session, which an administrator signing somebody out is. 263 is logged where the
# window is closed before sign-in; the component has no sentence for it. Microsoft:
# "Remote Desktop Client Troubleshooting: Disconnect Codes and Reasons".
$script:RemoteDesktopNormalReasons = @(0, 1, 2, 3, 263)

function Get-RemoteDesktopOutcome {
    <#
    .SYNOPSIS
        What became of a connection: Failed, Dropped, Ended or Cancelled. Pure.
    .DESCRIPTION
        Failed it never came about, and nobody here gave up on it
        Cancelled it never came about because it was closed here
        Dropped it came about, and ended with a reason that is an error
        Ended it came about and was ended, here or there
        A connection whose end is not in the log - it is still open, or the log begins
        after it - is Ended where it came about and nothing where it did not.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([bool]$Connected, [AllowNull()]$Reason)

    $number = ConvertTo-Number $Reason
    if ($Connected) {
        if ($null -eq $number -or $script:RemoteDesktopNormalReasons -contains [int]$number) { return 'Ended' }
        return 'Dropped'
    }
    if ($null -eq $number) { return '' }
    if ([int]$number -in 1, 263) { return 'Cancelled' }
    'Failed'
}

function Get-RemoteDesktopOutcomeText {
    <#
    .SYNOPSIS
        What the Report calls an outcome. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Outcome)

    switch ("$Outcome") {
        'Failed'    { return (Get-Text 'Value.RemoteDesktop.Outcome.Failed') }
        'Dropped'   { return (Get-Text 'Value.RemoteDesktop.Outcome.Dropped') }
        'Ended'     { return (Get-Text 'Value.RemoteDesktop.Outcome.Ended') }
        'Cancelled' { return (Get-Text 'Value.RemoteDesktop.Outcome.Cancelled') }
    }
    "$Outcome"
}

function Get-RemoteDesktopWarningText {
    <#
    .SYNOPSIS
        A warning of the client (event 226) as one line: which part of it, and what
        happened there. Pure.
    .DESCRIPTION
        The client names its parts and its events in English in every language:
        "RDPClient_TCP", "TcpEventConnectionTimeout". The ones a Technician meets have a
        German wording; another is shown as the client names it, which is a name and no
        code. The number behind it is left out: it says no more than the name.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Part, [AllowNull()][AllowEmptyString()][string]$EventName)

    switch ("$EventName".Trim()) {
        'TcpEventConnectionTimeout' { return (Get-Text 'Value.RemoteDesktop.Warning.TcpTimeout') }
        'TsSslEventInvalidState'    { return (Get-Text 'Value.RemoteDesktop.Warning.TlsState') }
        'TcpEventDisconnected'      { return (Get-Text 'Value.RemoteDesktop.Warning.TcpDisconnected') }
    }
    $where = ("$Part".Trim() -replace '^RDPClient_', '')
    @(@($where, "$EventName".Trim()) | Where-Object { $_ }) -join ': '
}

function ConvertTo-RemoteDesktopConnection {
    <#
    .SYNOPSIS
        The connections a list of the client's events amounts to. Pure.
    .DESCRIPTION
        Events of one activity id are one connection. An event without one belongs to the
        connection its process began last. A group without the event that names the
        server began before the log does, and is no connection that can be reported.
        The account is the SID the event carries; the Gatherer puts the name in its place.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$LogEntry)

    $ordered = @($LogEntry | Where-Object { $_ } | Sort-Object TimeCreated)
    $groups  = [ordered]@{}
    $open    = @{}
    foreach ($entry in $ordered) {
        $key = "$($entry.ActivityId)"
        if (-not $key -or $key -eq '00000000-0000-0000-0000-000000000000') {
            $process = "$($entry.ProcessId)"
            if ([int]$entry.Id -eq 1024) { $open[$process] = 'p{0}-{1}' -f $process, $groups.Count }
            $key = "$($open[$process])"
            if (-not $key) { continue }
        }
        if (-not $groups.Contains($key)) { $groups[$key] = New-Object System.Collections.Generic.List[psobject] }
        $groups[$key].Add($entry)
    }

    foreach ($key in $groups.Keys) {
        $entries = @($groups[$key])
        $asked   = @($entries | Where-Object { [int]$_.Id -eq 1024 })[0]
        if (-not $asked) { continue }
        $server = "$(ConvertTo-NetworkDisruptionServer -Value (Get-EventPayload -LogEntry $asked -Index 1))"
        if (-not $server) { continue }

        $went    = @($entries | Where-Object { [int]$_.Id -eq 1102 })[0]
        $address = ''
        if ($went) { $address = "$(ConvertTo-NetworkDisruptionServer -Value (Get-EventPayload -LogEntry $went -Index 1))" }

        $connected = @($entries | Where-Object { [int]$_.Id -eq 1025 })[0]
        $ended     = @($entries | Where-Object { [int]$_.Id -eq 1026 })[-1]
        $reason    = $null
        if ($ended) { $reason = ConvertTo-Number (Get-EventPayload -LogEntry $ended -Index 1) }

        $outcome = Get-RemoteDesktopOutcome -Connected ([bool]$connected) -Reason $reason
        if (-not $outcome) { continue }

        $start   = $asked.TimeCreated
        $seconds = $null
        if ($ended -and $null -ne $ended.TimeCreated -and $null -ne $start) {
            $seconds = [int][math]::Round(([datetime]$ended.TimeCreated - [datetime]$start).TotalSeconds)
        }
        $warning = @($entries | Where-Object { [int]$_.Id -eq 226 } | ForEach-Object {
            Get-RemoteDesktopWarningText -Part (Get-EventPayload -LogEntry $_ -Index 0) -EventName (Get-EventPayload -LogEntry $_ -Index 6)
        } | Where-Object { $_ } | Select-Object -Unique) -join '; '

        [pscustomobject]@{
            Account = "$($asked.UserId)"
            Server  = $server
            Address = $address
            Outcome = $outcome
            Start   = $start
            Seconds = $seconds
            Reason  = $reason
            Warning = $warning
        }
    }
}

function Get-RemoteDesktopReasonText {
    <#
    .SYNOPSIS
        Windows' own sentence for each reason of an ending, from its Remote Desktop
        component. Nothing for a reason it has none for. Reads Windows; changes nothing.
    .DESCRIPTION
        The component answers a reason it does not know with one fixed sentence about an
        internal error. That sentence is asked for first, with a number that is no
        reason, and whatever equals it is no meaning. Of a long answer the first two
        sentences are kept: the rest is advice to call an administrator.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()][int[]]$Reason)

    $wanted = @($Reason | Where-Object { $null -ne $_ } | Sort-Object -Unique)
    if (-not $wanted.Count) { return }

    $component = $null
    try { $component = New-Object -ComObject MSTscAx.MsTscAx -ErrorAction Stop } catch { return }
    try {
        $unknown = ''
        try { $unknown = "$($component.GetErrorDescription([uint32]999999, [uint32]0))".Trim() } catch { }
        foreach ($number in $wanted) {
            $text = ''
            try { $text = "$($component.GetErrorDescription([uint32]$number, [uint32]0))" } catch { $text = '' }
            $text = ($text -replace '\s+', ' ').Trim()
            if (-not $text -or $text -eq $unknown) { continue }
            # The dialog puts the computer's name into some sentences; asked here, the
            # place is left as two quotation marks. The first sentence says it without.
            if ($text -match '""') { $text = (@($text -split '(?<=\.)\s+')[0] -replace '\s*""', '') }
            # Two sentences, and where the second opens a numbered list, the list to its end.
            $sentences = @($text -split '(?<=\.)\s+' | Where-Object { $_ })
            if ($sentences.Count -gt 2) {
                $keep = 2
                if ($sentences[0] -match '\d\)') { while ($keep -lt $sentences.Count -and $sentences[$keep] -match '^\d+\)') { $keep++ } }
                $text = ($sentences[0..($keep - 1)] -join ' ')
            }
            [pscustomobject]@{ Reason = [int]$number; Text = $text.TrimEnd('.') }
        }
    }
    finally { try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($component) } catch { } }
}

function Get-RemoteDesktopData {
    <#
    .SYNOPSIS
        Reads the Remote Desktop client's log for the account of the Run and returns the
        connections in it. Judges none of them.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $days  = [int](Get-Parameter $Parameters 'Days' $script:RemoteDesktopDefaultDays)
    $since = (Get-Date).AddDays(-$days)
    $log   = $script:RemoteDesktopLog

    $unreadable = @{}
    $scanned    = @()
    $found      = @()
    $registration = Get-NetworkDisruptionRegistration
    if (-not $registration -or $registration.Log.Contains($log)) {
        $scanned = @($log)
        $found   = @(Get-StabilityEvent -Log $log -Id 1024, 1025, 1026, 1102, 226 -Since $since -Unreadable $unreadable)
    }

    $connections = @(ConvertTo-RemoteDesktopConnection -LogEntry $found)

    # The log names an account by its SID; a Technician reads its name. A SID nothing
    # translates - an account that was deleted - stays as it is.
    foreach ($connection in $connections) {
        $sid = "$($connection.Account)"
        if (-not $sid) { continue }
        $named = Resolve-AccountName -Sid $sid
        if ($named.Name) { $connection.Account = $named.Name }
    }
    $failing     = @($connections | Where-Object { $_.Outcome -in 'Failed', 'Dropped' })

    $coverage = @()
    if ($scanned.Count -and -not $unreadable.ContainsKey($log)) {
        $coverage = @([pscustomobject]@{ Log = $log; Oldest = Get-NetworkDisruptionLogOldest -Log $log })
    }

    [pscustomobject]@{
        PSTypeName     = 'Gutcheck.Data.RemoteDesktop'
        Days           = $days
        Since          = $since
        Connections    = $connections
        ReasonTexts    = @(Get-RemoteDesktopReasonText -Reason @($failing | ForEach-Object { $_.Reason } | Where-Object { $null -ne $_ } | ForEach-Object { [int]$_ }))
        UnreadableLogs = @($unreadable.Keys | Sort-Object)
        ScannedLogs    = [string[]]@($scanned)
        LogCoverage    = $coverage
    }
}

function Format-RemoteDesktopReason {
    <#
    .SYNOPSIS
        The reason of an ending with Windows' sentence for it behind it. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Reason, [AllowNull()][AllowEmptyCollection()]$Text)

    $number = ConvertTo-Number $Reason
    if ($null -eq $number) { return '' }
    $found = @(@($Text | Where-Object { $_ }) | Where-Object { "$(Get-DataProperty $_ 'Reason')" -eq "$([int]$number)" })[0]
    $meaning = "$(Get-DataProperty $found 'Text')"
    if (-not $meaning) { $meaning = Get-RemoteDesktopReasonMeaning -Reason $number }
    Format-CodeWithMeaning -Code "$([int]$number)" -Meaning $meaning
}

function Get-RemoteDesktopReasonMeaning {
    <#
    .SYNOPSIS
        What a reason means that Windows' component has no sentence for. Nothing for a
        reason not known here either. Pure.
    .DESCRIPTION
        The component answers some reasons of the sign-in with nothing: its dialog words
        them from more than the number. Microsoft lists them: "Remote Desktop Client
        Troubleshooting: Disconnect Codes and Reasons".
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Reason)

    $number = ConvertTo-Number $Reason
    if ($null -eq $number) { return '' }
    switch ([int]$number) {
        5639 { return (Get-Text 'Value.RemoteDesktop.Reason.5639') }
        5895 { return (Get-Text 'Value.RemoteDesktop.Reason.5895') }
        6151 { return (Get-Text 'Value.RemoteDesktop.Reason.6151') }
        7175 { return (Get-Text 'Value.RemoteDesktop.Reason.7175') }
        8455 { return (Get-Text 'Value.RemoteDesktop.Reason.8455') }
    }
    ''
}

function Get-RemoteDesktopServerTally {
    <#
    .SYNOPSIS
        Per server: how many connections, how many of them failed and dropped, and the
        addresses they went to. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Connection)

    $groups = @($Connection | Where-Object { $_ }) | Group-Object { "$(Get-DataProperty $_ 'Server')".ToUpperInvariant() }
    foreach ($group in @($groups)) {
        $addresses = @($group.Group | ForEach-Object { "$(Get-DataProperty $_ 'Address')" } | Where-Object { $_ } |
            Group-Object | Sort-Object @{ Expression = 'Count'; Descending = $true }, Name)
        [pscustomobject]@{
            Server    = $group.Name
            Total     = @($group.Group).Count
            Failed    = @($group.Group | Where-Object { "$(Get-DataProperty $_ 'Outcome')" -eq 'Failed' }).Count
            Dropped   = @($group.Group | Where-Object { "$(Get-DataProperty $_ 'Outcome')" -eq 'Dropped' }).Count
            Addresses = @($addresses | ForEach-Object { [pscustomobject]@{ Address = $_.Name; Count = $_.Count } })
        }
    }
}

function New-RemoteDesktopOutcomeFinding {
    <#
    .SYNOPSIS
        The Finding about the connections of one outcome, Failed or Dropped: per server
        how many of how many, and the reasons. Says that there were none where there
        were none.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Data, [hashtable]$Parameters,
        [Parameter(Mandatory)][ValidateSet('Failed', 'Dropped')][string]$Outcome,
        [AllowNull()][AllowEmptyCollection()]$Tally
    )

    $check = Get-Text 'Check.RemoteDesktop.Failed'
    $warn  = Get-Parameter $Parameters 'FailedWarnAbove' 3
    $valueText = Get-Text 'Value.RemoteDesktop.Failed'
    $meaning   = Get-Text 'Meaning.RemoteDesktop.Failed'
    $hint      = Get-Text 'Hint.RemoteDesktop.Failed'
    if ($Outcome -eq 'Dropped') {
        $check = Get-Text 'Check.RemoteDesktop.Dropped'
        $warn  = Get-Parameter $Parameters 'DroppedWarnAbove' 10
        $valueText = Get-Text 'Value.RemoteDesktop.Dropped'
        $meaning   = Get-Text 'Meaning.RemoteDesktop.Dropped'
        $hint      = Get-Text 'Hint.RemoteDesktop.Dropped'
    }

    $servers = @($Tally | Where-Object { $_ -and [int]$_.$Outcome -gt 0 } | Sort-Object @{ Expression = $Outcome; Descending = $true }, Server)
    if (-not $servers.Count) {
        $log = [string[]]@((Get-DataCollection $Data 'ScannedLogs') | ForEach-Object { "$_" })
        return New-NetworkDisruptionQuietFinding -Data $Data -Check $check -Unread (Get-DataCollection $Data 'UnreadableLogs') -Log $log
    }

    $total = ($servers | Measure-Object -Property $Outcome -Sum).Sum
    $named = @($servers | Select-Object -First 5 | ForEach-Object {
        (Get-Text 'Value.RemoteDesktop.OfTotal') -f $_.Server, $_.$Outcome, $_.Total })
    if ($servers.Count -gt 5) { $named += (Get-Text 'Value.NetworkProtocol.More') -f ($servers.Count - 5) }

    $rows = @((Get-DataCollection $Data 'Connections') | Where-Object { "$(Get-DataProperty $_ 'Outcome')" -eq $Outcome })
    $reasons = Format-NetworkProtocolCount -First 3 -Name @($rows | ForEach-Object {
        Format-RemoteDesktopReason -Reason (Get-DataProperty $_ 'Reason') -Text (Get-DataCollection $Data 'ReasonTexts') })

    $value = @($valueText -f $total, (Get-NetworkDisruptionDays -Data $Data), ($named -join '; '))
    if ($reasons) { $value += (Get-Text 'Value.NetworkProtocol.Causes') -f $reasons }

    $severity = Get-Severity $servers[0].$Outcome $warn ([double]::MaxValue)
    if ($severity -eq 'OK') { $severity = 'INFO' }

    New-Finding -Category Network -Check $check -Severity $severity -Value ($value -join ' | ') `
        -Meaning $meaning -Hint $hint -Reference @(New-Reference -Section 'Title.RemoteDesktop.Failures')
}

function New-RemoteDesktopAddressFinding {
    <#
    .SYNOPSIS
        The servers whose one name led to several addresses. Nothing where there is none.
    .DESCRIPTION
        "Sometimes it connects, sometimes it does not" is what a name with two addresses
        looks like when one of them does not answer. Which address a failed connection
        went to the log does not say - it ends before the client writes that down - so
        this names the addresses and leaves the conclusion to a test of each.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()]$Tally)

    $several = @($Tally | Where-Object { $_ -and @($_.Addresses).Count -gt 1 } | Sort-Object Server)
    if (-not $several.Count) { return }

    $named = @($several | ForEach-Object {
        '{0}: {1}' -f $_.Server, (@($_.Addresses | ForEach-Object { '{0} {1}x' -f $_.Address, $_.Count }) -join ', ') }) -join '; '
    $failing = @($several | Where-Object { ([int]$_.Failed + [int]$_.Dropped) -gt 0 }).Count

    $meaning = @(Get-Text 'Meaning.RemoteDesktop.Addresses')
    if ($failing) { $meaning += Get-Text 'Meaning.RemoteDesktop.AddressesFailing' }

    New-Finding -Category Network -Check (Get-Text 'Check.RemoteDesktop.Addresses') -Severity INFO `
        -Value ((Get-Text 'Value.RemoteDesktop.Addresses') -f $named) `
        -Meaning ($meaning -join ' | ') -Hint (Get-Text 'Hint.RemoteDesktop.Addresses')
}

function ConvertTo-RemoteDesktopFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [hashtable]$Parameters = @{})

    $connections = @((Get-DataCollection $Data 'Connections') | ForEach-Object { $_ })
    $unreadable  = @((Get-DataCollection $Data 'UnreadableLogs') | ForEach-Object { $_ })

    # Nobody connected from here, and the log was read: one line, and no three.
    if (-not $connections.Count -and -not $unreadable.Count) {
        return New-Finding -Category Network -Check (Get-Text 'Check.RemoteDesktop.Failed') -Severity OK `
            -Value ((Get-Text 'Value.RemoteDesktop.NoConnections') -f (Get-NetworkDisruptionDays -Data $Data))
    }

    $tally = @(Get-RemoteDesktopServerTally -Connection $connections)
    New-RemoteDesktopOutcomeFinding -Data $Data -Parameters $Parameters -Outcome Failed -Tally $tally
    # That the log could not be read is said once, by the Finding above.
    if ($connections.Count) {
        New-RemoteDesktopOutcomeFinding -Data $Data -Parameters $Parameters -Outcome Dropped -Tally $tally
        New-RemoteDesktopAddressFinding -Tally $tally
    }
    New-UnreadableLogFinding -Data $Data -Parameters $Parameters
}

function ConvertTo-RemoteDesktopSection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $connections = @((Get-DataCollection $Data 'Connections') | ForEach-Object { $_ })
    $texts       = Get-DataCollection $Data 'ReasonTexts'

    $time    = Get-Text 'Column.NetworkDisruption.Time'
    $result  = Get-Text 'Column.RemoteDesktop.Outcome'
    $server  = Get-Text 'Column.RemoteDesktop.Server'
    $address = Get-Text 'Column.NetworkDisruption.Address'
    $after   = Get-Text 'Column.RemoteDesktop.Seconds'
    $cause   = Get-Text 'Column.NetworkProtocol.Cause'
    $warning = Get-Text 'Column.RemoteDesktop.Warning'
    $account = Get-Text 'Column.RemoteDesktop.Account'
    $line    = {
        param($Connection)
        $row = [ordered]@{}
        $row[$time]    = Get-DataProperty $Connection 'Start'
        $row[$result]  = Get-RemoteDesktopOutcomeText -Outcome "$(Get-DataProperty $Connection 'Outcome')"
        $row[$account] = "$(Get-DataProperty $Connection 'Account')"
        $row[$server]  = "$(Get-DataProperty $Connection 'Server')"
        $row[$address] = "$(Get-DataProperty $Connection 'Address')"
        $row[$after]   = Get-DataProperty $Connection 'Seconds'
        $row[$cause]   = Format-RemoteDesktopReason -Reason (Get-DataProperty $Connection 'Reason') -Text $texts
        $row[$warning] = "$(Get-DataProperty $Connection 'Warning')"
        [pscustomobject]$row
    }

    New-Section -Title (Get-Text 'Title.RemoteDesktop.Failures') -Help (Get-Text 'Help.RemoteDesktop.Failures') -Row @(
        $connections | Where-Object { "$(Get-DataProperty $_ 'Outcome')" -in 'Failed', 'Dropped' } |
            Sort-Object { Get-DataProperty $_ 'Start' } -Descending | ForEach-Object { & $line $_ }
    )

    # Every connection, the ones that worked among them: what the failures are read against.
    New-Section -Title (Get-Text 'Title.RemoteDesktop.All') -Help (Get-Text 'Help.RemoteDesktop.All') -Row @(
        $connections | Sort-Object { Get-DataProperty $_ 'Start' } -Descending |
            Select-Object -First $script:RemoteDesktopSectionRows | ForEach-Object { & $line $_ }
    )

    $count   = Get-Text 'Column.RemoteDesktop.Connections'
    $failed  = Get-Text 'Column.RemoteDesktop.Failed'
    $dropped = Get-Text 'Column.RemoteDesktop.Dropped'
    $all     = Get-Text 'Column.RemoteDesktop.Addresses'
    New-Section -Title (Get-Text 'Title.RemoteDesktop.Servers') -Help (Get-Text 'Help.RemoteDesktop.Servers') -Row @(
        Get-RemoteDesktopServerTally -Connection $connections |
            Sort-Object @{ Expression = { [int]$_.Failed + [int]$_.Dropped }; Descending = $true }, Server | ForEach-Object {
                $row = [ordered]@{}
                $row[$server]  = $_.Server
                $row[$all]     = @($_.Addresses | ForEach-Object { '{0} ({1}x)' -f $_.Address, $_.Count }) -join ', '
                $row[$count]   = $_.Total
                $row[$failed]  = $_.Failed
                $row[$dropped] = $_.Dropped
                [pscustomobject]$row
            }
    )
}