Private/Kinds/Cpu.ps1

# The Cpu Kind: what the processor is doing when nobody is asking it to do anything.
#
# A machine that is busy at rest is busy for a reason - a scan, an update, an indexer, a
# runaway process - and the Section names the culprit so second level does not have to
# revisit the machine.

$script:CpuSampleCount = 5

# The process snapshot is a rate over an interval rather than an instant, so it needs its
# own settle regardless of the shared sampling wait.
$script:CpuProcessSampleSeconds = 2

function Get-CpuData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $filter = "Name='_Total'"
    # The first read of a formatted-performance class is primed with zeroes; discard it.
    [void](Get-CimInstance Win32_PerfFormattedData_Counters_ProcessorInformation -Filter $filter -ErrorAction SilentlyContinue)

    # One step per reading plus the process snapshot's settle, which is the longest wait.
    $steps = $script:CpuSampleCount + 1
    $samples = @(1..$script:CpuSampleCount | ForEach-Object {
        Write-CheckProgress -Status ((Get-Text 'Console.Progress.Sample') -f $_, $script:CpuSampleCount) -Step ($_ - 1) -Of $steps
        Wait-Sample
        Get-CimInstance Win32_PerfFormattedData_Counters_ProcessorInformation -Filter $filter -ErrorAction SilentlyContinue
    })

    # The script divided this counter by a core count the System Check had stashed in
    # script scope, so reordering the two Checks silently changed the column. Asking the
    # runtime directly removes that coupling. On Windows PowerShell 5.1 a machine with
    # more than 64 logical processors reports only the current processor group; no
    # Target Machine in the estate is close to that.
    $logical = [Environment]::ProcessorCount

    [void](Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -ErrorAction SilentlyContinue)
    $settled = Get-SampleSeconds -Seconds $script:CpuProcessSampleSeconds
    Write-CheckProgress -Status (Get-Text 'Console.Progress.Settle') -Step $script:CpuSampleCount -Of $steps
    Wait-Sample -Seconds $script:CpuProcessSampleSeconds

    $top = @(Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -ErrorAction SilentlyContinue |
        Where-Object { $_.Name -ne '_Total' -and $_.Name -ne 'Idle' } |
        Sort-Object PercentProcessorTime -Descending | Select-Object -First 12 | ForEach-Object {
            [pscustomobject]@{
                Process    = $_.Name
                'CPU %'    = [math]::Round($_.PercentProcessorTime / [math]::Max($logical, 1), 1)
                'Private MB' = [math]::Round($_.WorkingSetPrivate / 1MB)
                'IO KB/s'  = [math]::Round($_.IODataBytesPersec / 1KB)
            }
        })

    [pscustomobject]@{
        PSTypeName              = 'Gutcheck.Data.Cpu'
        ProcessorTimeSamples    = @($samples | ForEach-Object { $_.PercentProcessorTime })
        PerformanceLimitSamples = @($samples | ForEach-Object { $_.PercentPerformanceLimit })
        TopProcessesByCpu       = $top
        # How long the process snapshot actually settled for, so the Section can say so
        # rather than claim a window it did not use.
        ProcessSampleSeconds    = $settled
    }
}

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

    New-CpuLoadFinding             -Data $Data -Parameters $Parameters
    New-CpuPerformanceLimitFinding -Data $Data -Parameters $Parameters
}

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

    $seconds = ConvertTo-Number (Get-DataProperty $Data 'ProcessSampleSeconds')
    if ($null -eq $seconds) { $seconds = $script:CpuProcessSampleSeconds }

    New-Section -Title ((Get-Text 'Title.Cpu.TopProcessesByCpu') -f $seconds) `
        -Row (Get-DataProperty $Data 'TopProcessesByCpu')
}

function New-CpuLoadFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'CpuLoadAtRestWarnPercent' 30
    $fail = Get-Parameter $Parameters 'CpuLoadAtRestFailPercent' 60

    $average = Get-SampleAverage (Get-DataProperty $Data 'ProcessorTimeSamples')
    if ($null -eq $average) {
        return New-UnavailableFinding -Category CPU -Check (Get-Text 'Check.Cpu.CPULoadAtRest') `
            -Hint (Get-Text 'Hint.Cpu.TheProcessorPerformanceCountersDid')
    }

    New-Finding -Category CPU -Check (Get-Text 'Check.Cpu.CPULoadAtRest') -Severity (Get-Severity $average $warn $fail) `
        -Value ('{0:N0} %' -f $average) `
        -Hint (Get-Text 'Hint.Cpu.HighLoadWhileIdleSee')
}

function New-CpuPerformanceLimitFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    # Graded downwards, and the script only ever warned here, so the FAIL threshold
    # defaults to a percentage no reading can fall below.
    $warnBelow = Get-Parameter $Parameters 'CpuPerformanceLimitWarnBelowPercent' 90
    $failBelow = Get-Parameter $Parameters 'CpuPerformanceLimitFailBelowPercent' ([double]::MinValue)

    # Absent on processors that do not expose a performance limit. The script reported
    # nothing at all in that case rather than a gap, and so does this.
    $average = Get-SampleAverage (Get-DataProperty $Data 'PerformanceLimitSamples')
    if ($null -eq $average) { return }

    New-Finding -Category CPU -Check (Get-Text 'Check.Cpu.CPUPerformanceLimit') -Severity (Get-SeverityBelow $average $warnBelow $failBelow) `
        -Value ('{0:N0} %' -f $average) `
        -Hint (Get-Text 'Hint.Cpu.FirmwareOSIsCappingThe')
}