#Requires -Version 5.1 ######################################################################################### #### windows-disk-usage-reporter.ps1 - Find what's consuming disk space on Windows #### #### Scans volumes, ranks largest directories and files, flags old data #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### .\windows-disk-usage-reporter.ps1 #### #### .\windows-disk-usage-reporter.ps1 -Path C:\Users #### #### .\windows-disk-usage-reporter.ps1 -TopN 50 -MinSizeMB 100 #### #### .\windows-disk-usage-reporter.ps1 -Json #### #### #### ######################################################################################### [CmdletBinding()] param( [string]$Path = "C:\", [int]$TopN = 20, [int]$MinSizeMB = 1, [int]$MaxDepth = 3, [int]$AgeWarnDays = 90, [switch]$Json, [switch]$NoColor ) $Version = "1.00" $MinSizeBytes = [int64]$MinSizeMB * 1048576 $AgeCutoff = (Get-Date).AddDays(-$AgeWarnDays) $Separator = [string]::new([char]0x2500, 88) # ============================================================================ # HELPERS # ============================================================================ function Format-FileSize { param([int64]$Bytes) if ($Bytes -ge 1TB) { return "{0:N2} TB" -f ($Bytes / 1TB) } if ($Bytes -ge 1GB) { return "{0:N2} GB" -f ($Bytes / 1GB) } if ($Bytes -ge 1MB) { return "{0:N1} MB" -f ($Bytes / 1MB) } if ($Bytes -ge 1KB) { return "{0:N1} KB" -f ($Bytes / 1KB) } return "$Bytes B" } function Write-ColorLine { param( [string]$Text, [ConsoleColor]$Color = [ConsoleColor]::Gray ) if ($NoColor) { Write-Output $Text } else { Write-Host $Text -ForegroundColor $Color } } function Write-Header { param([string]$Title) Write-ColorLine "" Write-ColorLine ("=" * 56) Cyan Write-ColorLine " $Title" Cyan Write-ColorLine ("=" * 56) Cyan Write-ColorLine "" } function Get-DirectorySizeRecursive { param( [string]$DirPath, [int]$CurrentDepth, [int]$Limit ) if ($CurrentDepth -gt $Limit) { return @() } $results = [System.Collections.Generic.List[PSCustomObject]]::new() try { $items = Get-ChildItem -LiteralPath $DirPath -Directory -ErrorAction SilentlyContinue } catch { return @() } foreach ($dir in $items) { try { $files = Get-ChildItem -LiteralPath $dir.FullName -Recurse -File -ErrorAction SilentlyContinue $totalSize = ($files | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum if ($null -eq $totalSize) { $totalSize = 0 } $results.Add([PSCustomObject]@{ Path = $dir.FullName SizeBytes = [int64]$totalSize }) } catch { continue } if ($CurrentDepth -lt $Limit) { $children = Get-DirectorySizeRecursive -DirPath $dir.FullName -CurrentDepth ($CurrentDepth + 1) -Limit $Limit foreach ($child in $children) { $results.Add($child) } } } return $results } # ============================================================================ # VOLUME OVERVIEW # ============================================================================ function Get-VolumeOverview { Write-Header "Volume Overview" $headerLine = " {0,-12} {1,-20} {2,10} {3,10} {4,6}" -f "Drive", "Label", "Size", "Free", "Used%" Write-ColorLine $headerLine White Write-ColorLine " $Separator" $volumes = @() try { $disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" -ErrorAction SilentlyContinue } catch { $disks = Get-WmiObject -Class Win32_LogicalDisk -Filter "DriveType=3" -ErrorAction SilentlyContinue } foreach ($disk in $disks) { $total = [int64]$disk.Size $free = [int64]$disk.FreeSpace if ($total -eq 0) { continue } $used = $total - $free $pct = [math]::Round(($used / $total) * 100, 1) $color = [ConsoleColor]::Green if ($pct -ge 90) { $color = [ConsoleColor]::Red } elseif ($pct -ge 80) { $color = [ConsoleColor]::Yellow } $line = " {0,-12} {1,-20} {2,10} {3,10} {4,5}%" -f $disk.DeviceID, $disk.VolumeName, (Format-FileSize $total), (Format-FileSize $free), $pct Write-ColorLine $line $color $volumes += [PSCustomObject]@{ Drive = $disk.DeviceID Label = $disk.VolumeName SizeBytes = $total FreeBytes = $free UsedPct = $pct } } return $volumes } # ============================================================================ # TOP DIRECTORIES BY SIZE # ============================================================================ function Get-TopDirectories { Write-Header "Top $TopN Directories by Size" $headerLine = " {0,4} {1,-60} {2,10}" -f "#", "Directory", "Size" Write-ColorLine $headerLine White Write-ColorLine " $Separator" $allDirs = Get-DirectorySizeRecursive -DirPath $Path -CurrentDepth 1 -Limit $MaxDepth $topDirs = $allDirs | Sort-Object SizeBytes -Descending | Select-Object -First $TopN $rank = 0 foreach ($dir in $topDirs) { $rank++ $color = [ConsoleColor]::Gray if ($dir.SizeBytes -ge 10GB) { $color = [ConsoleColor]::Red } elseif ($dir.SizeBytes -ge 1GB) { $color = [ConsoleColor]::Yellow } $displayPath = $dir.Path if ($displayPath.Length -gt 58) { $displayPath = "..." + $displayPath.Substring($displayPath.Length - 55) } $line = " {0,4} {1,-60} {2,10}" -f $rank, $displayPath, (Format-FileSize $dir.SizeBytes) Write-ColorLine $line $color } return $topDirs } # ============================================================================ # TOP FILES BY SIZE # ============================================================================ function Get-TopFiles { Write-Header "Top $TopN Files by Size" $headerLine = " {0,4} {1,-60} {2,10}" -f "#", "File", "Size" Write-ColorLine $headerLine White Write-ColorLine " $Separator" $files = Get-ChildItem -LiteralPath $Path -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Length -ge $MinSizeBytes } | Sort-Object Length -Descending | Select-Object -First $TopN $rank = 0 $result = @() foreach ($file in $files) { $rank++ $color = [ConsoleColor]::Gray if ($file.Length -ge 1GB) { $color = [ConsoleColor]::Red } elseif ($file.Length -ge 100MB) { $color = [ConsoleColor]::Yellow } $displayPath = $file.FullName if ($displayPath.Length -gt 58) { $displayPath = "..." + $displayPath.Substring($displayPath.Length - 55) } $line = " {0,4} {1,-60} {2,10}" -f $rank, $displayPath, (Format-FileSize $file.Length) Write-ColorLine $line $color $result += [PSCustomObject]@{ Path = $file.FullName SizeBytes = $file.Length } } return $result } # ============================================================================ # OLD LARGE FILES # ============================================================================ function Get-OldLargeFiles { Write-Header "Old Large Files (> ${MinSizeMB} MB, older than $AgeWarnDays days)" $headerLine = " {0,4} {1,-50} {2,10} {3,12}" -f "#", "File", "Size", "Last Modified" Write-ColorLine $headerLine White Write-ColorLine " $Separator" $files = Get-ChildItem -LiteralPath $Path -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Length -ge $MinSizeBytes -and $_.LastWriteTime -lt $AgeCutoff } | Sort-Object Length -Descending | Select-Object -First $TopN if (-not $files -or $files.Count -eq 0) { Write-ColorLine " No files found matching criteria." return @() } $rank = 0 $result = @() foreach ($file in $files) { $rank++ $displayPath = $file.FullName if ($displayPath.Length -gt 48) { $displayPath = "..." + $displayPath.Substring($displayPath.Length - 45) } $line = " {0,4} {1,-50} {2,10} {3,12}" -f $rank, $displayPath, (Format-FileSize $file.Length), $file.LastWriteTime.ToString("yyyy-MM-dd") Write-ColorLine $line Yellow $result += [PSCustomObject]@{ Path = $file.FullName SizeBytes = $file.Length LastModified = $file.LastWriteTime.ToString("yyyy-MM-dd") } } return $result } # ============================================================================ # SUMMARY # ============================================================================ function Write-Summary { param( [int64]$TotalScanned, [array]$OldFiles ) Write-Header "Summary" $oldCount = 0 $oldBytes = [int64]0 if ($OldFiles) { $oldCount = $OldFiles.Count $oldBytes = ($OldFiles | Measure-Object -Property SizeBytes -Sum).Sum if ($null -eq $oldBytes) { $oldBytes = 0 } } Write-ColorLine (" Scan path: $Path") Write-ColorLine (" Total scanned: $(Format-FileSize $TotalScanned)") Write-ColorLine (" Min file size: ${MinSizeMB} MB") Write-ColorLine (" Age threshold: $AgeWarnDays days") Write-ColorLine "" Write-ColorLine (" Old large files: $oldCount files") Write-ColorLine (" Reclaimable space: $(Format-FileSize $oldBytes)") Yellow Write-ColorLine "" if ($oldBytes -gt 0) { Write-ColorLine " -> Review old files above - candidates for cleanup or archival" Yellow } else { Write-ColorLine " OK - No old large files found" Green } Write-ColorLine "" } # ============================================================================ # JSON OUTPUT # ============================================================================ function Get-JsonReport { param( [array]$Volumes, [array]$TopDirs, [array]$TopFilesList, [array]$OldFiles, [int64]$TotalScanned ) $oldBytes = [int64]0 if ($OldFiles) { $oldBytes = ($OldFiles | Measure-Object -Property SizeBytes -Sum -ErrorAction SilentlyContinue).Sum if ($null -eq $oldBytes) { $oldBytes = 0 } } $report = [PSCustomObject]@{ scan_path = $Path timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ") min_size_mb = $MinSizeMB age_warn_days = $AgeWarnDays max_depth = $MaxDepth volumes = @($Volumes) top_directories = @($TopDirs) top_files = @($TopFilesList) old_large_files = @($OldFiles) summary = [PSCustomObject]@{ total_scanned_bytes = $TotalScanned old_file_count = if ($OldFiles) { $OldFiles.Count } else { 0 } reclaimable_bytes = $oldBytes } } return $report | ConvertTo-Json -Depth 5 } # ============================================================================ # MAIN # ============================================================================ if (-not (Test-Path -LiteralPath $Path)) { Write-Error "Path does not exist: $Path" exit 1 } # Calculate total size of scanned path $totalScanned = [int64]0 try { $allFiles = Get-ChildItem -LiteralPath $Path -Recurse -File -ErrorAction SilentlyContinue $totalScanned = ($allFiles | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum if ($null -eq $totalScanned) { $totalScanned = 0 } } catch { $totalScanned = 0 } if ($Json) { # Collect data silently for JSON - redirect console output $volumes = @() try { $disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" -ErrorAction SilentlyContinue } catch { $disks = Get-WmiObject -Class Win32_LogicalDisk -Filter "DriveType=3" -ErrorAction SilentlyContinue } foreach ($disk in $disks) { $total = [int64]$disk.Size $free = [int64]$disk.FreeSpace if ($total -eq 0) { continue } $used = $total - $free $pct = [math]::Round(($used / $total) * 100, 1) $volumes += [PSCustomObject]@{ Drive = $disk.DeviceID Label = $disk.VolumeName SizeBytes = $total FreeBytes = $free UsedPct = $pct } } $topDirs = Get-DirectorySizeRecursive -DirPath $Path -CurrentDepth 1 -Limit $MaxDepth | Sort-Object SizeBytes -Descending | Select-Object -First $TopN $topFilesList = Get-ChildItem -LiteralPath $Path -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Length -ge $MinSizeBytes } | Sort-Object Length -Descending | Select-Object -First $TopN | ForEach-Object { [PSCustomObject]@{ Path = $_.FullName; SizeBytes = $_.Length } } $oldFiles = Get-ChildItem -LiteralPath $Path -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Length -ge $MinSizeBytes -and $_.LastWriteTime -lt $AgeCutoff } | Sort-Object Length -Descending | Select-Object -First $TopN | ForEach-Object { [PSCustomObject]@{ Path = $_.FullName; SizeBytes = $_.Length; LastModified = $_.LastWriteTime.ToString("yyyy-MM-dd") } } Get-JsonReport -Volumes $volumes -TopDirs $topDirs -TopFilesList $topFilesList -OldFiles $oldFiles -TotalScanned $totalScanned exit 0 } # Interactive output Write-ColorLine "" Write-ColorLine "Disk Usage Report" White Write-ColorLine ("{0} - Scanning: {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Path) $volumes = Get-VolumeOverview $topDirs = Get-TopDirectories $topFiles = Get-TopFiles $oldFiles = Get-OldLargeFiles Write-Summary -TotalScanned $totalScanned -OldFiles $oldFiles