Private/Data.ps1

# Reading gathered data.
#
# Judges are handed whatever a Gatherer produced on a real machine, and a real machine
# produces absent counters, unreadable devices and empty collections. These helpers let a
# Judge ask for a value without first asking whether it is there, so a Judge's logic
# stays about thresholds rather than about null.

function Get-DataProperty {
    <#
    .SYNOPSIS
        Reads a property off gathered data, returning $null when it is not there.
    .DESCRIPTION
        Gathered data reaches a Judge either as the object a Gatherer built or, for the
        Elevated Part, as its CliXML round trip. Neither is guaranteed to carry every
        property: a counter class that would not load leaves its properties absent
        altogether rather than null.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$InputObject,
        [Parameter(Mandatory)][string]$Name
    )
    if ($null -eq $InputObject) { return $null }
    if ($InputObject -is [hashtable]) {
        if ($InputObject.ContainsKey($Name)) { return $InputObject[$Name] }
        return $null
    }
    $property = $InputObject.PSObject.Properties[$Name]
    if ($property) { return $property.Value }
    $null
}

function ConvertTo-Number {
    <#
    .SYNOPSIS
        Returns the value as a double, or $null when it is not a number.
    .DESCRIPTION
        Zero is a number and must survive; an empty string, a null and a counter that
        reported text are not, and a Judge must be able to tell those apart from a
        genuine reading of zero.
    #>

    [CmdletBinding()]
    [OutputType([double])]
    param([AllowNull()]$Value)

    if ($null -eq $Value) { return $null }

    # A bool is not a reading, and neither is a date.
    if ($Value -is [bool] -or $Value -is [datetime]) { return $null }

    # Already a number: convert it as one, never through its text. PowerShell keeps the
    # source text of a suffixed numeric literal, so a value holding 2000GB can stringify
    # to "2000GB", which parses as nothing - and a Judge reaching for that text would
    # report a Check unavailable on a machine that answered perfectly well. ( -is unwraps
    # a PSObject on its own, so a wrapped number lands here too.)
    if ($Value -is [byte]   -or $Value -is [sbyte]  -or
        $Value -is [int16]  -or $Value -is [uint16] -or
        $Value -is [int32]  -or $Value -is [uint32] -or
        $Value -is [int64]  -or $Value -is [uint64] -or
        $Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) {
        return [double]$Value
    }

    $number = 0.0
    if ([double]::TryParse(
            [string]$Value, [Globalization.NumberStyles]::Float,
            [Globalization.CultureInfo]::InvariantCulture, [ref]$number)) {
        return $number
    }
    $null
}

# The formats a time is read from when it arrives as text. Each names its own order of day
# and month, so that none is left to the culture of the machine. See ConvertTo-DataTime.
$script:DataIsoTimeFormats = [string[]]@(
    'yyyy-MM-ddTHH:mm:ss.FFFFFFFK', 'yyyy-MM-ddTHH:mm:ssK', 'yyyy-MM-ddTHH:mmK',
    'yyyy-MM-dd HH:mm:ss.FFFFFFFK', 'yyyy-MM-dd HH:mm:ssK', 'yyyy-MM-dd HH:mmK', 'yyyy-MM-dd')
$script:DataGermanTimeFormats = [string[]]@('d.M.yyyy H:mm:ss', 'd.M.yyyy H:mm', 'd.M.yyyy')

function ConvertTo-DataTime {
    <#
    .SYNOPSIS
        A time out of gathered data as a date, or $null when it is none. Pure.
    .DESCRIPTION
        A time arrives as a date from the Gatherer and as text from a fixture read by
        Windows PowerShell 5.1, or from a CliXML or JSON round trip.
 
        Read in this order, and in no other way: a date as it is; ISO 8601 and the
        round-trip format; a German date, day first. Text that is none of them is no time.
        Never a free parse: that reads 03.04.2026 as the fourth of March under one culture
        and as the third of April under another, and a time that is a month off is worse
        than none - it clears the network for a crash nobody held anything against, and
        calls a Session idle for a month that was used yesterday.
 
        The one way a Judge makes a time of what it was handed. It began as the
        Stability Kind's own; a Kind that casts for itself is back at the free parse.
    #>

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

    if ($Value -is [datetime]) {
        # The same moment, as the clock of this machine shows it: every time an event row
        # carries is local, and a UTC one beside it would be hours off.
        if (([datetime]$Value).Kind -eq [DateTimeKind]::Utc) { return ([datetime]$Value).ToLocalTime() }
        return [datetime]$Value
    }

    $text = "$Value".Trim()
    if (-not $text) { return $null }

    $invariant = [Globalization.CultureInfo]::InvariantCulture
    $styles    = [Globalization.DateTimeStyles]::AllowWhiteSpaces
    $parsed    = [datetime]::MinValue

    # A time that names its offset comes out as the local time of the same moment.
    if ([datetime]::TryParseExact($text, $script:DataIsoTimeFormats, $invariant, $styles, [ref]$parsed)) {
        return $parsed
    }

    # What ConvertTo-Json writes on Windows PowerShell 5.1: milliseconds since 1970, UTC.
    if ($text -match '^\\?/Date\((-?\d+)\)\\?/$') {
        return [datetime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc).AddMilliseconds([double]$Matches[1]).ToLocalTime()
    }

    if ([datetime]::TryParseExact($text, $script:DataGermanTimeFormats, $invariant, $styles, [ref]$parsed)) {
        return $parsed
    }

    $null
}

function Get-SampleAverage {
    <#
    .SYNOPSIS
        The average of the readings in a sample window, or $null when none is a number.
    .DESCRIPTION
        Several Checks average a counter across a window rather than trusting one reading.
        A real machine returns windows with holes in them - a counter that would not load
        leaves a null, an unreadable one leaves text - so the readable readings are judged
        and the rest ignored, and a window with nothing readable in it is not a zero.
    #>

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

    $readings = @(@($Sample) | ForEach-Object { ConvertTo-Number $_ } | Where-Object { $null -ne $_ })
    if (-not $readings.Count) { return $null }
    ($readings | Measure-Object -Average).Average
}

function Get-Severity {
    <#
    .SYNOPSIS
        The Severity a value earns against a WARN and a FAIL threshold.
    .DESCRIPTION
        Thresholds are exclusive: a value sitting exactly on the WARN threshold is still
        OK, which is what the Gutcheck script has always done and what the Report's
        documented boundaries mean.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][double]$Value,
        [Parameter(Mandatory)][double]$WarnAbove,
        [Parameter(Mandatory)][double]$FailAbove
    )
    if ($Value -gt $FailAbove) { return 'FAIL' }
    if ($Value -gt $WarnAbove) { return 'WARN' }
    'OK'
}

function Get-SeverityBelow {
    <#
    .SYNOPSIS
        The Severity a value earns when less of it is worse.
    .DESCRIPTION
        Installed memory and a processor's performance limit are graded downwards: it is
        the small number that is the problem. Thresholds stay exclusive in the same sense
        as Get-Severity, so a value sitting exactly on the WARN threshold is still OK.
 
        A Check that only ever warned in the Gutcheck script keeps only its WARN
        threshold, and its FAIL threshold defaults to a value no reading can go below -
        still a named parameter a Customer could set, but inert until they do.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][double]$Value,
        [Parameter(Mandatory)][double]$WarnBelow,
        [Parameter(Mandatory)][double]$FailBelow
    )
    if ($Value -lt $FailBelow) { return 'FAIL' }
    if ($Value -lt $WarnBelow) { return 'WARN' }
    'OK'
}

function Get-SamplePercentile {
    <#
    .SYNOPSIS
        The reading at the given percentile of a sample window, or $null when none is a number.
    .DESCRIPTION
        An average hides the tail, and the tail is what a Technician feels: a disk whose
        typical write takes 0.2 ms and whose worst takes 90 ms is a disk that stutters.
        Unreadable readings are ignored the same way Get-SampleAverage ignores them, so a
        window with holes in it still yields a percentile rather than nothing.
 
        Nearest-rank, which is what a percentile over a few hundred readings should be: the
        value returned is one that was actually measured rather than an interpolation
        between two that were.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][AllowEmptyCollection()]$Sample,
        [Parameter(Mandatory)][ValidateRange(0, 100)][double]$Percentile
    )

    $readings = @(@($Sample) | ForEach-Object { ConvertTo-Number $_ } | Where-Object { $null -ne $_ } | Sort-Object)
    if (-not $readings.Count) { return $null }

    $rank = [int][math]::Ceiling($Percentile / 100 * $readings.Count)
    if ($rank -lt 1) { $rank = 1 }
    if ($rank -gt $readings.Count) { $rank = $readings.Count }
    $readings[$rank - 1]
}

function Get-WorstSeverity {
    <#
    .SYNOPSIS
        The worst of the Severities handed to it, or OK when none was.
    .DESCRIPTION
        Several Checks grade one Finding against two independent measurements - a link that
        loses packets and a link that is slow are both bad links, and a gateway doing both
        is not excused by either. Whichever reads worse wins, which is how the Gutcheck
        script resolved the same disagreement.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(ValueFromRemainingArguments)][AllowNull()][AllowEmptyCollection()]$Severity)

    $worst = 'OK'
    $rank  = $script:SeverityValues.IndexOf('OK')
    foreach ($value in @($Severity | Where-Object { $_ })) {
        $index = $script:SeverityValues.IndexOf([string]$value)
        # $script:SeverityValues is ordered worst first, so a lower index is worse. An
        # unrecognised Severity is ignored rather than allowed to rank as the worst.
        if ($index -ge 0 -and $index -lt $rank) { $rank = $index; $worst = [string]$value }
    }
    $worst
}

function Get-DataCollection {
    <#
    .SYNOPSIS
        A property that should hold a list, as a real array with the holes taken out.
    .DESCRIPTION
        @($null) has one element, not none. So a Judge writing @(Get-DataProperty $Data
        'Crashes').Count against data whose Crashes property is absent - a Gatherer that
        could not read that log, or a CliXML round trip that dropped an empty collection -
        counts one crash that never happened, and reports a machine as unstable because a
        Check failed to run. Every list a Judge reads comes through here.
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [AllowNull()]$InputObject,
        [Parameter(Mandatory)][string]$Name
    )
    , @(@(Get-DataProperty -InputObject $InputObject -Name $Name) | Where-Object { $null -ne $_ })
}

function Format-DataSize {
    <#
    .SYNOPSIS
        A size as a Technician reads it in a sentence: in MB without decimals below one
        GB, in GB with one decimal from there. Pure.
    .DESCRIPTION
        The one way a size is written where it stands in a sentence or as text in a cell:
        "16.622 MB" in one Finding and "16,2 GB" in the next are one size read as two.
 
        A cell a Technician sorts or compares by is another matter and is not written by
        this function: there the number stands plain, in MB, and the unit in the heading of the
        column - the private memory in the tables of the Sessions and of the programs.
 
        What is handed in as nothing is written as "0 MB". So a size that is not known is
        not to be handed in: what to say of it is for the Judge, and it is not zero.
    .PARAMETER Bytes
        The size in bytes.
    .PARAMETER MB
        The size in MB, where that is what the data holds.
    .PARAMETER AtLeast
        The size is a lower bound, and is said to be one: "mindestens 1,2 GB".
    #>

    [CmdletBinding(DefaultParameterSetName = 'Bytes')]
    [OutputType([string])]
    param(
        [Parameter(ParameterSetName = 'Bytes', Position = 0)][AllowNull()]$Bytes,
        [Parameter(ParameterSetName = 'MB', Mandatory)][AllowNull()]$MB,
        [switch]$AtLeast
    )

    $inMB = $PSCmdlet.ParameterSetName -eq 'MB'
    $number = ConvertTo-Number $(if ($inMB) { $MB } else { $Bytes })
    if ($null -eq $number) { $number = 0 }
    if ($inMB) { $number = $number * 1MB }

    $text = $(
        if ([math]::Round($number / 1MB) -lt 1024) { (Get-Text 'Value.Size.MB') -f [math]::Round($number / 1MB) }
        else                 { (Get-Text 'Value.Size.GB') -f ($number / 1GB) })
    if ($AtLeast) { return (Get-Text 'Value.Size.AtLeast') -f $text }
    $text
}