Private/Show-AACStorageSizeView.ps1

function Show-AACStorageSizeView {
    <#
    .SYNOPSIS
        Renders Get-AACStorageAccountContainerSize's report
        (ConvertTo-AACStorageSizeReport) as a Spectre.Console view.
    .DESCRIPTION
        The scope; tiles (storage accounts, containers, blobs, size, the
        largest container, what couldn't be read); the size by access tier
        as one proportion bar with its legend; the largest containers as a
        bar chart; every subscription, storage account and container as a
        tree - each container with a size bar against the account's largest,
        coloured by the tier most of it is in; the largest blobs; and what
        couldn't be read, with what to do. Tiers: Hot orange, Cool blue, Cold
        steel blue, Archive purple, no tier grey. Output goes straight to the
        Spectre console; wrap the call in Invoke-AACPagedOutput to page it.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Report,

        [System.Collections.IDictionary] $Scope,

        # Containers shown per storage account in the tree.
        [int] $ContainersPerAccount = 25,

        # Blobs in the largest-blobs table.
        [int] $LargestBlobs = 15
    )

    $escape = { param($Text) [Spectre.Console.Markup]::Escape([string]$Text) }
    $glyph = Get-AACGlyph
    $unicode = [Spectre.Console.AnsiConsole]::Profile.Capabilities.Unicode
    $stats = $Report.Stats
    $tierColor = [ordered]@{ Hot = 'orangered1'; Cool = 'deepskyblue1'; Cold = 'steelblue1'; Archive = 'mediumpurple2'; None = 'grey62' }
    $tierLabel = @{ Hot = 'Hot'; Cool = 'Cool'; Cold = 'Cold'; Archive = 'Archive'; None = 'No tier' }
    $size = { param([double] $Bytes) Format-AACByteSize -Bytes $Bytes }
    $full = if ($unicode) { [string][char]0x2588 } else { '#' }
    $empty = if ($unicode) { [string][char]0x2591 } else { '.' }
    $bar = {
        param([double] $Share, [string] $Color, [int] $Width = 12)
        $filled = [int][Math]::Round([Math]::Max(0, [Math]::Min(1, $Share)) * $Width)
        if ($Share -gt 0 -and $filled -eq 0) { $filled = 1 }
        "[$Color]$($full * $filled)[/][grey23]$($empty * ($Width - $filled))[/]"
    }
    # The tier holding most of a container's bytes.
    $dominant = {
        param($Row)
        $best = 'None'; $most = [long]-1
        foreach ($name in 'Hot', 'Cool', 'Cold', 'Archive') { if ($Row."Size$name" -gt $most) { $best = $name; $most = $Row."Size$name" } }
        if ($Row.SizeNoTier -gt $most) { $best = 'None'; $most = $Row.SizeNoTier }
        @{ Tier = $best; Share = $(if ($Row.Size) { $most / $Row.Size } else { 0 }) }
    }
    $sum = { param([object[]] $Items, [string] $Property) $total = [long]0; foreach ($item in $Items) { $total += [long]$item.$Property }; $total }
    $blobCount = { param([long] $Count) if ($Count -eq 1) { '1 blob' } else { '{0:N0} blobs' -f $Count } }
    # '30 Sept' this year, 'Jun 2025' before: short enough for a narrow terminal.
    $when = { param($Date) $local = ([datetime]$Date).ToLocalTime(); if ($local.Year -eq (Get-Date).Year) { $local.ToString('d MMM') } else { $local.ToString('MMM yyyy') } }
    $statusColor = @{ OK = 'green3'; Partial = 'orange1'; Failed = 'red1'; Empty = 'grey58'; 'No blob service' = 'grey50' }

    $facts = [System.Collections.Generic.List[string]]::new()
    if ($script:AACSession) { $facts.Add("[white]$(& $escape $script:AACSession.Account)[/]") }
    if ($Scope) { foreach ($key in $Scope.Keys) { $facts.Add("$(& $escape $key): $(& $escape $Scope[$key])") } }
    $facts.Add((Get-Date).ToString('d MMM yyyy HH:mm'))
    Write-AACMarkup "[grey58]$($facts -join " $($glyph.Dot) ")[/]"
    [Spectre.Console.AnsiConsole]::WriteLine()

    if (-not @($Report.Accounts).Count) {
        Show-AACPanel -Content '[bold]No storage accounts[/] [grey58]were found in this scope.[/]' -BorderColor 'grey50' -AllowMarkup
        return
    }

    # --- Tiles ------------------------------------------------------------------------------------------
    $biggest = $stats.LargestContainer
    $notRead = $stats.Failed + $stats.Partial
    Show-AACTileRow -Tile @(
        @{ Value = '{0:N0}' -f $stats.Accounts; Caption = 'storage accounts'; Color = 'deepskyblue1' }
        @{ Value = '{0:N0}' -f $stats.Containers; Caption = $(if ($stats.Empty) { "containers ($('{0:N0}' -f $stats.Empty) empty)" } else { 'containers' }); Color = 'mediumpurple2' }
        @{ Value = '{0:N0}' -f $stats.Blobs; Caption = 'blobs'; Color = 'turquoise2' }
        @{ Value = (& $size $stats.Bytes); Caption = $(if ($stats.TotalBytes -gt $stats.Bytes) { "stored ($(& $size $stats.TotalBytes) with snapshots, versions, deleted)" } else { 'stored' }); Color = 'springgreen2' }
        @{ Value = $(if ($biggest -and $biggest.Size) { & $size $biggest.Size } else { '-' }); Caption = $(if ($biggest -and $biggest.Size) { "largest: $($biggest.StorageAccount)/$($biggest.Container)" } else { 'largest container' }); Color = 'gold1' }
        @{ Value = '{0:N0}' -f $notRead; Caption = 'not read in full'; Color = $(if ($notRead) { 'red1' } else { 'green3' }) }
    )
    [Spectre.Console.AnsiConsole]::WriteLine()

    # --- Size by access tier ------------------------------------------------------------------------------
    if ($stats.Bytes -gt 0) {
        $slices = [ordered]@{}
        $colors = @{}
        $legend = foreach ($name in $tierColor.Keys) {
            $bytes = [double]$Report.Tiers[$name]
            if ($bytes -le 0) { continue }
            $slices[$tierLabel[$name]] = $bytes
            $colors[$tierLabel[$name]] = $tierColor[$name]
            "[$($tierColor[$name])]$($glyph.Bullet)[/] $($tierLabel[$name]) [bold]$(& $size $bytes)[/] [grey58]$('{0:P0}' -f ($bytes / $stats.Bytes))[/]"
        }
        Show-AACBreakdownChart -Data $slices -Color $colors -Title 'Size by access tier' -Width 100 -HideTags
        Write-AACMarkup ($legend -join ' ')
        if ($stats.Snapshots -or $stats.Versions -or $stats.Deleted) {
            Write-AACMarkup "[grey58]Not in the bar: $('{0:N0}' -f $stats.Snapshots) snapshot(s) $(& $size $stats.SnapshotBytes) $($glyph.Dot) $('{0:N0}' -f $stats.Versions) previous version(s) $(& $size $stats.VersionBytes) $($glyph.Dot) $('{0:N0}' -f $stats.Deleted) deleted blob(s) $(& $size $stats.DeletedBytes)[/]"
        }
        [Spectre.Console.AnsiConsole]::WriteLine()
    }

    # --- The largest containers ----------------------------------------------------------------------------
    $top = @($Report.Rows | Where-Object { $null -ne $_.Container -and $_.Size -gt 0 } | Sort-Object -Property Size -Descending | Select-Object -First 12)
    if ($top.Count) {
        $unit = Get-AACByteUnit -Bytes $top[0].Size
        $power = Get-AACByteUnit -Bytes $top[0].Size -AsPower
        $items = @($top | ForEach-Object { @{ Label = "$($_.StorageAccount)/$($_.Container)"; Value = $_.Size / [Math]::Pow(1024, $power); Color = $tierColor[(& $dominant $_).Tier] } })
        Show-AACBarChart -Item $items -Title "Largest containers ($unit, coloured by their main tier)" -Format $(if ($power) { 'N2' } else { 'N0' }) -Suffix " $unit"
        [Spectre.Console.AnsiConsole]::WriteLine()
    }

    # --- Subscriptions, accounts and containers --------------------------------------------------------------
    $rowsByAccount = @{}
    foreach ($row in $Report.Rows) {
        $key = [string]$row.StorageAccountId
        if (-not $rowsByAccount.ContainsKey($key)) { $rowsByAccount[$key] = [System.Collections.Generic.List[object]]::new() }
        if ($null -ne $row.Container) { $rowsByAccount[$key].Add($row) }
    }
    $root = [Spectre.Console.Tree]::new([Spectre.Console.Markup]::new("[bold deepskyblue1]$($glyph.Bullet) Storage[/] [grey58]$(& $size $stats.Bytes) $($glyph.Dot) $('{0:N0}' -f $stats.Blobs) blobs in $('{0:N0}' -f $stats.Containers) containers, $('{0:N0}' -f $stats.Accounts) accounts[/]"))
    $root.Style = [Spectre.Console.Style]::Parse('grey42')
    if (-not $unicode) { $root.Guide = [Spectre.Console.TreeGuide]::Ascii }
    foreach ($subscription in @($Report.Accounts | Group-Object -Property SubscriptionName | Sort-Object -Property @{ Expression = { & $sum $_.Group 'Size' }; Descending = $true }, Name)) {
        $subBytes = & $sum $subscription.Group 'Size'
        $subNode = [Spectre.Console.TreeNode]::new([Spectre.Console.Markup]::new("[bold]$(& $escape $subscription.Name)[/] [springgreen2]$(& $size $subBytes)[/] [grey58]$($glyph.Dot) $($subscription.Count) account(s)[/]"))
        $root.Nodes.Add($subNode)
        foreach ($account in $subscription.Group) {
            $detail = @($account.Kind, $account.Sku, $account.Location, $(if ($account.HierarchicalNamespace) { 'Data Lake' }), $(if ($account.NetworkAccess -ne 'All networks') { $account.NetworkAccess })) | Where-Object { $_ }
            $color = $statusColor[$account.Status]
            $state = switch ($account.Status) {
                'OK' { '' }
                'Empty' { ' [grey58]no containers[/]' }
                'No blob service' { " [grey50]$(& $escape $account.Reason)[/]" }
                default { " [$color]$(& $escape $account.Status): $('{0:N0}' -f $account.Failed) not read[/]" }
            }
            $label = "[bold deepskyblue1]$(& $escape $account.StorageAccount)[/] [grey50]$(& $escape ($detail -join " $($glyph.Dot) "))[/]"
            if ($account.Status -ne 'No blob service') {
                $label += " [bold springgreen2]$(& $size $account.Size)[/] [grey58]$($glyph.Dot) $(& $blobCount $account.BlobCount) $($glyph.Dot) $($account.Containers) container$(if ($account.Containers -ne 1) { 's' })[/]"
            }
            $accountNode = [Spectre.Console.TreeNode]::new([Spectre.Console.Markup]::new($label + $state))
            $subNode.Nodes.Add($accountNode)

            $rows = @(if ($rowsByAccount.ContainsKey([string]$account.StorageAccountId)) { $rowsByAccount[[string]$account.StorageAccountId] })
            if ($account.Status -eq 'Failed' -and -not $rows.Count) {
                $accountNode.Nodes.Add([Spectre.Console.TreeNode]::new([Spectre.Console.Markup]::new("[red1]$($glyph.Cross) $(& $escape $account.Reason)[/]"))) | Out-Null
                continue
            }
            $max = [double]0
            $width = 8
            foreach ($row in $rows) { $max = [Math]::Max($max, [double]$row.Size); $width = [Math]::Max($width, $row.Container.Length) }
            $width = [Math]::Min(36, $width)
            foreach ($row in @($rows | Select-Object -First $ContainersPerAccount)) {
                $name = & $escape $row.Container.PadRight($width)
                if ($row.Status -eq 'Failed') {
                    $code = ($row.Error -split ':')[0]
                    $text = "[grey23]$($empty * 12)[/] [white]$name[/] [red1]$($glyph.Cross) $(& $escape $code)[/]"
                }
                else {
                    $main = & $dominant $row
                    $flags = @(
                        if ($row.BlobCount -eq 0) { '[grey50]empty[/]' }
                        else { "[grey58]$(& $blobCount $row.BlobCount) $($glyph.Dot) [/][$($tierColor[$main.Tier])]$($tierLabel[$main.Tier]) $('{0:P0}' -f $main.Share)[/]" }
                        if ($row.LastModified) { "[grey50]$($glyph.Dot) changed $(& $escape (& $when $row.LastModified))[/]" }
                        if ($row.PublicAccess -and $row.PublicAccess -ne 'None') { "[orange1]$($glyph.Dot) public ($(& $escape $row.PublicAccess))[/]" }
                        if ($row.Status -eq 'Partial') { "[orange1]$($glyph.Dot) partial: $(& $escape (($row.Error -split ':')[0]))[/]" }
                    )
                    $text = "$(& $bar ($(if ($max) { $row.Size / $max } else { 0 })) $tierColor[$main.Tier]) [white]$name[/] [bold]$((& $size $row.Size).PadLeft(11))[/] $($flags -join ' ')"
                }
                $accountNode.Nodes.Add([Spectre.Console.TreeNode]::new([Spectre.Console.Markup]::new($text))) | Out-Null
            }
            if ($rows.Count -gt $ContainersPerAccount) {
                $rest = @($rows | Select-Object -Skip $ContainersPerAccount)
                $accountNode.Nodes.Add([Spectre.Console.TreeNode]::new([Spectre.Console.Markup]::new("[grey50]$($glyph.Chevron) $($rest.Count) more container(s), $(& $size (& $sum $rest 'Size')) - -HtmlPath or -CsvPath lists them all[/]"))) | Out-Null
            }
        }
    }
    [Spectre.Console.AnsiConsole]::Write($root)
    [Spectre.Console.AnsiConsole]::WriteLine()

    # --- The largest blobs ------------------------------------------------------------------------------
    $blobs = @($Report.Largest | Select-Object -First $LargestBlobs)
    if ($blobs.Count) {
        $table = [Spectre.Console.Table]::new()
        $table.Border = [Spectre.Console.TableBorder]::Rounded
        $table.BorderStyle = [Spectre.Console.Style]::Parse('gold1')
        $table.Expand = $true
        $table.Title = [Spectre.Console.TableTitle]::new("[bold]$($glyph.Bullet) Largest blobs[/] [grey58]$($glyph.Dot) the $($blobs.Count) largest[/]")
        foreach ($header in '#', 'Blob', 'Account / container', 'Tier', 'Type', 'Size', 'Changed') {
            $column = [Spectre.Console.TableColumn]::new("[grey62]$header[/]")
            if ($header -in '#', 'Size') { $column.Alignment = [Spectre.Console.Justify]::Right }
            $table.AddColumn($column) | Out-Null
        }
        $rank = 0
        foreach ($blob in $blobs) {
            $rank++
            $cells = @(
                "[grey50]$rank[/]"
                "[white]$(& $escape $blob.Name)[/]"
                "[grey70]$(& $escape $blob.StorageAccount)[/][grey50]/$(& $escape $blob.Container)[/]"
                "[$($tierColor[$(if ($tierColor.Contains($blob.AccessTier)) { $blob.AccessTier } else { 'None' })])]$(& $escape $tierLabel[$(if ($tierLabel.ContainsKey($blob.AccessTier)) { $blob.AccessTier } else { 'None' })])[/]"
                "[grey58]$(& $escape ($blob.BlobType -replace 'Blob$', ''))[/]"
                "[bold]$(& $size $blob.Size)[/]"
                "[grey58]$(if ($blob.LastModified) { & $escape (& $when $blob.LastModified) })[/]"
            )
            [Spectre.Console.TableExtensions]::AddRow($table, [Spectre.Console.Rendering.IRenderable[]]@($cells | ForEach-Object { [Spectre.Console.Markup]::new($_) })) | Out-Null
        }
        [Spectre.Console.AnsiConsole]::Write($table)
        [Spectre.Console.AnsiConsole]::WriteLine()
    }

    # --- What couldn't be read ------------------------------------------------------------------------------
    $failures = @($Report.Failures)
    if ($failures.Count) {
        $lines = [System.Collections.Generic.List[string]]::new()
        foreach ($group in @($failures | Group-Object -Property { ($_.Error -split ':')[0] } | Sort-Object -Property Count -Descending)) {
            $sample = $group.Group[0]
            $where = @($group.Group | ForEach-Object { if ($null -ne $_.Container) { "$($_.StorageAccount)/$($_.Container)" } else { $_.StorageAccount } })
            $shown = ($where | Select-Object -First 4) -join ', '
            if ($where.Count -gt 4) { $shown += " and $($where.Count - 4) more" }
            $lines.Add("[red1]$($glyph.Cross)[/] [bold]$(& $escape $group.Name)[/] [grey58]$($glyph.Dot) $($group.Count) $(if ($group.Count -eq 1) { 'item' } else { 'items' }): $(& $escape $shown)[/]")
            $reason = ($sample.Error -replace '^[^:]*:\s*', '')
            $lines.Add(" [grey70]$(& $escape $reason)[/]")
        }
        Show-AACPanel -Content ($lines -join "`n") -Header ' Not read in full ' -BorderColor 'red1' -AllowMarkup
        [Spectre.Console.AnsiConsole]::WriteLine()
    }

    $elapsed = $stats.Elapsed
    $took = if ($elapsed.TotalMinutes -ge 1) { '{0}m {1:00}s' -f [int][Math]::Floor($elapsed.TotalMinutes), $elapsed.Seconds } else { '{0:N1}s' -f $elapsed.TotalSeconds }
    Write-AACMarkup "[grey42]Read $('{0:N0}' -f $stats.Blobs) blob(s) in $took ($('{0:N0}' -f $stats.BlobsPerSecond) a second). Add -PassThru (or pipe the command) for the rows; -CsvPath, -BlobCsvPath and -HtmlPath write them.[/]"
}