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 { } } } # What Get-RemoteDesktopData hands its Judge. See Private/DataShape.ps1. $script:RemoteDesktopDataShape = @{ Days = 'Answered' Since = 'Value' Connections = 'List' ReasonTexts = 'List' UnreadableLogs = 'List' ScannedLogs = 'List' LogCoverage = 'List' } 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. .DESCRIPTION A warning above the count a Check Definition allows for one server, and above the one it allows for all of them together: twenty-six sessions dropped to fifteen servers is no server's fault, and was no warning while each was counted alone. #> [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 $warnTotal = Get-Parameter $Parameters 'FailedTotalWarnAbove' 9 $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' 9 $warnTotal = Get-Parameter $Parameters 'DroppedTotalWarnAbove' 19 $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' if ((Get-Severity $total $warnTotal ([double]::MaxValue)) -ne 'OK') { $severity = 'WARN' $meaning = '{0} | {1}' -f (Get-Text 'Meaning.RemoteDesktop.Many'), $meaning } } 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. How far # back the log reaches is still drawn: a quiet stretch is only quiet where it reaches. if (-not $connections.Count -and -not $unreadable.Count) { New-Finding -Category Network -Check (Get-Text 'Check.RemoteDesktop.Failed') -Severity OK ` -Value ((Get-Text 'Value.RemoteDesktop.NoConnections') -f (Get-NetworkDisruptionDays -Data $Data)) return (ConvertTo-RemoteDesktopTimedEvent -Data $Data) } $tally = @(Get-RemoteDesktopServerTally -Connection $connections) $found = @{} $found.Failed = New-RemoteDesktopOutcomeFinding -Data $Data -Parameters $Parameters -Outcome Failed -Tally $tally $found.Failed # That the log could not be read is said once, by the Finding above. if ($connections.Count) { $found.Dropped = New-RemoteDesktopOutcomeFinding -Data $Data -Parameters $Parameters -Outcome Dropped -Tally $tally $found.Dropped New-RemoteDesktopAddressFinding -Tally $tally } New-UnreadableLogFinding -Data $Data -Parameters $Parameters # The same connections on the 30 days, each with the Finding that counted it. ADR-0010. ConvertTo-RemoteDesktopTimedEvent -Data $Data -Finding $found } function ConvertTo-RemoteDesktopTimedEvent { <# .SYNOPSIS Every connection that failed or dropped as a Timed Event of its Server's lane, with the Finding that counted it, and how far back the client's log reaches. Pure. ADR-0010. .DESCRIPTION A connection that never came about is Failed, a session that dropped is Lost: the marks the file shares' events have, so that a Server RDP alone fails to reach has a lane and stands on the Weg. One that ended in order or was closed here is no fault and is not drawn: the table of all connections has it. The Server is named as the client was asked for it, with the address it went to where the log has one. A connection that failed before it had one has none; the view puts it on the Server of the same name. Nothing is looked up for this. The words say it was Remote Desktop, so that the hover tells it apart from SMB on the same Server, and the reason of the ending where its meaning is known: a number alone says nothing. A connection whose time cannot be read is left out: its Finding counts it, and the timeline cannot place it. .PARAMETER Finding The Findings made of the connections, by the outcome they count: Failed and Dropped. Where one is missing, its connections are drawn grey. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data, [hashtable]$Finding = @{}) $texts = Get-DataCollection $Data 'ReasonTexts' foreach ($connection in @((Get-DataCollection $Data 'Connections') | Where-Object { $_ })) { $outcome = "$(Get-DataProperty $connection 'Outcome')" if ($outcome -notin 'Failed', 'Dropped') { continue } $at = ConvertTo-DataTime (Get-DataProperty $connection 'Start') if ($null -eq $at) { continue } $mark = 'Failed' $detail = Get-Text 'Value.RemoteDesktop.Timeline.Failed' if ($outcome -eq 'Dropped') { $mark = 'Lost' $detail = Get-Text 'Value.RemoteDesktop.Timeline.Dropped' } # A reason with no meaning known comes back as the bare number, and stays out. $cause = Format-RemoteDesktopReason -Reason (Get-DataProperty $connection 'Reason') -Text $texts if ($cause -and $cause -notmatch '^\d+$') { $detail = '{0}: {1}' -f $detail, $cause } New-TimedEvent -Time $at -Lane Server -Mark $mark -Subject "$(Get-DataProperty $connection 'Server')" ` -Address "$(Get-DataProperty $connection 'Address')" -Detail $detail -Log $script:RemoteDesktopLog ` -Finding $Finding[$outcome] } ConvertTo-LogCoverageTimedEvent -Coverage (Get-DataCollection $Data 'LogCoverage') -Unreadable (Get-DataCollection $Data 'UnreadableLogs') } 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 } ) } |