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 } |