Public/disk/Show-FolderUsage.ps1
|
#Requires -Version 5.1 function Show-FolderUsage { <# .SYNOPSIS Shows folder tree disk usage by file extension with a usage bar .DESCRIPTION Aggregates one folder tree per target computer by file extension and emits one PSWinOps.FolderUsage object per extension, sorted descending by SizeBytes. Remote calls dispatch through Invoke-RemoteOrLocal, so the Path is resolved and the tree is walked, grouped and sorted on the target machine, and only the per-extension summary rows cross the wire. Unreadable subfolders are tolerated and reported through a non-zero InaccessibleCount instead of discarding the partial result, and one failing computer never stops the remaining ones. .PARAMETER Path The folder tree to measure. The path is interpreted on the target machine, never resolved locally. Extensionless files are grouped under '(none)', and extensions are compared case-insensitively. .PARAMETER ComputerName One or more computer names to query. Defaults to the local computer. Accepts pipeline input by value and by property name. .PARAMETER Threshold Percentage of the tree's total size below which an extension row is omitted, between 0 and 100. Defaults to 0, which keeps every extension. .PARAMETER Top Keep only the N largest extensions, between 1 and 1000. When omitted, every extension that survives the threshold is returned. .PARAMETER Credential Optional PSCredential for authenticating to remote computers. Not used for local queries. .EXAMPLE Show-FolderUsage -Path 'C:\inetpub\wwwroot' Reports usage per file extension for the local wwwroot tree. .EXAMPLE Show-FolderUsage -Path 'C:\inetpub\wwwroot' -ComputerName 'SRV01' Reports usage per file extension for the wwwroot tree on SRV01. .EXAMPLE 'SRV01', 'SRV02' | Show-FolderUsage -Path 'C:\inetpub\wwwroot' Reports per-extension usage for multiple servers via the pipeline. .EXAMPLE Show-FolderUsage -Path 'C:\Logs' -Threshold 5 -Top 20 Shows the 20 largest extensions that make up at least 5 percent of the C:\Logs tree. .OUTPUTS PSWinOps.FolderUsage One object per file extension with the file count, the exact byte and rounded MB size, the share of the tree total, the tree totals (TotalSizeBytes and TotalFileCount, repeated on every row), and the number of unreadable subfolders. The default view renders a fixed-width usage bar. .NOTES Author: Franck SALLET Version: 1.0.0 Last Modified: 2026-09-19 Requires: PowerShell 5.1+ / Windows only Requires: Read access to Path on the target machine. Subfolders the caller cannot read are counted in InaccessibleCount rather than failing the whole tree, so non-elevated callers may under-report totals. This command walks every file in the tree, so runtime grows with the number of files and can be long on very large trees. .LINK https://github.com/k9fr4n/PSWinOps .LINK https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/get-childitem #> [CmdletBinding()] [OutputType('PSWinOps.FolderUsage')] param( [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [Alias('CN', 'Name', 'DNSHostName')] [string[]]$ComputerName = $env:COMPUTERNAME, [Parameter(Mandatory = $false)] [ValidateRange(0, 100)] [double]$Threshold = 0, [Parameter(Mandatory = $false)] [ValidateRange(1, 1000)] [int]$Top, [Parameter(Mandatory = $false)] [ValidateNotNull()] [System.Management.Automation.PSCredential] [System.Management.Automation.Credential()] $Credential ) begin { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting" # The whole aggregation runs here, on the target machine: the tree is # walked, grouped by extension, summed, sorted and truncated remotely so # only the per-extension summary rows travel back over the wire. $scriptBlock = { param($TargetPath, $MinPercent, $MaxRows) $rootItem = Get-Item -LiteralPath $TargetPath -Force -ErrorAction SilentlyContinue if ($null -eq $rootItem) { throw "Path '$TargetPath' does not exist on '$env:COMPUTERNAME'." } if (-not $rootItem.PSIsContainer) { throw "Path '$TargetPath' is a file, not a directory." } $rootPath = $rootItem.FullName # Manual traversal instead of a single -Recurse -File pass so # reparse-point directories (junctions, mount points, symlinks) are # skipped: following them could double-count files or loop. A # subfolder we cannot read raises a non-terminating error that is # counted rather than thrown, so the accessible remainder of the # tree is still reported. $files = [System.Collections.Generic.List[object]]::new() $inaccessible = [long]0 $directoryStack = [System.Collections.Generic.Stack[string]]::new() $directoryStack.Push($rootPath) while ($directoryStack.Count -gt 0) { $directoryPath = $directoryStack.Pop() $directoryErrors = $null $items = Get-ChildItem -LiteralPath $directoryPath -Force ` -ErrorAction SilentlyContinue -ErrorVariable directoryErrors $inaccessible += [long]@($directoryErrors).Count foreach ($item in $items) { if ($item.PSIsContainer) { $reparsePoint = [int][System.IO.FileAttributes]::ReparsePoint if (([int]$item.Attributes -band $reparsePoint) -eq $reparsePoint) { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Skipping reparse-point directory '$($item.FullName)'." continue } $directoryStack.Push($item.FullName) } else { $files.Add($item) } } } $stats = @{} $totalBytes = [long]0 $totalFiles = [long]0 foreach ($file in $files) { $length = [long]$file.Length $totalBytes += $length $totalFiles++ $extension = if ([string]::IsNullOrEmpty($file.Extension)) { '(none)' } else { $file.Extension.ToLowerInvariant() } if ($stats.ContainsKey($extension)) { $stats[$extension].FileCount = [long]$stats[$extension].FileCount + 1 $stats[$extension].SizeBytes = [long]$stats[$extension].SizeBytes + $length } else { $stats[$extension] = [PSCustomObject]@{ FileCount = [long]1 SizeBytes = $length } } } if ($totalFiles -eq 0) { Write-Verbose -Message "[$($MyInvocation.MyCommand)] The folder '$rootPath' contained no files." } $rows = foreach ($extension in $stats.Keys) { $sizeBytes = [long]$stats[$extension].SizeBytes # An empty (or fully unreadable) tree totals zero bytes: report # 0 percent instead of dividing by zero. $percentOfTotal = [double]0 if ($totalBytes -gt 0) { $percentOfTotal = [math]::Round(($sizeBytes / $totalBytes) * 100, 2) } [PSCustomObject]@{ Path = $rootPath Extension = $extension FileCount = [long]$stats[$extension].FileCount SizeBytes = $sizeBytes SizeMB = [math]::Round($sizeBytes / 1MB, 2) PercentOfTotal = $percentOfTotal TotalSizeBytes = $totalBytes TotalFileCount = $totalFiles InaccessibleCount = [long]$inaccessible } } $rows = @($rows | Where-Object -FilterScript { $_.PercentOfTotal -ge $MinPercent } | Sort-Object -Property 'SizeBytes' -Descending) if ($MaxRows -gt 0) { $rows = @($rows | Select-Object -First $MaxRows) } $rows } } process { foreach ($machine in $ComputerName) { try { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Measuring '$Path' on '$machine'" $rows = @(Invoke-RemoteOrLocal -ComputerName $machine -ScriptBlock $scriptBlock ` -ArgumentList @($Path, $Threshold, $Top) -Credential $Credential) foreach ($row in $rows) { [PSCustomObject]@{ PSTypeName = 'PSWinOps.FolderUsage' ComputerName = $machine Path = $row.Path Extension = $row.Extension FileCount = [long]$row.FileCount SizeBytes = [long]$row.SizeBytes SizeMB = [double]$row.SizeMB PercentOfTotal = [double]$row.PercentOfTotal TotalSizeBytes = [long]$row.TotalSizeBytes TotalFileCount = [long]$row.TotalFileCount InaccessibleCount = [long]$row.InaccessibleCount Timestamp = Get-Date -Format 'o' } } } catch { Write-Error -Message "[$($MyInvocation.MyCommand)] Failed on '${machine}': $_" continue } } } end { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed" } } |