Public/disk/Show-FolderUsageAge.ps1

#Requires -Version 5.1
function Show-FolderUsageAge {
    <#
    .SYNOPSIS
        Shows folder tree disk usage by file age with a usage bar
 
    .DESCRIPTION
        Walks one folder tree per target computer and emits one
        PSWinOps.FolderUsageAge object for each of seven fixed age buckets,
        oldest first, always returning all seven even when empty. Files are
        bucketed by their LastWriteTime by default, and the scan runs on the
        target machine through Invoke-RemoteOrLocal so only the seven summary
        rows cross the wire. Age bucket bounds are half-open: MinAgeDays is
        inclusive and MaxAgeDays exclusive, with [int]::MaxValue as MaxAgeDays
        on the unbounded oldest bucket, and future-dated files are clamped
        into the newest bucket.
        Unreadable subfolders are counted in InaccessibleCount, 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.
 
    .PARAMETER Property
        Which file timestamp drives the age bucket: LastWriteTime (the
        default), CreationTime, or LastAccessTime.
 
    .PARAMETER ComputerName
        One or more computer names to query. Defaults to the local computer.
        Accepts pipeline input by value and by property name.
 
    .PARAMETER Credential
        Optional PSCredential for authenticating to remote computers.
        Not used for local queries.
 
    .EXAMPLE
        Show-FolderUsageAge -Path 'C:\Logs'
 
        Reports the seven age buckets for the local C:\Logs tree.
 
    .EXAMPLE
        Show-FolderUsageAge -Path 'C:\Logs' -ComputerName 'SRV01'
 
        Reports the seven age buckets for C:\Logs on SRV01.
 
    .EXAMPLE
        'SRV01', 'SRV02' | Show-FolderUsageAge -Path 'C:\Logs'
 
        Reports age buckets for multiple servers via the pipeline.
 
    .EXAMPLE
        Show-FolderUsageAge -Path 'C:\Archive' -Property CreationTime
 
        Buckets C:\Archive by file creation date instead of last write time.
 
    .OUTPUTS
        PSWinOps.FolderUsageAge
        Seven objects per tree, one per age bucket, ordered oldest first, each
        carrying the AgeBucket label, its inclusive MinAgeDays and exclusive
        MaxAgeDays bounds, 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), the AgeProperty the buckets were
        computed from, 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.
        Note: LastAccessTime is unreliable on Windows — NTFS last-access
        updates are disabled by default (NtfsDisableLastAccessUpdate), so
        buckets computed from it may reflect the last write instead.
 
    .LINK
        https://github.com/k9fr4n/PSWinOps
 
    .LINK
        https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/get-childitem
    #>

    [CmdletBinding()]
    [OutputType('PSWinOps.FolderUsageAge')]
    param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Path,

        [Parameter(Mandatory = $false)]
        [ValidateSet('LastWriteTime', 'CreationTime', 'LastAccessTime')]
        [string]$Property = 'LastWriteTime',

        [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('CN', 'Name', 'DNSHostName')]
        [string[]]$ComputerName = $env:COMPUTERNAME,

        [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 and the seven buckets are summed remotely, so only the summary
        # rows travel back over the wire.
        $scriptBlock = {
            param($TargetPath, $AgeProperty)

            $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

            # One clock reading for the whole scan, so every file is aged
            # against the same instant.
            $now = Get-Date

            # The seven buckets, oldest first, always emitted in this order even
            # when empty. MinAgeDays is inclusive, MaxAgeDays exclusive;
            # [int]::MaxValue marks the unbounded oldest bucket.
            $buckets = @(
                [PSCustomObject]@{ AgeBucket = '>2y'; MinAgeDays = [int]730; MaxAgeDays = [int]::MaxValue; FileCount = [long]0; SizeBytes = [long]0 }
                [PSCustomObject]@{ AgeBucket = '1-2y'; MinAgeDays = [int]365; MaxAgeDays = [int]730; FileCount = [long]0; SizeBytes = [long]0 }
                [PSCustomObject]@{ AgeBucket = '180-365d'; MinAgeDays = [int]180; MaxAgeDays = [int]365; FileCount = [long]0; SizeBytes = [long]0 }
                [PSCustomObject]@{ AgeBucket = '90-180d'; MinAgeDays = [int]90; MaxAgeDays = [int]180; FileCount = [long]0; SizeBytes = [long]0 }
                [PSCustomObject]@{ AgeBucket = '30-90d'; MinAgeDays = [int]30; MaxAgeDays = [int]90; FileCount = [long]0; SizeBytes = [long]0 }
                [PSCustomObject]@{ AgeBucket = '7-30d'; MinAgeDays = [int]7; MaxAgeDays = [int]30; FileCount = [long]0; SizeBytes = [long]0 }
                [PSCustomObject]@{ AgeBucket = '0-7d'; MinAgeDays = [int]0; MaxAgeDays = [int]7; FileCount = [long]0; SizeBytes = [long]0 }
            )

            # 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)
                    }
                }
            }

            $totalBytes = [long]0
            $totalFiles = [long]0

            foreach ($file in $files) {
                $length = [long]$file.Length
                $totalBytes += $length
                $totalFiles++

                $ageDays = ($now - [datetime]$file.$AgeProperty).TotalDays
                if ($ageDays -lt 0) {
                    # A future-dated timestamp (clock skew, restored archive)
                    # clamps into the newest bucket instead of falling through.
                    $ageDays = [double]0
                }

                foreach ($bucket in $buckets) {
                    $upperBound = $bucket.MaxAgeDays
                    if ($ageDays -ge $bucket.MinAgeDays -and $ageDays -lt $upperBound) {
                        $bucket.FileCount = [long]$bucket.FileCount + 1
                        $bucket.SizeBytes = [long]$bucket.SizeBytes + $length
                        break
                    }
                }
            }

            if ($totalFiles -eq 0) {
                Write-Verbose -Message "[$($MyInvocation.MyCommand)] The folder '$rootPath' contained no files."
            }

            $rows = foreach ($bucket in $buckets) {
                $sizeBytes = [long]$bucket.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
                    AgeBucket         = $bucket.AgeBucket
                    MinAgeDays        = [int]$bucket.MinAgeDays
                    MaxAgeDays        = [int]$bucket.MaxAgeDays
                    FileCount         = [long]$bucket.FileCount
                    SizeBytes         = $sizeBytes
                    SizeMB            = [math]::Round($sizeBytes / 1MB, 2)
                    PercentOfTotal    = $percentOfTotal
                    TotalSizeBytes    = $totalBytes
                    TotalFileCount    = $totalFiles
                    AgeProperty       = $AgeProperty
                    InaccessibleCount = [long]$inaccessible
                }
            }

            $rows
        }
    }

    process {
        foreach ($machine in $ComputerName) {
            try {
                Write-Verbose -Message "[$($MyInvocation.MyCommand)] Measuring '$Path' on '$machine' by $Property"

                $rows = @(Invoke-RemoteOrLocal -ComputerName $machine -ScriptBlock $scriptBlock `
                        -ArgumentList @($Path, $Property) -Credential $Credential)

                foreach ($row in $rows) {
                    [PSCustomObject]@{
                        PSTypeName        = 'PSWinOps.FolderUsageAge'
                        ComputerName      = $machine
                        Path              = $row.Path
                        AgeBucket         = $row.AgeBucket
                        MinAgeDays        = [int]$row.MinAgeDays
                        MaxAgeDays        = [int]$row.MaxAgeDays
                        FileCount         = [long]$row.FileCount
                        SizeBytes         = [long]$row.SizeBytes
                        SizeMB            = [double]$row.SizeMB
                        PercentOfTotal    = [double]$row.PercentOfTotal
                        TotalSizeBytes    = [long]$row.TotalSizeBytes
                        TotalFileCount    = [long]$row.TotalFileCount
                        AgeProperty       = $row.AgeProperty
                        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"
    }
}