Private/Kinds/NetworkDisruption.ps1

# The NetworkDisruption Kind: what Windows itself logged about the network going away.
#
# A program that runs from a share dies when the share does, and the crash it leaves in the
# Application log says nothing about why. Windows did write down why, elsewhere: the SMB
# client records every connection it lost and every Server it could not reach, the adapter
# records its link going down, and the power manager records the standby during which a
# dock or the Wi-Fi was switched off. This Kind reads those records for the period and lays
# them out as one list, so a crash can be held against it.
#
# Which events count is not written here. A Check Definition's Scans parameter names them,
# one entry per class of event, and the Local Definitions carry the entries that were
# verified against the event manifests (docs/research/network-disruption-events.md):
#
# { "Log": "Microsoft-Windows-SmbClient/Connectivity", the log to read
# "Provider": [ "Microsoft-Windows-SMBClient" ], optional; any provider when absent
# "Id": [ 30805, 30807 ], the event ids
# "Kind": "smb-disconnect", what a later Judge calls them
# "ServerIndex": 4, optional; payload position of the Server
# "ServerIndexByVersion": { "0": 3 } } optional; where an older version of
# the event keeps it instead
#
# A scan's providers and its ids match as a cross product: an event belongs to the scan when
# its provider is any of the scan's and its id is any of the scan's. Two providers with two
# ids are therefore four events, not two. A scan that names several ids names one provider,
# and an id that means the same thing from two providers is two scans - Tcpip 4202 and
# e1dexpress 27 are both a link going down, and Tcpip 27 is something else. A test over the
# Local Definitions holds them to that.
#
# The payload is read by position for the reason Private/Kinds/Stability.ps1 gives: the
# field names are localised and the positions are not. A position is per event version,
# because Microsoft has moved the Server's name within an event between versions.
#
# The events of a resume or of a link coming back are not disruptions, and are gathered all
# the same: the list is a timeline, and a disconnect reads differently beside the standby
# that caused it. The ContextKinds parameter names the Kind tokens that are shown but not
# counted.
#
# What the Gatherer returns:
# Days how many days back it read, from the Check Definition
# Since the start of that period: the time the events were filtered from
# Disruptions { Time, Provider, Id, Server, Kind, Message, ServerExpected }, newest
# first. Kind is the scan's token; Server is the host only, or $null when
# the event names none; ServerExpected says whether it should have
# UnreadableLogs the logs this Run's rights were refused, by name
# ScannedLogs every log that was asked, by name, the refused ones included. A log this
# machine does not have was not asked and is not in it
# LogCoverage { Log, Oldest }, one per log that was asked and could be read: Oldest is
# the time of the oldest record the log still holds, or $null when the log
# is empty or would not say. A log overwrites its oldest records, so one
# that reaches back a week says nothing about the three weeks before
#
# Data from before Since, ScannedLogs and LogCoverage were gathered has none of the three,
# and is judged all the same.

# The SMBClient channels refuse a Run without admin rights.
$script:NetworkDisruptionNeedsAdmin = $true

$script:NetworkDisruptionDefaultDays = 30

# How far apart two events of one Server may be and still report the same lost connection.
# The events of one loss carry the same second; fifteen leaves room for a slow log and is
# well short of the forty seconds a drop and the next one were apart on a real machine.
$script:NetworkDisruptionEpisodeSeconds = 15

# The tag these events carry in events.csv.
$script:NetworkDisruptionEventTag = 'Network disruption'

# How many of the newest events the Section lists. events.csv holds all of them.
$script:NetworkDisruptionSectionRows = 50

# How many event ids one query may name. Get-WinEvent -FilterHashtable answers "no events
# matched" to a query naming 23 ids or more, whatever the log holds: verified on Windows 11
# with a query that found its events at 22 ids and none at 23. No error says so, which is
# the worst way for a list in a Check Definition to be too long.
$script:NetworkDisruptionMaxIdsPerQuery = 20

function ConvertTo-NetworkDisruptionScan {
    <#
    .SYNOPSIS
        Reads one entry of the Scans parameter into which events to read and what they are.
        Pure, and reaches nothing.
    .DESCRIPTION
        An entry arrives as ConvertFrom-Json made it or as a hashtable, and a hand-edited
        Definition writes a single id as a number rather than a list. Both halves of the
        Kind read entries through here.
 
        A broken entry is not dropped: it comes back with a Problem, so the Report can name
        a mistyped Definition instead of reporting a quiet network.
    #>

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

    $log   = "$(Get-DataProperty $Entry 'Log')".Trim()
    $token = "$(Get-DataProperty $Entry 'Kind')".Trim()

    $providers = @(@(Get-DataProperty $Entry 'Provider') | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
    $ids = @(@(Get-DataProperty $Entry 'Id') | ForEach-Object { ConvertTo-Number $_ } |
        Where-Object { $null -ne $_ } | ForEach-Object { [int]$_ })

    $serverIndex = ConvertTo-Number (Get-DataProperty $Entry 'ServerIndex')
    if ($null -ne $serverIndex) { $serverIndex = [int]$serverIndex }

    # Keyed by the version as text: JSON has no other kind of key, and an event's Version
    # is a byte that would not find an [int] key in a hashtable.
    $byVersion = @{}
    $versions  = Get-DataProperty $Entry 'ServerIndexByVersion'
    if ($versions -is [hashtable]) {
        foreach ($key in @($versions.Keys)) {
            $index = ConvertTo-Number $versions[$key]
            if ($null -ne $index) { $byVersion["$key"] = [int]$index }
        }
    }
    elseif ($null -ne $versions) {
        foreach ($property in $versions.PSObject.Properties) {
            $index = ConvertTo-Number $property.Value
            if ($null -ne $index) { $byVersion["$($property.Name)"] = [int]$index }
        }
    }

    $problem = $null
    if     (-not $log)       { $problem = 'NoLog' }
    elseif (-not $ids.Count) { $problem = 'NoId' }
    elseif (-not $token)     { $problem = 'NoKind' }

    [pscustomobject]@{
        Log                  = $log
        Provider             = $providers
        Id                   = $ids
        Kind                 = $token
        ServerIndex          = $serverIndex
        ServerIndexByVersion = $byVersion
        Problem              = $problem
    }
}

function Get-NetworkDisruptionScan {
    <#
    .SYNOPSIS
        Every entry of a Check Definition's Scans parameter, read. Pure.
    .DESCRIPTION
        No entries when the Definition names none. The events that count are data
        (ADR-0001) and their verified set is in the Local Definitions; a second copy here
        would be the one nobody remembers to update when a Windows build moves a field.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][hashtable]$Parameters)

    foreach ($entry in @(Get-Parameter $Parameters 'Scans' @())) {
        if ($null -ne $entry) { ConvertTo-NetworkDisruptionScan -Entry $entry }
    }
}

function Get-NetworkDisruptionQuery {
    <#
    .SYNOPSIS
        The event log queries a set of scans amounts to: one per log, split where the ids
        outnumber what one query may name. Pure.
    .DESCRIPTION
        One query per log rather than one per scan. Twenty scans are a handful of logs, and
        every query against the System log reads an index a Technician waits for.
 
        A query therefore names the providers and ids of several scans, and can return an
        event that belongs to none of them - one scan's provider with another's id. Each
        event is matched back to its scan by Select-NetworkDisruptionScan, which drops those.
    #>

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

    $logs = [ordered]@{}
    foreach ($entry in @($Scan | Where-Object { $_ -and -not $_.Problem })) {
        $key = $entry.Log.ToLowerInvariant()
        if (-not $logs.Contains($key)) { $logs[$key] = New-Object System.Collections.Generic.List[psobject] }
        $logs[$key].Add($entry)
    }

    foreach ($key in $logs.Keys) {
        $entries = @($logs[$key])

        # A scan that names no provider wants the id from whoever logged it, so the query
        # for its log cannot filter by provider at all.
        $providers = @()
        if (-not @($entries | Where-Object { -not @($_.Provider).Count }).Count) {
            $providers = @($entries | ForEach-Object { $_.Provider } | Sort-Object -Unique)
        }
        $ids = @($entries | ForEach-Object { $_.Id } | Sort-Object -Unique)

        for ($start = 0; $start -lt $ids.Count; $start += $script:NetworkDisruptionMaxIdsPerQuery) {
            $end = [math]::Min($start + $script:NetworkDisruptionMaxIdsPerQuery, $ids.Count) - 1
            [pscustomobject]@{
                Log      = $entries[0].Log
                Provider = $providers
                Id       = @($ids[$start..$end])
            }
        }
    }
}

function Get-NetworkDisruptionRegistration {
    <#
    .SYNOPSIS
        The event logs and providers this machine has, by name. $null when it will not say.
    .DESCRIPTION
        Asked once, before any query, because a query is no way to find out. Asked for a
        provider the machine never registered - an Intel adapter's driver on a Realtek
        board - Get-WinEvent passes it over when it is one of several and throws "the
        parameter is incorrect" when it is the only one, whatever it is told about errors.
        That line in a transcript reads like a broken tool, for a machine that merely has
        a different network card.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param()

    try {
        $session   = [System.Diagnostics.Eventing.Reader.EventLogSession]::GlobalSession
        $providers = New-Object 'System.Collections.Generic.HashSet[string]' (, [StringComparer]::OrdinalIgnoreCase)
        $logs      = New-Object 'System.Collections.Generic.HashSet[string]' (, [StringComparer]::OrdinalIgnoreCase)
        foreach ($name in $session.GetProviderNames()) { $null = $providers.Add($name) }
        foreach ($name in $session.GetLogNames())      { $null = $logs.Add($name) }
        @{ Provider = $providers; Log = $logs }
    }
    catch { $null }
}

function Select-NetworkDisruptionQuery {
    <#
    .SYNOPSIS
        The queries this machine can answer, naming only providers it has. Pure.
    .DESCRIPTION
        A log or a provider the machine does not have is an expected miss, not a gap: there
        is nothing in it to have been read. A log the Run may not read is still asked, since
        a refusal is found by asking. Without a registration every query passes unchanged.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Query,
        [AllowNull()][hashtable]$Registration
    )

    foreach ($entry in @($Query | Where-Object { $_ })) {
        if (-not $Registration) { $entry; continue }
        if (-not $Registration.Log.Contains($entry.Log)) { continue }
        if (-not @($entry.Provider).Count) { $entry; continue }

        $registered = @($entry.Provider | Where-Object { $Registration.Provider.Contains($_) })
        if (-not $registered.Count) { continue }
        [pscustomobject]@{ Log = $entry.Log; Provider = $registered; Id = $entry.Id }
    }
}

function Select-NetworkDisruptionScan {
    <#
    .SYNOPSIS
        The scan an event belongs to, or nothing when no scan asked for it. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$LogEntry,
        [Parameter(Mandatory)][string]$Log,
        [AllowNull()][AllowEmptyCollection()]$Scan
    )

    foreach ($entry in @($Scan | Where-Object { $_ -and -not $_.Problem })) {
        if ($entry.Log -ne $Log) { continue }
        if (@($entry.Id) -notcontains [int]$LogEntry.Id) { continue }
        if (@($entry.Provider).Count -and @($entry.Provider) -notcontains "$($LogEntry.ProviderName)") { continue }
        return $entry
    }
}

function ConvertTo-NetworkDisruptionServer {
    <#
    .SYNOPSIS
        The host a payload field names, or $null when it names none. Pure.
    .DESCRIPTION
        The SMB client writes a Server as its name, a share as \Server\Share and Offline
        Files a path as \\Server\Share\Folder, all in a field the manifest calls ServerName,
        ShareName or Path. What a Technician and a later Check want from each is the host.
 
        Anything that does not look like a host name or an address is no Server: a wrong
        position in a Definition reads a length, a status code or a byte array, and a
        Report naming "System.Byte[]" as a Server would be believed.
 
        A link-local IPv6 address is written with the interface it was reached through,
        fe80::1%4. The Server is the address without it: the number is this machine's name
        for one of its own adapters, and the same Server reached through the dock and
        through Wi-Fi would otherwise be counted as two.
    #>

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

    $text = "$Value".Trim().TrimStart('\')
    $cut  = $text.IndexOf('\')
    if ($cut -ge 0) { $text = $text.Substring(0, $cut) }

    # Only behind an address: a percent sign after anything else is no scope.
    if ($text -match '^([0-9A-Fa-f.]*:[0-9A-Fa-f:.]*)%[A-Za-z0-9._\-]+$') { $text = $Matches[1] }

    # A bare number is a length or a status, not a host. A name has a letter in it, and an
    # address a dot or a colon.
    if ($text -notmatch '^[A-Za-z0-9][A-Za-z0-9._:\-]*$') { return $null }
    if ($text -match '^\d+$') { return $null }

    # A drive letter is no Server. The SMB client does log "the server name C: cannot be
    # resolved" when something hands it a local path, and a Report then counted C: among
    # the file servers that dropped.
    if ($text -match '^[A-Za-z]:$') { return $null }
    $text
}

function ConvertTo-NetworkDisruptionRow {
    <#
    .SYNOPSIS
        One event as the row the Judge, the Section and a later Check read. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$LogEntry,
        [Parameter(Mandatory)]$Scan
    )

    $index   = $Scan.ServerIndex
    $version = "$($LogEntry.Version)"
    if ($version -and $Scan.ServerIndexByVersion.ContainsKey($version)) {
        $index = $Scan.ServerIndexByVersion[$version]
    }

    $server = $null
    if ($null -ne $index) {
        $server = ConvertTo-NetworkDisruptionServer -Value (Get-EventPayload -LogEntry $LogEntry -Index $index)
    }

    $row = ConvertTo-EventRow -LogEntry $LogEntry
    [pscustomobject]@{
        Time     = $row.Time
        Provider = $row.Provider
        Id       = $row.Id
        Server   = $server
        Kind     = $Scan.Kind
        Message  = $row.Message
        # Whether this event should have named a Server. One that should have and named
        # nothing usable is neither a Server that dropped nor the machine losing its
        # network, and the Judge counts it as neither.
        ServerExpected = ($null -ne $index)
    }
}

function Group-NetworkDisruptionEpisode {
    <#
    .SYNOPSIS
        The times a connection to a Server was lost, from the events that reported it. Pure.
    .DESCRIPTION
        One lost connection is several events: the SMB client logs the connection, the
        session on it, and each share that was open, all within the same second. Seen on a
        real notebook: 167 events for 31 lost connections. Counting events told a
        Technician a Server had dropped 86 times when it had dropped sixteen.
 
        Events of one Server no further apart than the gap are one episode. A row whose
        time cannot be read is an episode of its own: it happened, and nothing says when.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Disruption,
        [double]$GapSeconds = $script:NetworkDisruptionEpisodeSeconds
    )

    $byServer = @($Disruption | Where-Object { $_ }) | Group-Object { "$(Get-DataProperty $_ 'Server')".ToUpperInvariant() }
    foreach ($group in @($byServer)) {
        $timed   = [System.Collections.Generic.List[psobject]]::new()
        $untimed = 0
        foreach ($row in $group.Group) {
            $time = ConvertTo-StabilityTime (Get-DataProperty $row 'Time')
            if ($null -eq $time) { $untimed++ } else { $timed.Add([pscustomobject]@{ Time = $time }) }
        }

        $episodes = $untimed
        $last     = $null
        foreach ($entry in @($timed | Sort-Object Time)) {
            if ($null -eq $last -or ($entry.Time - $last).TotalSeconds -gt $GapSeconds) { $episodes++ }
            $last = $entry.Time
        }

        [pscustomobject]@{ Server = $group.Name; Episodes = $episodes; Events = @($group.Group).Count }
    }
}

function Get-NetworkDisruptionLogOldest {
    <#
    .SYNOPSIS
        The time of the oldest record a log still holds, or $null when it is empty or will
        not say. Decides nothing.
    .DESCRIPTION
        Asked not to stop, and the error collected instead of caught, for the reason
        Get-StabilityEvent gives: an empty log answers "no events" as an error, and that
        must not leave a TerminatingError line in a transcript.
    #>

    [CmdletBinding()]
    [OutputType([datetime])]
    param([Parameter(Mandatory)][string]$Log)

    $problems = @()
    $oldest = @(Get-WinEvent -LogName $Log -Oldest -MaxEvents 1 -ErrorAction SilentlyContinue -ErrorVariable problems)
    if (-not $oldest.Count -or $null -eq $oldest[0].TimeCreated) { return $null }
    [datetime]$oldest[0].TimeCreated
}

function Get-NetworkDisruptionData {
    <#
    .SYNOPSIS
        Reads the events a Check Definition names and returns them. Judges none of them.
    .DESCRIPTION
        A log this Run's rights could not open is recorded by name, because the SMBClient
        channels are closed to a Run without admin rights and a list that is empty for that
        reason is not a quiet network.
    #>

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

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

    $scans      = @(Get-NetworkDisruptionScan -Parameters $Parameters)
    $unreadable = @{}
    # Not List[object]: @($x) throws on one of those. See Public/Invoke-Gutcheck.ps1.
    $rows       = New-Object System.Collections.Generic.List[psobject]

    $queries = @(Select-NetworkDisruptionQuery -Query @(Get-NetworkDisruptionQuery -Scan $scans) `
                                               -Registration (Get-NetworkDisruptionRegistration))
    # Not List[object], as above.
    $scanned = New-Object System.Collections.Generic.List[psobject]
    foreach ($query in $queries) {
        if (@($scanned | Where-Object { $_ -eq $query.Log }).Count -eq 0) { $scanned.Add($query.Log) }

        # Get-StabilityEvent, because it already knows the three ways an event log says
        # nothing: no events, no such provider, and no permission. Only the last is a gap.
        $found = @(Get-StabilityEvent -Log $query.Log -Provider $query.Provider -Id $query.Id `
                                      -Since $since -Unreadable $unreadable)
        foreach ($entry in $found) {
            $scan = Select-NetworkDisruptionScan -LogEntry $entry -Log $query.Log -Scan $scans
            if ($scan) { $rows.Add((ConvertTo-NetworkDisruptionRow -LogEntry $entry -Scan $scan)) }
        }
    }

    # Materialised before it is sorted, for the reason Get-StabilityData gives.
    $unreadableLogs = @($unreadable.Keys)

    # How far back each log reaches. Not asked of a log that was refused: the answer is
    # the same refusal.
    $coverage = @(foreach ($log in $scanned) {
        if ($unreadable.ContainsKey($log)) { continue }
        [pscustomobject]@{
            Log    = $log
            Oldest = Get-NetworkDisruptionLogOldest -Log $log
        }
    })

    [pscustomobject]@{
        PSTypeName     = 'Gutcheck.Data.NetworkDisruption'
        Days           = $days
        Since          = $since
        Disruptions    = @($rows | Sort-Object Time -Descending)
        UnreadableLogs = @($unreadableLogs | Sort-Object)
        ScannedLogs    = [string[]]@($scanned)
        LogCoverage    = $coverage
    }
}

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

    $scans  = @(Get-NetworkDisruptionScan -Parameters $Parameters)
    $broken = @($scans | Where-Object { $_.Problem })

    if ($broken.Count -eq $scans.Count) {
        # Nothing was asked for, so nothing was read. Saying "none" here would be the
        # Report calling a network quiet because a Definition was empty or mistyped.
        return New-Finding -Category Gutcheck -Check (Get-Text 'Check.NetworkDisruption.Definition') -Severity INFO `
            -Value (Get-Text 'Value.NetworkDisruption.NoScans') `
            -Hint (Get-Text 'Hint.NetworkDisruption.FixDefinition')
    }
    if ($broken.Count) {
        New-Finding -Category Gutcheck -Check (Get-Text 'Check.NetworkDisruption.Definition') -Severity INFO `
            -Value ((Get-Text 'Value.NetworkDisruption.BrokenScans') -f $broken.Count, $scans.Count) `
            -Hint (Get-Text 'Hint.NetworkDisruption.FixDefinition')
    }

    # The Kind tokens that are part of the timeline without being a disruption: a resume,
    # a link that came back, a session that was re-established.
    $context = @(@(Get-Parameter $Parameters 'ContextKinds' @()) | ForEach-Object { "$_" })
    $counted = @((Get-DataCollection $Data 'Disruptions') | Where-Object { $context -notcontains "$(Get-DataProperty $_ 'Kind')" })

    # Which refused log leaves which Finding without its evidence. The SMBClient channels
    # are where a Server is named, so a Run that was refused them can still say what the
    # adapter did, and must not say that no Server dropped.
    $unreadable = Get-DataCollection $Data 'UnreadableLogs'
    $counting   = @($scans | Where-Object { -not $_.Problem -and $context -notcontains $_.Kind })
    $naming     = @($counting | Where-Object { $null -ne $_.ServerIndex -or $_.ServerIndexByVersion.Count })
    $serverLogs = @($naming | ForEach-Object { $_.Log })
    $localLogs  = @($counting | Where-Object { $naming -notcontains $_ } | ForEach-Object { $_.Log })

    New-NetworkDisruptionServerFinding -Data $Data -Parameters $Parameters `
        -Disruption @($counted | Where-Object { Get-DataProperty $_ 'Server' }) `
        -Unread @($unreadable | Where-Object { $serverLogs -contains $_ })
    # Not among them: an event that should have named a Server and named nothing usable.
    # Rows gathered before that was recorded carry no such mark and count as they did.
    New-NetworkDisruptionLocalFinding -Data $Data -Parameters $Parameters `
        -Disruption @($counted | Where-Object { -not (Get-DataProperty $_ 'Server') -and -not (Get-DataProperty $_ 'ServerExpected') }) `
        -Unread @($unreadable | Where-Object { $localLogs -contains $_ })

    New-UnreadableLogFinding -Data $Data -Parameters $Parameters
}

function New-NetworkDisruptionQuietFinding {
    <#
    .SYNOPSIS
        The Finding for a list with nothing in it: quiet, or not read.
    .DESCRIPTION
        An empty list is only good news when every log behind it was read. With one
        refused, "none" is what a Run without admin rights reports about every network,
        however bad.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Data,
        [Parameter(Mandatory)][string]$Check,
        [AllowNull()][AllowEmptyCollection()]$Unread
    )

    $unreadable = @($Unread | Where-Object { $_ })
    if ($unreadable.Count) {
        return New-Finding -Category Network -Check $Check -Severity INFO `
            -Value ((Get-Text 'Value.NetworkDisruption.NotAssessable') -f ($unreadable -join ', ')) `
            -Hint (Get-Text 'Hint.NetworkDisruption.RerunWithAdmin')
    }

    New-Finding -Category Network -Check $Check -Severity OK `
        -Value ((Get-Text 'Value.NetworkDisruption.NoneInPeriod') -f (Get-NetworkDisruptionDays -Data $Data))
}

function Get-NetworkDisruptionDays {
    [CmdletBinding()]
    param([AllowNull()]$Data)

    $days = Get-DataProperty $Data 'Days'
    if ($null -eq $days) { $days = $script:NetworkDisruptionDefaultDays }
    $days
}

function New-NetworkDisruptionServerFinding {
    <#
    .SYNOPSIS
        How often the connection to a Server was lost or refused, per Server.
    .DESCRIPTION
        Judged on the Server with the most, not on the total: ten Servers that each dropped
        once are a laptop that was carried home, and one Server that dropped ten times is a
        Server, a switch port or a cable.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Data, [hashtable]$Parameters,
        [AllowNull()][AllowEmptyCollection()]$Disruption,
        [AllowNull()][AllowEmptyCollection()]$Unread
    )

    $warn = Get-Parameter $Parameters 'ServerDisruptionWarnAbove' 5
    $fail = Get-Parameter $Parameters 'ServerDisruptionFailAbove' 20

    $check = Get-Text 'Check.NetworkDisruption.Servers'
    $rows  = @($Disruption | Where-Object { $_ })
    if (-not $rows.Count) { return New-NetworkDisruptionQuietFinding -Data $Data -Check $check -Unread $Unread }

    # Counted as the times a connection was lost, not as the events that reported it: one
    # lost connection is logged as five or more. See Group-NetworkDisruptionEpisode.
    $gap     = ConvertTo-Number (Get-Parameter $Parameters 'EpisodeSeconds' $script:NetworkDisruptionEpisodeSeconds)
    if ($null -eq $gap -or $gap -lt 0) { $gap = $script:NetworkDisruptionEpisodeSeconds }
    $servers = @(Group-NetworkDisruptionEpisode -Disruption $rows -GapSeconds $gap |
        Sort-Object @{ Expression = 'Episodes'; Descending = $true }, Server)
    $named = ($servers | Select-Object -First 5 | ForEach-Object { '{0} {1}x' -f $_.Server, $_.Episodes }) -join ', '
    $total = ($servers | Measure-Object -Property Episodes -Sum).Sum

    # Even one is worth a line: a program on a share does not survive it.
    $severity = Get-Severity $servers[0].Episodes $warn $fail
    if ($severity -eq 'OK') { $severity = 'INFO' }

    New-Finding -Category Network -Check $check -Severity $severity `
        -Value ((Get-Text 'Value.NetworkDisruption.Episodes') -f $total, (Get-NetworkDisruptionDays -Data $Data), $rows.Count, $named) `
        -Hint (Get-Text 'Hint.NetworkDisruption.Servers')
}

function New-NetworkDisruptionLocalFinding {
    <#
    .SYNOPSIS
        How often the network went away at the machine itself: the events that name no Server.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Data, [hashtable]$Parameters,
        [AllowNull()][AllowEmptyCollection()]$Disruption,
        [AllowNull()][AllowEmptyCollection()]$Unread
    )

    # A notebook loses its network every time it leaves its dock, so only a count no
    # working day explains is a warning, and none is a failure unless a Definition says so.
    $warn = Get-Parameter $Parameters 'LocalDisruptionWarnAbove' 30
    $fail = Get-Parameter $Parameters 'LocalDisruptionFailAbove' ([double]::MaxValue)

    $check = Get-Text 'Check.NetworkDisruption.Local'
    $rows  = @($Disruption | Where-Object { $_ })
    if (-not $rows.Count) { return New-NetworkDisruptionQuietFinding -Data $Data -Check $check -Unread $Unread }

    $byKind = ($rows | Group-Object { "$(Get-DataProperty $_ 'Kind')" } |
        Sort-Object @{ Expression = 'Count'; Descending = $true }, Name |
        ForEach-Object { '{0} {1}x' -f (Get-NetworkDisruptionKindText -Kind $_.Name), $_.Count }) -join ', '

    $severity = Get-Severity $rows.Count $warn $fail
    if ($severity -eq 'OK') { $severity = 'INFO' }

    New-Finding -Category Network -Check $check -Severity $severity `
        -Value ((Get-Text 'Value.NetworkDisruption.Count') -f $rows.Count, (Get-NetworkDisruptionDays -Data $Data), $byKind) `
        -Hint (Get-Text 'Hint.NetworkDisruption.Local')
}

function Get-NetworkDisruptionKindText {
    <#
    .SYNOPSIS
        What the Report calls a Kind token. Pure.
    .DESCRIPTION
        The token is data: it is what a Check Definition writes, what a later Check
        compares and what the gathered rows carry. A Technician reads German, so a Finding
        and a Section show the name written for the token in Private/Text.ps1.
 
        A literal Get-Text per token and no computed key, so that the tests can hold every
        key to being defined and to being used.
    #>

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

    switch ($Kind) {
        'smb-connect-failed'      { return (Get-Text 'Value.NetworkDisruption.Kind.SmbConnectFailed') }
        'smb-disconnect'          { return (Get-Text 'Value.NetworkDisruption.Kind.SmbDisconnect') }
        'smb-timeout'             { return (Get-Text 'Value.NetworkDisruption.Kind.SmbTimeout') }
        'smb-handle-lost'         { return (Get-Text 'Value.NetworkDisruption.Kind.SmbHandleLost') }
        'smb-auth-failed'         { return (Get-Text 'Value.NetworkDisruption.Kind.SmbAuthFailed') }
        'smb-share-refused'       { return (Get-Text 'Value.NetworkDisruption.Kind.SmbShareRefused') }
        'smb-reconnect'           { return (Get-Text 'Value.NetworkDisruption.Kind.SmbReconnect') }
        'network-disconnected'    { return (Get-Text 'Value.NetworkDisruption.Kind.NetworkDisconnected') }
        'adapter-reset'           { return (Get-Text 'Value.NetworkDisruption.Kind.AdapterReset') }
        'link-down'               { return (Get-Text 'Value.NetworkDisruption.Kind.LinkDown') }
        'link-up'                 { return (Get-Text 'Value.NetworkDisruption.Kind.LinkUp') }
        'wlan-disconnect'         { return (Get-Text 'Value.NetworkDisruption.Kind.WlanDisconnect') }
        'wlan-connect-failed'     { return (Get-Text 'Value.NetworkDisruption.Kind.WlanConnectFailed') }
        'standby'                 { return (Get-Text 'Value.NetworkDisruption.Kind.Standby') }
        'resume'                  { return (Get-Text 'Value.NetworkDisruption.Kind.Resume') }
        'offline-files'           { return (Get-Text 'Value.NetworkDisruption.Kind.OfflineFiles') }
        'offline-files-online'    { return (Get-Text 'Value.NetworkDisruption.Kind.OfflineFilesOnline') }
        'offline-files-slow-link' { return (Get-Text 'Value.NetworkDisruption.Kind.OfflineFilesSlowLink') }
    }
    # A token a Check Definition invented is shown as it came rather than hidden.
    "$Kind"
}

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

    $rows = @((Get-DataCollection $Data 'Disruptions') | Sort-Object { Get-DataProperty $_ 'Time' } -Descending)

    # The headings are German like the rest of the Report, so the rows are built with the
    # names the tables are to show.
    $count    = Get-Text 'Column.NetworkDisruption.Count'
    $server   = Get-Text 'Column.NetworkDisruption.Server'
    $kind     = Get-Text 'Column.NetworkDisruption.Kind'
    $first    = Get-Text 'Column.NetworkDisruption.First'
    $last     = Get-Text 'Column.NetworkDisruption.Last'
    $time     = Get-Text 'Column.NetworkDisruption.Time'
    $provider = Get-Text 'Column.NetworkDisruption.Provider'
    $id       = Get-Text 'Column.NetworkDisruption.Id'
    $message  = Get-Text 'Column.NetworkDisruption.Message'

    # Sorted on the gathered values and named afterwards: the most frequent first.
    New-Section -Title (Get-Text 'Title.NetworkDisruption.Summary') -Row @(
        $rows | Group-Object { '{0}|{1}' -f "$(Get-DataProperty $_ 'Server')".ToUpperInvariant(), (Get-DataProperty $_ 'Kind') } |
            ForEach-Object {
                [pscustomobject]@{
                    Count  = $_.Count
                    Server = "$(Get-DataProperty $_.Group[0] 'Server')".ToUpperInvariant()
                    Kind   = "$(Get-DataProperty $_.Group[0] 'Kind')"
                    First  = Get-DataProperty $_.Group[-1] 'Time'
                    Last   = Get-DataProperty $_.Group[0] 'Time'
                }
            } | Sort-Object @{ Expression = 'Count'; Descending = $true }, Server, Kind |
            ForEach-Object {
                $row = [ordered]@{}
                $row[$count]  = $_.Count
                $row[$server] = $_.Server
                $row[$kind]   = Get-NetworkDisruptionKindText -Kind $_.Kind
                $row[$first]  = $_.First
                $row[$last]   = $_.Last
                [pscustomobject]$row
            }
    )

    New-Section -Title (Get-Text 'Title.NetworkDisruption.Events') -Row @(
        $rows | Select-Object -First $script:NetworkDisruptionSectionRows | ForEach-Object {
            $row = [ordered]@{}
            $row[$time]     = Get-DataProperty $_ 'Time'
            $row[$kind]     = Get-NetworkDisruptionKindText -Kind "$(Get-DataProperty $_ 'Kind')"
            $row[$server]   = Get-DataProperty $_ 'Server'
            $row[$provider] = Get-DataProperty $_ 'Provider'
            $row[$id]       = Get-DataProperty $_ 'Id'
            $row[$message]  = Get-DataProperty $_ 'Message'
            [pscustomobject]$row
        }
    )
}
function ConvertTo-NetworkDisruptionEvent {
    <#
    .SYNOPSIS
        The events this Check gathered, for the Report's events.csv.
    .DESCRIPTION
        Built from the rows rather than carried beside them: the same events twice in the
        gathered data would be twice the data for a later Check to be handed, and two lists
        that have to agree.
    #>

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

    foreach ($row in (Get-DataCollection $Data 'Disruptions')) {
        [pscustomobject]@{
            Time     = Get-DataProperty $row 'Time'
            Category = $script:NetworkDisruptionEventTag
            Provider = Get-DataProperty $row 'Provider'
            Id       = Get-DataProperty $row 'Id'
            Message  = Get-DataProperty $row 'Message'
        }
    }
}