Private/Kinds/Storage.ps1

# The Storage Kind: what the machine stores things on, and whether there is room.
#
# The reliability counters behind a solid-state drive's wear need admin rights and ship
# as their own Kind in the Elevated Part. What is here is what a Technician can see
# without elevating: the device, its media, its free space, and whether it is thrashing.

$script:StorageSampleCount = 3

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

    $disks = @()
    try {
        $disks = @(Get-PhysicalDisk -ErrorAction Stop | ForEach-Object {
            [pscustomobject]@{
                DeviceId     = $_.DeviceId
                FriendlyName = $_.FriendlyName
                MediaType    = "$($_.MediaType)"
                BusType      = "$($_.BusType)"
                SizeBytes    = $_.Size
                HealthStatus = "$($_.HealthStatus)"
            }
        })
    }
    catch { }

    $systemDrive = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$env:SystemDrive'" -ErrorAction SilentlyContinue

    $filter = "Name='_Total'"
    # The first read of a formatted-performance class is primed with zeroes; discard it.
    [void](Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter $filter -ErrorAction SilentlyContinue)
    $samples = @(1..$script:StorageSampleCount | ForEach-Object {
        Wait-Sample
        Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter $filter -ErrorAction SilentlyContinue
    })

    [pscustomobject]@{
        PSTypeName          = 'Gutcheck.Data.Storage'
        PhysicalDisks       = $disks
        SystemDriveLetter   = $env:SystemDrive
        SystemDriveFreeBytes = $systemDrive.FreeSpace
        SystemDriveSizeBytes = $systemDrive.Size
        IdleTimeSamples     = @($samples | ForEach-Object { $_.PercentIdleTime })
    }
}

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

    New-PhysicalDiskFinding -Data $Data -Parameters $Parameters
    New-FreeSpaceFinding    -Data $Data -Parameters $Parameters
    New-DiskBusyFinding     -Data $Data -Parameters $Parameters
}

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

    $disks = (Get-DataCollection $Data 'PhysicalDisks')
    if (-not $disks.Count) {
        # Named so it cannot be mistaken for a device: the per-device Findings below are
        # "Disk 0", "Disk 1". The script abandoned the whole Check here and lost free
        # space and disk-busy with it; a Check that could not run belongs in the Report.
        return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.Storage.PhysicalDisks') `
            -Hint (Get-Text 'Hint.Storage.NoPhysicalDiskCouldBe')
    }

    # One Finding per device: a machine with a healthy SSD and a failing HDD is not
    # summarised honestly by either one of them.
    foreach ($disk in $disks) {
        $severity = 'OK'
        if ("$($disk.HealthStatus)" -ne 'Healthy') { $severity = 'FAIL' }
        elseif ("$($disk.MediaType)" -eq 'HDD')    { $severity = 'FAIL' }

        New-Finding -Category Storage -Check ((Get-Text 'Check.Storage.Disk') -f $disk.DeviceId) -Severity $severity `
            -Value ('{0} | {1} | {2} | {3} GB | Health: {4}' -f
                $disk.FriendlyName, $disk.MediaType, $disk.BusType,
                [math]::Round(($disk.SizeBytes / 1GB)), $disk.HealthStatus) `
            -Hint (Get-Text 'Hint.Storage.UnhealthyDiskOrHDDReplace')
    }
}

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

    # Two thresholds that disagree are resolved the way the script resolved them: whichever
    # is worse wins, because 12 GB free on a 2 TB disk is as fatal as 8 % on a small one.
    $failBelowGB      = Get-Parameter $Parameters 'FreeSpaceFailBelowGB' 15
    $failBelowPercent = Get-Parameter $Parameters 'FreeSpaceFailBelowPercent' 10
    $warnBelowPercent = Get-Parameter $Parameters 'FreeSpaceWarnBelowPercent' 20

    $free  = ConvertTo-Number (Get-DataProperty $Data 'SystemDriveFreeBytes')
    $size  = ConvertTo-Number (Get-DataProperty $Data 'SystemDriveSizeBytes')
    $drive = Get-DataProperty $Data 'SystemDriveLetter'

    if ($null -eq $free -or $null -eq $size -or $size -le 0) {
        return New-UnavailableFinding -Category Storage -Check ((Get-Text 'Check.Storage.FreeSpace') -f $drive) `
            -Hint (Get-Text 'Hint.Storage.TheSystemDriveDidNot')
    }

    $freeGB      = $free / 1GB
    $freePercent = $free / $size * 100

    # Two thresholds that disagree are resolved the way the script resolved them: whichever
    # is worse wins. 12 GB free on a 2 TB disk is a comfortable-looking 0.6 % and still
    # fatal, and 9 % of a 1 TB disk is 90 comfortable-looking gigabytes and still fatal.
    $severity = Get-SeverityBelow $freePercent $warnBelowPercent $failBelowPercent
    if ($freeGB -lt $failBelowGB) { $severity = 'FAIL' }

    New-Finding -Category Storage -Check ((Get-Text 'Check.Storage.FreeSpace') -f $drive) -Severity $severity `
        -Value ('{0:N1} GB ({1:N0} %)' -f $freeGB, $freePercent) `
        -Hint (Get-Text 'Hint.Storage.FullSSDsGetSlowUpdates')
}

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

    $warn = Get-Parameter $Parameters 'DiskBusyAtRestWarnPercent' 40
    $fail = Get-Parameter $Parameters 'DiskBusyAtRestFailPercent' 80

    $idle = Get-SampleAverage (Get-DataProperty $Data 'IdleTimeSamples')
    if ($null -eq $idle) {
        return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.Storage.DiskBusyAtRest') `
            -Hint (Get-Text 'Hint.Storage.TheDiskPerformanceCountersDid')
    }

    $busy = 100 - $idle
    New-Finding -Category Storage -Check (Get-Text 'Check.Storage.DiskBusyAtRest') -Severity (Get-Severity $busy $warn $fail) `
        -Value ('{0:N0} %' -f $busy) `
        -Hint (Get-Text 'Hint.Storage.DiskPermanentlyBusyWhileIdle')
}