Scripts/Get-DiskHealthReport.ps1
|
<#
.SYNOPSIS Collects disk usage, volume health, and physical disk status for IT support diagnostics. .DESCRIPTION Gathers free space per volume, physical disk health status (where available without elevation), disk queue length as an I/O bottleneck indicator, and flags any volume approaching capacity. Supports the diagnostic steps in playbooks/high-cpu-memory-usage.md (Step 8) and playbooks/application-not-launching.md (Step 1). This script is read-only. It makes no changes to the system. Most functionality works without administrative privileges; sections that require elevation for fuller detail (SMART status access in some environments) are clearly marked and degrade gracefully if unavailable. .NOTES Author: it-support-ops repository Run as: Standard user (no elevation required; some SMART detail may be limited without elevation depending on hardware and driver configuration) Compatibility: Windows 10, Windows 11, PowerShell 5.1+ Output: Console output + log file in user's Documents folder .EXAMPLE .\Get-DiskHealthReport.ps1 Runs the disk health report and saves output to: $env:USERPROFILE\Documents\IT-Diagnostics\DiskHealthReport_<timestamp>.txt .EXAMPLE .\Get-DiskHealthReport.ps1 -LowSpaceThresholdPercent 15 Runs the report using a custom low-space warning threshold of 15% free instead of the default 20%. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool by design: report content goes through Write-ReportLine (pipe-safe); Write-Host is used only for colored status messages meant for a human reading the console.')] [CmdletBinding()] param( # Optional custom output path. Defaults to Documents\IT-Diagnostics. [Parameter(Mandatory = $false)] [string]$OutputPath, # Percentage of free space below which a volume is flagged as low. # Default of 20 aligns with the threshold used in the high-cpu-memory # playbook for consistency across the repository. [Parameter(Mandatory = $false)] [ValidateRange(1, 100)] [int]$LowSpaceThresholdPercent = 20, # Percentage of free space below which a volume is flagged as critical. [Parameter(Mandatory = $false)] [ValidateRange(1, 100)] [int]$CriticalSpaceThresholdPercent = 10 ) # --------------------------------------------------------------------------- # SETUP # --------------------------------------------------------------------------- $ErrorActionPreference = "Continue" $timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm-ss" if (-not $OutputPath) { $diagnosticsFolder = Join-Path $env:USERPROFILE "Documents\IT-Diagnostics" if (-not (Test-Path $diagnosticsFolder)) { try { New-Item -ItemType Directory -Path $diagnosticsFolder -Force | Out-Null } catch { Write-Warning "Could not create diagnostics folder. Output will be console-only." $diagnosticsFolder = $null } } if ($diagnosticsFolder) { $OutputPath = Join-Path $diagnosticsFolder "DiskHealthReport_$timestamp.txt" } } $reportLines = New-Object System.Collections.Generic.List[string] function Write-ReportLine { param([string]$Text = "") Write-Host $Text $reportLines.Add($Text) } function Write-SectionHeader { param([string]$Title) Write-ReportLine "" Write-ReportLine "=" * 70 Write-ReportLine " $Title" Write-ReportLine "=" * 70 } # Accumulates flags across all sections so the summary at the end can list # every concern in one place rather than requiring the reader to scroll # back through the full report to find them. $script:flags = New-Object System.Collections.Generic.List[string] function Add-Flag { param([string]$Message) $script:flags.Add($Message) } # --------------------------------------------------------------------------- # REPORT HEADER # --------------------------------------------------------------------------- Write-ReportLine "=" * 70 Write-ReportLine " IT SUPPORT - DISK HEALTH REPORT" Write-ReportLine "=" * 70 Write-ReportLine "Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" Write-ReportLine "Computer Name: $env:COMPUTERNAME" Write-ReportLine "Current User: $env:USERNAME" Write-ReportLine "Thresholds: Low <$LowSpaceThresholdPercent% free | Critical <$CriticalSpaceThresholdPercent% free" # --------------------------------------------------------------------------- # SECTION 1: VOLUME / FILE SYSTEM USAGE # --------------------------------------------------------------------------- Write-SectionHeader "VOLUME USAGE" try { $volumes = Get-PSDrive -PSProvider FileSystem -ErrorAction Stop | Where-Object { $_.Used -gt 0 } if (-not $volumes) { Write-ReportLine "No file system volumes with usable data were found." } foreach ($vol in $volumes) { $usedGB = [math]::Round($vol.Used / 1GB, 2) $freeGB = [math]::Round($vol.Free / 1GB, 2) $totalGB = $usedGB + $freeGB $pctFree = if ($totalGB -gt 0) { [math]::Round(($freeGB / $totalGB) * 100, 1) } else { 0 } Write-ReportLine "Volume $($vol.Name):" Write-ReportLine " Used: $usedGB GB" Write-ReportLine " Free: $freeGB GB" Write-ReportLine " Total: $totalGB GB" Write-ReportLine " Free %: $pctFree%" if ($pctFree -lt $CriticalSpaceThresholdPercent) { Write-ReportLine " STATUS: CRITICAL - free space below ${CriticalSpaceThresholdPercent}%" Add-Flag "Volume $($vol.Name): CRITICAL low disk space ($pctFree% free)" } elseif ($pctFree -lt $LowSpaceThresholdPercent) { Write-ReportLine " STATUS: LOW - free space below ${LowSpaceThresholdPercent}%" Add-Flag "Volume $($vol.Name): LOW disk space ($pctFree% free)" } else { Write-ReportLine " STATUS: OK" } Write-ReportLine "" } } catch { Write-ReportLine "ERROR: Could not retrieve volume information - $($_.Exception.Message)" } # --------------------------------------------------------------------------- # SECTION 2: PHYSICAL DISK HEALTH # --------------------------------------------------------------------------- Write-SectionHeader "PHYSICAL DISK HEALTH" try { $physicalDisks = Get-PhysicalDisk -ErrorAction Stop if (-not $physicalDisks) { Write-ReportLine "No physical disk information available via Get-PhysicalDisk on this system." } foreach ($disk in $physicalDisks) { Write-ReportLine "Disk: $($disk.FriendlyName)" Write-ReportLine " Media Type: $($disk.MediaType)" Write-ReportLine " Health Status: $($disk.HealthStatus)" Write-ReportLine " Operational Status: $($disk.OperationalStatus)" Write-ReportLine " Size: $([math]::Round($disk.Size / 1GB, 1)) GB" if ($disk.HealthStatus -ne "Healthy") { Write-ReportLine " STATUS: FLAG - disk health is not reporting Healthy" Add-Flag "Physical disk '$($disk.FriendlyName)': HealthStatus = $($disk.HealthStatus)" } Write-ReportLine "" } } catch { # Get-PhysicalDisk can fail or return limited info without elevation or # on certain virtualised/managed hardware. Degrade gracefully rather # than treating this as a hard failure of the whole script. Write-ReportLine "Could not retrieve physical disk health via Get-PhysicalDisk." Write-ReportLine "This may require administrator privileges on this system, or the" Write-ReportLine "disk controller may not expose health data through this interface." Write-ReportLine "Detail: $($_.Exception.Message)" } # --------------------------------------------------------------------------- # SECTION 3: DISK I/O PERFORMANCE INDICATOR # --------------------------------------------------------------------------- # Disk queue length is a useful proxy for whether the disk itself is a # bottleneck, distinct from raw capacity. A sustained high queue length # with otherwise normal CPU/memory usage points the technician toward # storage hardware rather than continuing to chase a phantom CPU issue. Write-SectionHeader "DISK I/O PERFORMANCE INDICATOR" try { $queueSamples = Get-Counter '\PhysicalDisk(_Total)\Current Disk Queue Length' ` -SampleInterval 2 -MaxSamples 3 -ErrorAction Stop $avgQueue = ($queueSamples.CounterSamples | Measure-Object -Property CookedValue -Average).Average Write-ReportLine "Average Disk Queue Length (3 samples, 2s apart): $([math]::Round($avgQueue, 2))" # A queue length sustained above 2 per physical disk is a commonly used # rule-of-thumb threshold indicating the disk cannot keep up with demand. if ($avgQueue -gt 2) { Write-ReportLine "STATUS: FLAG - sustained disk queue length above 2 indicates a" Write-ReportLine " potential I/O bottleneck. The disk may be a limiting factor" Write-ReportLine " in perceived system slowness." Add-Flag "Disk queue length elevated (avg $([math]::Round($avgQueue,2))) - possible I/O bottleneck" } else { Write-ReportLine "STATUS: OK" } } catch { Write-ReportLine "Could not retrieve disk queue length counter - $($_.Exception.Message)" } # --------------------------------------------------------------------------- # SECTION 4: LARGEST FOLDERS ON SYSTEM DRIVE (TOP-LEVEL ONLY) # --------------------------------------------------------------------------- # A common follow-up question once low disk space is identified is "what is # actually using the space?" This gives an immediate, low-cost starting # point (top-level user profile folders only — deliberately not a full # recursive scan, which would be slow and is not necessary for triage). Write-SectionHeader "LARGEST TOP-LEVEL FOLDERS IN USER PROFILE" try { $profilePath = $env:USERPROFILE Get-ChildItem -Path $profilePath -Directory -ErrorAction SilentlyContinue | ForEach-Object { $folder = $_ try { $size = (Get-ChildItem -Path $folder.FullName -Recurse -Force ` -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum [PSCustomObject]@{ Folder = $folder.Name SizeGB = if ($size) { [math]::Round($size / 1GB, 2) } else { 0 } } } catch { [PSCustomObject]@{ Folder = $folder.Name; SizeGB = "N/A" } } } | Sort-Object SizeGB -Descending | Select-Object -First 8 | ForEach-Object { Write-ReportLine (" {0,-25} {1} GB" -f $_.Folder, $_.SizeGB) } Write-ReportLine "" Write-ReportLine "Note: This scan covers the current user's profile only and may take" Write-ReportLine "a moment to complete on profiles with large amounts of data." } catch { Write-ReportLine "Could not calculate folder sizes - $($_.Exception.Message)" } # --------------------------------------------------------------------------- # SUMMARY # --------------------------------------------------------------------------- Write-SectionHeader "DIAGNOSTIC SUMMARY" if ($script:flags.Count -eq 0) { Write-ReportLine "No disk-related issues flagged. All volumes and disks within" Write-ReportLine "normal thresholds." } else { Write-ReportLine "$($script:flags.Count) issue(s) flagged:" Write-ReportLine "" foreach ($flag in $script:flags) { Write-ReportLine " - $flag" } Write-ReportLine "" Write-ReportLine "Refer to playbooks/high-cpu-memory-usage.md (Step 8) for next steps" Write-ReportLine "on disk-related performance issues, or escalate per" Write-ReportLine "methodology/escalation-matrix.md if a physical disk health" Write-ReportLine "issue was identified." } Write-ReportLine "" Write-ReportLine "Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" # --------------------------------------------------------------------------- # FILE OUTPUT # --------------------------------------------------------------------------- if ($OutputPath) { try { $reportLines | Out-File -FilePath $OutputPath -Encoding UTF8 -ErrorAction Stop Write-Host "" Write-Host "Report saved to: $OutputPath" -ForegroundColor Green Write-Host "Attach this file to the ticket per diagnostic-report-template.md" -ForegroundColor Green } catch { Write-Warning "Could not save report to file: $($_.Exception.Message)" Write-Warning "Report was displayed in console only — copy manually if needed." } } else { Write-Host "" Write-Host "No output file path available — report displayed in console only." -ForegroundColor Yellow } |