Private/Kinds/Server.ps1

# The Server Kind: whether the machines an application depends on can be reached.
#
# The first Kind whose parameters are genuinely per-Customer. Every other Kind asks the
# same questions of every machine; this one cannot be performed at all without knowing
# which Servers matter, and those differ per Customer by definition. It is therefore also
# the first real exercise of a Check Definition configuring rather than merely tuning.
#
# The same Kind serves an application's Servers and Servers a Technician supplied directly.
# There is no difference between them worth a second implementation: a Server is a host,
# optionally with ports, and the questions are the same either way.

# How many times each port is tried and how many pings are sent. Not Check Definition
# parameters, for the reason Private/Sampling.ps1 gives: a Customer may disagree about a
# threshold, not about how many attempts make a measurement. One connect is not evidence -
# a firewall that drops one connection in five is exactly what makes an application feel
# unreliable, and a single successful probe would report it healthy.
$script:ServerTcpAttempts = 5
$script:ServerPingCount   = 20

function ConvertTo-ServerTarget {
    <#
    .SYNOPSIS
        Parses one Server entry into a host and its ports. Pure, and reaches nothing.
    .DESCRIPTION
        A Server is written as "host", "host:port" or "host:port,port". Parsing is separate
        from probing so that what Gutcheck understood from a Check Definition can be tested
        without a network, which matters because a mistyped entry in the Checks Repo would
        otherwise surface as a Customer's server being unreachable.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$Entry)

    if (-not $Entry -or -not "$Entry".Trim()) { return }

    $parts    = "$Entry".Trim() -split ':', 2
    $hostName = $parts[0].Trim()
    if (-not $hostName) { return }

    $ports = @()
    if ($parts.Count -gt 1) {
        $ports = @($parts[1] -split ',' |
            ForEach-Object { $_.Trim() } |
            Where-Object { $_ -match '^\d+$' } |
            ForEach-Object { [int]$_ } |
            Where-Object { $_ -ge 1 -and $_ -le 65535 })
    }

    [pscustomobject]@{
        PSTypeName  = 'Gutcheck.ServerTarget'
        Entry       = "$Entry".Trim()
        HostName    = $hostName
        Ports       = $ports
        # An address literal has nothing to resolve, and reporting DNS for one would be
        # reporting on a lookup that never happened.
        IsIpLiteral = $hostName -match '^\d{1,3}(\.\d{1,3}){3}$'
    }
}

function ConvertTo-ServerRoute {
    <#
    .SYNOPSIS
        Whether an address is in the machine's own network or beyond a gateway. Pure.
    .DESCRIPTION
        A route without a next hop is an address next door. How the machine is connected
        - by cable or by radio - is not asked here: that is the Situation, the same for
        every Server. Where a Server is, is this Server's own.
    #>

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

    $hops = @($Route | ForEach-Object { "$(Get-DataProperty $_ 'NextHop')" } | Where-Object { $_ })
    # Nothing said about a next hop is nothing known, not an address next door.
    if (-not $hops.Count) { return }
    [pscustomobject]@{ OnLink = @($hops | Where-Object { $_ -notin '0.0.0.0', '::' }).Count -eq 0 }
}

function Get-ServerRoute {
    <#
    .SYNOPSIS
        Asks Windows how it would reach an address. Nothing where it cannot say: a Finding
        then says what fits either way.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$Address)

    if (-not $Address -or -not (Get-Command -Name Find-NetRoute -ErrorAction SilentlyContinue)) { return }
    try { ConvertTo-ServerRoute -Route @(Find-NetRoute -RemoteIPAddress $Address -ErrorAction Stop) }
    catch { }
}
function Get-ServerMailboxTarget {
    <#
    .SYNOPSIS
        Which Servers to measure for the mailboxes that are set up, out of what the
        Autodiscover Check gathered: the Customer's own Exchange for a mailbox that is
        on it, the Servers of the Check Definition for one in Exchange Online. Pure.
    .DESCRIPTION
        "Laufzeitmessungen (Server)" under Outlook measured Microsoft's two Servers on
        every machine, also on one whose mailbox is on the Customer's own Exchange, and
        said nothing of the Server Outlook talks to.
 
        Each target says for which mail domains it is measured and as what (Origin). A
        Server two mailboxes share is measured once. Where no mailbox is known - the
        Autodiscover Check did not run, or found none - the Servers of the Check
        Definition are measured as before, and Reason says why.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Autodiscover, [AllowNull()][AllowEmptyCollection()][string[]]$Default = @())

    $mailboxes = @()
    $reason    = ''
    if ($null -eq $Autodiscover) { $reason = 'NoAutodiscover' }
    else {
        $mailboxes = @(Get-AutodiscoverMailboxServer -Data $Autodiscover)
        if (-not $mailboxes.Count) { $reason = 'NoMailbox' }
    }

    $order = New-Object System.Collections.Generic.List[string]
    $byEntry = @{}
    $add = {
        param([string]$Entry, [string]$Origin, [string]$Domain)
        $key = $Entry.ToLowerInvariant()
        if (-not $byEntry.ContainsKey($key)) {
            $order.Add($key)
            $byEntry[$key] = [pscustomobject]@{ Entry = $Entry; Origin = $Origin; Domains = (New-Object System.Collections.Generic.List[string]) }
        }
        if ($Domain -and -not $byEntry[$key].Domains.Contains($Domain)) { $byEntry[$key].Domains.Add($Domain) }
    }

    if ($reason) { foreach ($entry in @($Default)) { & $add $entry 'Default' '' } }
    foreach ($mailbox in $mailboxes) {
        if ($mailbox.Location -eq 'OnPremises') {
            $origin = if ($mailbox.Source -eq 'Cached') { 'OnPremises' } else { 'OnPremisesAutodiscover' }
            foreach ($hostName in @($mailbox.Hosts)) { & $add ('{0}:443' -f $hostName) $origin $mailbox.Domain }
        }
        else {
            # In Exchange Online, or not known where: Microsoft's, as the Definition names them.
            foreach ($entry in @($Default)) { & $add $entry 'ExchangeOnline' $mailbox.Domain }
        }
    }

    [pscustomobject]@{
        Targets   = @($order | ForEach-Object { $t = $byEntry[$_]; [pscustomobject]@{ Entry = $t.Entry; Origin = $t.Origin; For = (@($t.Domains) -join ', ') } })
        Mailboxes = $mailboxes
        Reason    = $reason
    }
}

function Get-ServerData {
    <#
    .PARAMETER Observed
        What the Checks before this one gathered. Read only where the Check Definition
        sets MailboxServers: then 'Autodiscover' says which mailboxes are set up and
        where, and their Servers are what is measured. The Check has to stand after the
        Autodiscover Check for that; where it does not, or that Check did not run, the
        Servers of the Definition are measured and the Finding says so.
    #>

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

    $entries = @(Get-Parameter $Parameters 'Servers' @())

    $mailbox = $null
    $about   = @{}
    if ([bool](Get-Parameter $Parameters 'MailboxServers' $false)) {
        $autodiscover = $null
        if ($Observed -and $Observed.ContainsKey('Autodiscover')) { $autodiscover = $Observed['Autodiscover'] }
        $chosen  = Get-ServerMailboxTarget -Autodiscover $autodiscover -Default $entries
        $entries = @($chosen.Targets | ForEach-Object { $_.Entry })
        foreach ($t in $chosen.Targets) { $about["$($t.Entry)".Trim()] = $t }
        $mailbox = [pscustomobject]@{ Requested = $true; Reason = $chosen.Reason; Mailboxes = @($chosen.Mailboxes) }
    }

    $servers = @(foreach ($entry in $entries) {
        $target = ConvertTo-ServerTarget -Entry $entry
        if (-not $target) { continue }

        $resolution = $null
        if (-not $target.IsIpLiteral) { $resolution = Resolve-HostAddress -HostName $target.HostName }

        # A host that does not resolve cannot be connected to or pinged by name, so the
        # remaining probes would only produce noise about a name that does not exist.
        $resolved = $target.IsIpLiteral -or $resolution.Resolved

        $portResults = @()
        $latency     = $null
        $route       = $null
        if ($resolved) {
            $route = Get-ServerRoute -Address $(if ($target.IsIpLiteral) { $target.HostName } else { $resolution.Address })
            $portResults = @(foreach ($port in $target.Ports) {
                $times = @(1..$script:ServerTcpAttempts | ForEach-Object {
                    Write-CheckProgress -Status ((Get-Text 'Console.Progress.Connect') -f ('{0}:{1}' -f $target.HostName, $port)) `
                        -Step ($_ - 1) -Of $script:ServerTcpAttempts
                    $ms = Measure-TcpConnect -HostName $target.HostName -Port $port
                    Start-Sleep -Milliseconds 200
                    $ms
                })
                $ok = @($times | Where-Object { $null -ne $_ })
                [pscustomobject]@{
                    Port      = $port
                    Attempts  = $script:ServerTcpAttempts
                    Successes = $ok.Count
                    AverageMs = $(if ($ok.Count) { [math]::Round(($ok | Measure-Object -Average).Average, 1) } else { $null })
                    MaximumMs = $(if ($ok.Count) { ($ok | Measure-Object -Maximum).Maximum } else { $null })
                }
            })
            $latency = Measure-Latency -Target $target.HostName -Count $script:ServerPingCount
        }

        [pscustomobject]@{
            Entry       = $target.Entry
            HostName    = $target.HostName
            Ports       = $target.Ports
            IsIpLiteral = $target.IsIpLiteral
            Resolution  = $resolution
            PortResults = $portResults
            Latency     = $latency
            Route       = $route
            # For which mailboxes this Server is measured and as what, where the Check
            # measures the Servers of the mailboxes; nothing otherwise.
            For         = $(if ($about.ContainsKey($target.Entry)) { $about[$target.Entry].For })
            Origin      = $(if ($about.ContainsKey($target.Entry)) { $about[$target.Entry].Origin })
        }
    })

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.Data.Server'
        Servers    = $servers
        Mailbox    = $mailbox
    }
}

function Get-ServerForText {
    <#
    .SYNOPSIS
        Whose Server a measured one is, in a Technician's words; nothing for a Server
        that was not measured for a mailbox. Pure.
    #>

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

    $for = "$(Get-DataProperty $Server 'For')"
    switch ("$(Get-DataProperty $Server 'Origin')") {
        'OnPremises'             { return (Get-Text 'Value.Server.For.OnPremises') -f $for }
        'OnPremisesAutodiscover' { return (Get-Text 'Value.Server.For.OnPremisesAutodiscover') -f $for }
        'ExchangeOnline'         { return (Get-Text 'Value.Server.For.ExchangeOnline') -f $for }
        'Default'                { return Get-Text 'Value.Server.For.Default' }
    }
    ''
}

function New-ServerMailboxFinding {
    <#
    .SYNOPSIS
        The Finding that says which mailbox is where and which Server was measured for
        it, or why the Servers of the Check Definition were measured. Pure.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data)

    $mailbox = Get-DataProperty $Data 'Mailbox'
    if (-not [bool](Get-DataProperty $mailbox 'Requested')) { return }

    $check  = Get-Text 'Check.Server.MailboxServers'
    $reason = "$(Get-DataProperty $mailbox 'Reason')"
    if ($reason) {
        $value = if ($reason -eq 'NoAutodiscover') { Get-Text 'Value.Server.Mailbox.NoAutodiscover' } else { Get-Text 'Value.Server.Mailbox.NoMailbox' }
        return New-Finding -Category Network -Check $check -Severity INFO -Value $value `
            -Meaning (Get-Text 'Meaning.Server.Mailbox') -Hint (Get-Text 'Hint.Server.Mailbox.Fallback')
    }

    $known = $true
    $lines = @((Get-DataCollection $mailbox 'Mailboxes') | ForEach-Object {
        $domain = "$(Get-DataProperty $_ 'Domain')"
        $hosts  = @((Get-DataCollection $_ 'Hosts') | ForEach-Object { "$_" } | Where-Object { $_ }) -join ', '
        # Read here: inside the switch below, $_ is what is switched on.
        $source = "$(Get-DataProperty $_ 'Source')"
        switch ("$(Get-DataProperty $_ 'Location')") {
            'OnPremises' {
                if (-not $hosts) { $known = $false; (Get-Text 'Value.Server.Mailbox.OnPremisesUnknown') -f $domain }
                elseif ($source -eq 'Cached') { (Get-Text 'Value.Server.Mailbox.OnPremises') -f $domain, $hosts }
                else { $known = $false; (Get-Text 'Value.Server.Mailbox.OnPremisesAutodiscover') -f $domain, $hosts }
            }
            'ExchangeOnline' { (Get-Text 'Value.Server.Mailbox.ExchangeOnline') -f $domain }
            default          { $known = $false; (Get-Text 'Value.Server.Mailbox.Unknown') -f $domain }
        }
    })

    New-Finding -Category Network -Check $check -Severity $(if ($known) { 'OK' } else { 'INFO' }) -Value ($lines -join ' | ') `
        -Meaning (Get-Text 'Meaning.Server.Mailbox') -Hint (Get-Text 'Hint.Server.Mailbox.Fallback')
}

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

    $servers = Get-DataCollection $Data 'Servers'
    $forMailboxes = [bool](Get-DataProperty (Get-DataProperty $Data 'Mailbox') 'Requested')
    if (-not $servers.Count -and $forMailboxes) { return New-ServerMailboxFinding -Data $Data }
    if (-not $servers.Count) {
        # A Check Definition that names a Kind but supplies it nothing has not been
        # written yet, and a Report that simply omits it lets that go unnoticed.
        return New-Finding -Category Network -Check (Get-Text 'Check.Server.Servers') -Severity INFO -Value (Get-Text 'Value.Server.NoneConfigured') `
            -Hint (Get-Text 'Hint.Server.ThisCheckDefinitionNamesNo')
    }

    New-ServerMailboxFinding -Data $Data

    # Of the Servers of one mailbox on the own Exchange, which answer. Outlook is told
    # an address for inside the network and one for outside, and needs one of them.
    $answers = @{}
    foreach ($server in $servers) {
        if ("$(Get-DataProperty $server 'Origin')" -notlike 'OnPremises*') { continue }
        $group = "$(Get-DataProperty $server 'For')"
        $up = (Test-ServerResolved -Server $server) -and
              [bool]@((Get-DataCollection $server 'PortResults') | Where-Object { (ConvertTo-Number (Get-DataProperty $_ 'Successes')) -gt 0 }).Count
        if ($up) { $answers[$group] = $true } elseif (-not $answers.ContainsKey($group)) { $answers[$group] = $false }
    }

    foreach ($server in $servers) {
        # Whose Server it is, behind what was measured: "TCP mail...:443" alone does not
        # say that this is the Server of the User's mailbox.
        $whose = Get-ServerForText -Server $server
        $found = @(
            New-ServerDnsFinding -Server $server -Parameters $Parameters

            # Everything below needs a name that resolved. Asking anything else of a host that
            # does not exist produces Findings about a typo, dressed as Findings about a
            # network - which is how a Technician ends up looking at a firewall for an hour.
            if (Test-ServerResolved -Server $server) {
                New-ServerPortFinding -Server $server -Parameters $Parameters -Situation $Situation
                New-ServerPingFinding -Server $server -Parameters $Parameters -Situation $Situation
            }
        )
        $onPremises = "$(Get-DataProperty $server 'Origin')" -like 'OnPremises*'
        $otherUp    = $onPremises -and [bool]$answers["$(Get-DataProperty $server 'For')"]
        foreach ($finding in $found) {
            if (-not $finding) { continue }
            if ($whose) { $finding.Value = (@("$($finding.Value)", $whose) | Where-Object { $_ }) -join ' | ' }
            if ($onPremises -and "$($finding.Severity)" -in 'FAIL', 'WARN') {
                if ($otherUp) {
                    # Not this Server's failing: another of the same mailbox answers.
                    # Information, and no Signal - nothing is lost to the User.
                    $finding.Severity = 'INFO'
                    $finding.Value    = '{0} | {1}' -f $finding.Value, (Get-Text 'Value.Server.Mailbox.OtherAnswers')
                    $finding.Signals  = @()
                }
                else {
                    $finding.Hint = (@("$($finding.Hint)", (Get-Text 'Hint.Server.Mailbox.OutsideVpn')) | Where-Object { $_ }) -join ' | '
                }
            }
            $finding
        }
    }
}

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

    $measured = @((Get-DataCollection $Data 'Servers') | Where-Object { $_ -and (Get-DataProperty $_ 'Latency') })
    if (-not $measured.Count) { return }
    $forMailboxes = [bool](Get-DataProperty (Get-DataProperty $Data 'Mailbox') 'Requested')

    New-Section -Title (Get-Text 'Title.Server.LatencyTestsServers') -Row @(
        foreach ($server in $measured) {
            $row = [ordered]@{}
            if ($forMailboxes) { $row[(Get-Text 'Column.Server.For')] = Get-ServerForText -Server $server }
            $_ = Get-DataProperty $server 'Latency'
            $row[(Get-Text 'Column.Server.Target')]      = Get-DataProperty $_ 'Target'
            $row[(Get-Text 'Column.Server.Sent')]        = Get-DataProperty $_ 'Sent'
            $row[(Get-Text 'Column.Server.Lost')]        = Get-DataProperty $_ 'Lost'
            $row[(Get-Text 'Column.Server.LossPercent')] = Get-DataProperty $_ 'LossPercent'
            $row[(Get-Text 'Column.Server.AverageMs')]   = Get-DataProperty $_ 'AverageMs'
            $row[(Get-Text 'Column.Server.MaximumMs')]   = Get-DataProperty $_ 'MaximumMs'
            [pscustomobject]$row
        }
    )
}

function Test-ServerResolved {
    <#
    .SYNOPSIS
        Whether this Server has a name that can be reached at all.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)]$Server)

    if (Get-DataProperty $Server 'IsIpLiteral') { return $true }
    [bool](Get-DataProperty (Get-DataProperty $Server 'Resolution') 'Resolved')
}

function New-ServerDnsFinding {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Server, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'ServerDnsWarnMs' 500
    $fail = Get-Parameter $Parameters 'ServerDnsFailMs' ([double]::MaxValue)

    # Nothing to resolve, so nothing to report. An address literal is not a DNS success.
    if (Get-DataProperty $Server 'IsIpLiteral') { return }

    $hostName   = Get-DataProperty $Server 'HostName'
    $resolution = Get-DataProperty $Server 'Resolution'

    if (-not (Get-DataProperty $resolution 'Resolved')) {
        return New-Finding -Category Network -Check ((Get-Text 'Check.Server.Dns') -f $hostName) -Severity FAIL `
            -Value (Get-Text 'Value.Server.CannotBeResolved') `
            -Meaning (Get-Text 'Meaning.Server.NotResolved') `
            -Hint (Get-Text 'Hint.Server.WrongNameDNSProblemOr')
    }

    $ms = ConvertTo-Number (Get-DataProperty $resolution 'Milliseconds')
    if ($null -eq $ms) {
        return New-UnavailableFinding -Category Network -Check ((Get-Text 'Check.Server.Dns') -f $hostName) `
            -Hint (Get-Text 'Hint.Shared.NameResolvedNotTimed')
    }

    New-Finding -Category Network -Check ((Get-Text 'Check.Server.Dns') -f $hostName) `
        -Severity (Get-Severity $ms $warn $fail) -Value ('{0:N0} ms' -f $ms) `
        -Meaning (Get-Text 'Meaning.Server.SlowNameResolution') `
        -Hint (Get-Text 'Hint.Server.SlowNameResolutionDelaysEvery')
}

function New-ServerPortFinding {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Server, [hashtable]$Parameters, [AllowNull()]$Situation)

    $latencyWarn = Get-Parameter $Parameters 'ServerTcpWarnMs' 150
    $latencyFail = Get-Parameter $Parameters 'ServerTcpFailMs' ([double]::MaxValue)

    $hostName = Get-DataProperty $Server 'HostName'

    foreach ($port in (Get-DataCollection $Server 'PortResults')) {
        $check     = 'TCP {0}:{1}' -f $hostName, $port.Port
        $successes = ConvertTo-Number $port.Successes
        $attempts  = ConvertTo-Number $port.Attempts
        if ($null -eq $attempts -or $attempts -le 0) { $attempts = $script:ServerTcpAttempts }

        if (-not $successes) {
            # Refused outright: a distinct Finding from a port that answers slowly or
            # intermittently, because the thing to go and look at is different.
            New-Finding -Category Network -Check $check -Severity FAIL `
                -Value ((Get-Text 'Value.Server.NoConnection') -f $attempts) `
                -Meaning (Get-Text 'Meaning.Server.NoConnection') `
                -Hint (Get-Text 'Hint.Server.NothingIsListeningOrA') `
                -Signal 'server-unreachable' -Subject "$hostName"
            continue
        }

        $average = ConvertTo-Number $port.AverageMs

        # Two independent ways for a port to be bad: some attempts failed, or the ones that
        # succeeded were slow. Whichever reads worse decides.
        $missed = 'OK'
        if ($successes -lt $attempts) { $missed = 'WARN' }
        $slow = 'OK'
        if ($null -ne $average) { $slow = Get-Severity $average $latencyWarn $latencyFail }

        $severity = Get-WorstSeverity $missed $slow
        $signal = @()
        if ($severity -in 'WARN', 'FAIL') { $signal = @('server-slow') }
        New-Finding -Category Network -Check $check -Severity $severity `
            -Value ((Get-Text 'Value.Server.ConnectResult') -f $average, $port.MaximumMs, $successes, $attempts) `
            -Meaning (Get-Text 'Meaning.Server.FailedOrSlowConnects') `
            -Hint ((Get-Text 'Hint.Server.FailedOrSlowConnectsFirewall') -f (Get-SituationLinkText -Situation $Situation)) `
            -Signal $signal -Subject "$hostName"
    }
}

function Select-ServerPingText {
    <#
    .SYNOPSIS
        The wording about a slow or lossy ping that fits how the machine is connected and
        where the Server is. Pure.
    .PARAMETER Text
        A wording for each of Wireless, WiredLocal, WiredRemote and Other, and one for
        Unknown, which is said where the connection or the place of the Server is not known.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Server, [AllowNull()]$Situation, [Parameter(Mandatory)][hashtable]$Text)

    # At a cable it depends on where the Server is; where that is not known either,
    # what fits both.
    $wired  = $Text['Unknown']
    $onLink = Get-DataProperty (Get-DataProperty $Server 'Route') 'OnLink'
    if ($onLink -eq $true)  { $wired = $Text['WiredLocal'] }
    if ($onLink -eq $false) { $wired = $Text['WiredRemote'] }

    Select-SituationText -Situation $Situation -Fact Connection -Text @{
        Wired    = $wired
        Wireless = $Text['Wireless']
        Vpn      = $Text['Other']
        Unknown  = $Text['Unknown']
    }
}

function Get-ServerPlaceText {
    <#
    .SYNOPSIS
        Where the machine and the Server are, as a statement of a Meaning. Nothing where
        that is not known. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Server, [AllowNull()]$Situation)

    $adapter = Get-SituationAdapterName -Situation $Situation
    Select-ServerPingText -Server $Server -Situation $Situation -Text @{
        Wireless    = (Get-Text 'Meaning.Server.Place.Wireless') -f $adapter
        WiredLocal  = (Get-Text 'Meaning.Server.Place.WiredLocal') -f $adapter
        WiredRemote = (Get-Text 'Meaning.Server.Place.WiredRemote') -f $adapter
        Other       = (Get-Text 'Meaning.Server.Place.Other') -f $adapter
        Unknown     = ''
    }
}

function Get-ServerPingMeaning {
    <#
    .SYNOPSIS
        What a slow or lossy ping means: where the machine and the Server are, and what
        follows from loss on that way. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Server, [AllowNull()]$Situation)

    # Loss to a target beyond the own network is as a rule the target not answering.
    $lost = Get-Text 'Meaning.Server.Ping'
    $what = Select-ServerPingText -Server $Server -Situation $Situation -Text @{
        Wireless = $lost; WiredLocal = $lost; Other = $lost; Unknown = $lost
        WiredRemote = Get-Text 'Meaning.Server.Ping.PublicTarget'
    }
    @(@((Get-ServerPlaceText -Server $Server -Situation $Situation), $what) | Where-Object { $_ }) -join ' | '
}

function Get-ServerPingHint {
    <#
    .SYNOPSIS
        What to do about a slow or lossy ping, by how the machine is connected and by
        where the Server is. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Server, [AllowNull()]$Situation)

    Select-ServerPingText -Server $Server -Situation $Situation -Text @{
        Wireless    = Get-Text 'Hint.Server.Ping.Wireless'
        WiredLocal  = Get-Text 'Hint.Server.Ping.WiredLocal'
        WiredRemote = Get-Text 'Hint.Server.Ping.WiredRemote'
        Other       = Get-Text 'Hint.Server.Ping.Other'
        Unknown     = Get-Text 'Hint.Server.Ping.Unknown'
    }
}

function New-ServerPingFinding {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Server, [hashtable]$Parameters, [AllowNull()]$Situation)

    $lossWarn = Get-Parameter $Parameters 'ServerLossWarnPercent'  0
    $lossFail = Get-Parameter $Parameters 'ServerLossFailPercent'  2
    # One ping of twenty is five percent, and one ping goes missing on a healthy line:
    # a FAIL takes more than that.
    $lostFail = ConvertTo-Number (Get-Parameter $Parameters 'ServerLossFailMinimumLost' 2)
    $msWarn   = Get-Parameter $Parameters 'ServerLatencyWarnMs'   50
    $msFail   = Get-Parameter $Parameters 'ServerLatencyFailMs'   ([double]::MaxValue)

    $hostName = Get-DataProperty $Server 'HostName'
    $check    = 'Ping {0}' -f $hostName
    $latency  = Get-DataProperty $Server 'Latency'
    if (-not $latency) { return }

    $loss     = ConvertTo-Number (Get-DataProperty $latency 'LossPercent')
    $average  = ConvertTo-Number (Get-DataProperty $latency 'AverageMs')
    $maximum  = ConvertTo-Number (Get-DataProperty $latency 'MaximumMs')

    if ($null -eq $average) {
        # No reply at all. Whether that matters depends on whether anything else got
        # through: plenty of Customer networks block ICMP to servers that work perfectly.
        $ports     = Get-DataCollection $Server 'PortResults'
        $anyPortOk = @($ports | Where-Object { (ConvertTo-Number $_.Successes) -gt 0 }).Count -gt 0

        if ($ports.Count -and $anyPortOk) {
            return New-Finding -Category Network -Check $check -Severity INFO `
                -Value (Get-Text 'Value.Server.NoIcmpReply')
        }
        return New-Finding -Category Network -Check $check -Severity FAIL -Value (Get-Text 'Value.Server.NoReply') `
            -Meaning (Get-Text 'Meaning.Server.NoReply') `
            -Hint (Get-Text 'Hint.Server.HostUnreachableOrPingBlocked') `
            -Signal 'server-unreachable' -Subject "$hostName"
    }

    $lossGrade = Get-Severity $loss $lossWarn $lossFail
    $lost = ConvertTo-Number (Get-DataProperty $latency 'Lost')
    if ($lossGrade -eq 'FAIL' -and $null -ne $lost -and $lost -lt $lostFail) { $lossGrade = 'WARN' }
    $severity = Get-WorstSeverity $lossGrade (Get-Severity $average $msWarn $msFail)

    # Slow or lossy is this Server's; loss to a Server next door is also something about
    # the network this machine is on.
    $signal = @()
    if ($severity -in 'WARN', 'FAIL') { $signal += 'server-slow' }
    if ($lost -gt 0 -and (Get-DataProperty (Get-DataProperty $Server 'Route') 'OnLink') -eq $true) { $signal += 'loss-on-own-network' }

    New-Finding -Category Network -Check $check -Severity $severity `
        -Value ((Get-Text 'Value.Server.PingResult') -f $average, $maximum, $loss) `
        -Meaning (Get-ServerPingMeaning -Server $Server -Situation $Situation) `
        -Hint (Get-ServerPingHint -Server $Server -Situation $Situation) `
        -Signal $signal -Subject "$hostName"
}