Private/Show-AACInventoryView.ps1

function Show-AACInventoryView {
    <#
    .SYNOPSIS
        Renders the tenant inventory (ConvertTo-AACInventory) as a
        Spectre.Console view: the scope, tiles, the hierarchy as a tree and
        the most common resource types.
    .DESCRIPTION
          Tenant Contoso · 3 subscriptions · 5 resource groups · 17 resources
          ├── MG Platform · 1 subscription · 6 resources
          │ └── MG Connectivity
          │ └── SUB sub-connectivity 11111111-... · Enabled · 1 RG · 6 resources
          │ └── RG rg-hub uksouth · 6 resources · publicipaddresses 2, ...
          └── SUB sub-legacy ...
 
        -Depth stops the tree at management groups, subscriptions, resource
        groups (the default) or resources. Empty resource groups are amber.
        With Defender for Cloud data, each node shows its secure score in
        colour (Good green, Fair amber, Poor red) and its unhealthy findings
        by severity (H red, M amber, L blue); after the tree come the security
        controls with the most to gain and the High-severity recommendations.
        With cost (Get-AACInventory -Cost), a row of cost tiles - month to
        date and last month per currency - each node's cost month to date in
        green (or why it couldn't be read), a 'Deleted resources' line under
        a subscription with such costs, and the resources that cost the most
        this month.
        With insights (Get-AACInventory -Insight): tiles, the estate's mix as
        proportion charts (VM sizes, operating systems, power states, Azure
        and Arc, storage replication, database tiers), tag coverage, and
        what needs attention - unattached disks, unused public IPs and NICs,
        VMs stopped but billed, classic resources, nearly full subnets,
        connections down - with each one's cost this month when cost was
        read.
        In a console that isn't UTF-8 the tree is drawn in ASCII. Output goes
        straight to the Spectre console; wrap the call in
        Invoke-AACPagedOutput to page it.
    #>

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

        [ValidateSet('ManagementGroup', 'Subscription', 'ResourceGroup', 'Resource')]
        [string] $Depth = 'ResourceGroup',

        [System.Collections.IDictionary] $Scope
    )

    $escape = { param($Text) [Spectre.Console.Markup]::Escape([string]$Text) }
    $glyph = Get-AACGlyph
    $stats = $Inventory.Stats
    $unicode = [Spectre.Console.AnsiConsole]::Profile.Capabilities.Unicode

    $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()

    $ratingColor = @{ Good = 'green3'; Fair = 'orange1'; Poor = 'red1' }
    $tiles = @(
        @{ Value = '{0:N0}' -f $stats.ManagementGroups; Caption = 'management groups'; Color = 'mediumpurple2' }
        @{ Value = '{0:N0}' -f $stats.Subscriptions; Caption = 'subscriptions'; Color = 'gold1' }
        @{ Value = '{0:N0}' -f $stats.ResourceGroups; Caption = 'resource groups'; Color = 'deepskyblue1' }
        @{ Value = '{0:N0}' -f $stats.Resources; Caption = 'resources'; Color = 'green3' }
    )
    if ($stats.HasSecurity -and $null -ne $stats.SecureScore) {
        $tiles += @{ Value = "$($stats.SecureScore)%"; Caption = "secure score ($($stats.Rating.ToLowerInvariant()))"; Color = $ratingColor[$stats.Rating] }
        $tiles += @{ Value = '{0:N0}' -f $stats.High; Caption = 'high-severity findings'; Color = $(if ($stats.High) { 'red1' } else { 'green3' }) }
    }
    else {
        $tiles += @{ Value = '{0:N0}' -f $stats.Types; Caption = 'types'; Color = 'grey70' }
        $tiles += @{ Value = '{0:N0}' -f $stats.Locations; Caption = 'locations'; Color = 'grey70' }
    }
    Show-AACTileRow -Tile $tiles
    [Spectre.Console.AnsiConsole]::WriteLine()
    $money = { param($Value, [string] $Currency) ('{0:N2} {1}' -f [double]$Value, $Currency).Trim() }
    if ($stats.HasCost) {
        $costTiles = @(foreach ($total in @($stats.Cost | Select-Object -First 2)) {
                @{ Value = (& $money $total.MonthToDate $total.Currency); Caption = 'month to date'; Color = 'springgreen2' }
                @{ Value = (& $money $total.LastMonth $total.Currency); Caption = 'last month'; Color = 'deepskyblue1' }
            })
        $deletedItems = @($Inventory.Items | Where-Object Level -EQ 'DeletedResources')
        if ($deletedItems.Count -and @($stats.Cost).Count -eq 1) {
            $deletedTotal = 0.0
            foreach ($item in $deletedItems) { $deletedTotal += [double]$item.CostMonthToDate }
            $costTiles += @{ Value = (& $money $deletedTotal $stats.Cost[0].Currency); Caption = 'deleted resources this month'; Color = 'orange1' }
        }
        $unread = @($Inventory.Items | Where-Object { $_.Level -eq 'Subscription' -and $_.CostStatus -notin 'OK', 'No cost', '' }).Count
        if ($unread) { $costTiles += @{ Value = '{0:N0}' -f $unread; Caption = 'subscriptions without cost data'; Color = 'orange1' } }
        if ($costTiles.Count) {
            Show-AACTileRow -Tile $costTiles
            [Spectre.Console.AnsiConsole]::WriteLine()
        }
        else {
            Write-AACMarkup '[grey58]Cost Management reports no cost for this scope this month or last.[/]'
            [Spectre.Console.AnsiConsole]::WriteLine()
        }
    }

    $order = @{ Tenant = 0; ManagementGroup = 1; Subscription = 2; ResourceGroup = 3; DeletedResources = 3; Resource = 4 }
    $stop = $order[$Depth]
    $count = {
        param([int] $Value, [string] $One, [string] $Many)
        "[white]$('{0:N0}' -f $Value)[/] $(if ($Value -eq 1) { $One } else { $Many })"
    }
    # A node's security posture: its score in colour and its findings by severity.
    $posture = {
        param($Node)
        $parts = [System.Collections.Generic.List[string]]::new()
        if ($null -ne $Node.SecureScore) { $parts.Add("[$($ratingColor[$Node.Rating])]$($Node.SecureScore)%[/]") }
        if ($Node.High) { $parts.Add("[red1]H$($Node.High)[/]") }
        if ($Node.Medium) { $parts.Add("[orange1]M$($Node.Medium)[/]") }
        if ($Node.Low) { $parts.Add("[deepskyblue1]L$($Node.Low)[/]") }
        if ($parts.Count) { ' ' + ($parts -join ' ') } else { '' }
    }
    $label = {
        param($Node)
        $name = "[bold]$(& $escape $Node.DisplayName)[/]"
        switch ($Node.Level) {
            'Tenant' {
                "[grey70 on grey23] TENANT [/] $name [grey50]$(& $escape $Node.Name)[/] [grey58]$((@((& $count $Node.ManagementGroups 'management group' 'management groups'), (& $count $Node.Subscriptions 'subscription' 'subscriptions'), (& $count $Node.ResourceGroups 'resource group' 'resource groups'), (& $count $Node.Resources 'resource' 'resources'))) -join " $($glyph.Dot) ")[/]"
            }
            'ManagementGroup' {
                "[mediumpurple2]MG[/] $name [grey50]$(& $escape $Node.Name)[/] [grey58]$((@((& $count $Node.Subscriptions 'subscription' 'subscriptions'), (& $count $Node.Resources 'resource' 'resources'))) -join " $($glyph.Dot) ")[/]"
            }
            'Subscription' {
                $state = if ($Node.State -and $Node.State -ne 'Enabled') { " [orange1]$(& $escape $Node.State)[/]" } else { '' }
                "[gold1]SUB[/] $name$state [grey50]$(& $escape $Node.SubscriptionId)[/] [grey58]$((@((& $count $Node.ResourceGroups 'resource group' 'resource groups'), (& $count $Node.Resources 'resource' 'resources'))) -join " $($glyph.Dot) ")[/]"
            }
            'ResourceGroup' {
                if ($Node.Resources -eq 0) {
                    "[deepskyblue1]RG[/] [orange1]$(& $escape $Node.DisplayName)[/] [grey50]$(& $escape $Node.Location)[/] [orange1]empty[/]"
                }
                else {
                    "[deepskyblue1]RG[/] $name [grey50]$(& $escape $Node.Location)[/] [grey58]$(& $count $Node.Resources 'resource' 'resources')$(if ($Node.TopTypes) { " $($glyph.Dot) $(& $escape $Node.TopTypes)" })[/]"
                }
            }
            'DeletedResources' {
                "[orange1]DEL[/] [orange1]$(& $escape $Node.DisplayName)[/] [grey58]$(& $escape $Node.Detail)[/]"
            }
            'Resource' {
                "[green3]$(& $escape $Node.DisplayName)[/] [grey58]$(& $escape ($Node.Type -replace '^microsoft\.', ''))$(if ($Node.Location) { " $($glyph.Dot) $(& $escape $Node.Location)" })$(if ($Node.Sku) { " $($glyph.Dot) $(& $escape $Node.Sku)" })[/]"
            }
        }
    }

    # A node's cost month to date (with -Cost): green, 'mixed' across
    # currencies, or why a subscription's couldn't be read.
    $spend = {
        param($Node)
        if (-not $stats.HasCost) { return '' }
        if ($Node.Level -eq 'Subscription' -and $Node.CostStatus -notin 'OK', 'No cost', '') { return " [orange1]cost not read[/]" }
        if ($Node.Currency -eq 'mixed') { return " [grey58]cost in several currencies[/]" }
        if ($Node.CostMonthToDate -gt 0 -or $Node.CostLastMonth -gt 0) {
            return " [springgreen2]$(& $escape (& $money $Node.CostMonthToDate $Node.Currency))[/][grey50] MTD[/]$(if ($Node.Level -ne 'Resource') { "[grey42] $($glyph.Dot) last month $(& $escape (& $money $Node.CostLastMonth $Node.Currency))[/]" })"
        }
        if ($Node.Level -eq 'Subscription') { return ' [grey50]no cost[/]' }
        ''
    }
    $withPosture = { param($Node) (& $label $Node) + (& $spend $Node) + (& $posture $Node) }
    $tree = [Spectre.Console.Tree]::new([Spectre.Console.Markup]::new((& $withPosture $Inventory.Root)))
    $tree.Style = [Spectre.Console.Style]::Parse('grey42')
    if (-not $unicode) { $tree.Guide = [Spectre.Console.TreeGuide]::Ascii }
    $add = {
        param($Parent, $Node)
        foreach ($child in $Node.Children) {
            if ($order[$child.Level] -gt $stop) { continue }
            $childNode = [Spectre.Console.TreeNode]::new([Spectre.Console.Markup]::new((& $withPosture $child)))
            $Parent.Nodes.Add($childNode)
            & $add $childNode $child
        }
    }
    & $add $tree $Inventory.Root
    [Spectre.Console.AnsiConsole]::Write($tree)
    [Spectre.Console.AnsiConsole]::WriteLine()

    # The most common resource types.
    $types = @($Inventory.Root.TypeCounts.GetEnumerator() | Sort-Object -Property @{ Expression = { $_.Value }; Descending = $true }, Name | Select-Object -First 10 | ForEach-Object {
            @{ Label = ($_.Name -replace '^microsoft\.', ''); Value = $_.Value }
        })
    if ($types.Count) {
        Show-AACBarChart -Item $types -Title 'Most common resource types'
        [Spectre.Console.AnsiConsole]::WriteLine()
    }
    # Cost: the resources that cost the most this month.
    if ($stats.HasCost -and @($Inventory.TopSpend).Count) {
        $currency = @($stats.Cost)[0].Currency
        $bars = @($Inventory.TopSpend | Where-Object Currency -EQ $currency | Select-Object -First 10 | ForEach-Object { @{ Label = "$($_.Name) ($($_.ResourceGroup))"; Value = $_.CostMonthToDate } })
        if ($bars.Count) {
            Show-AACBarChart -Item $bars -Title "Top spend this month ($currency)" -Format 'N2'
            [Spectre.Console.AnsiConsole]::WriteLine()
        }
    }
    # --- Insights (Get-AACInventory -Insight) ------------------------------------------------------
    $insight = $Inventory['Insight']
    if ($insight) {
        $istats = $insight.Stats
        Write-AACRule -Title '[bold]Insights[/]' -Color 'grey50'
        Show-AACTileRow -Tile @(
            @{ Value = '{0:N0}' -f $istats.AzureVms; Caption = 'Azure VMs'; Color = 'deepskyblue1' }
            @{ Value = '{0:N0}' -f $istats.ArcServers; Caption = 'Azure Arc servers'; Color = 'mediumpurple2' }
            @{ Value = '{0:N0}' -f $istats.StoppedBilled; Caption = 'VMs stopped but billed'; Color = $(if ($istats.StoppedBilled) { 'orange1' } else { 'green3' }) }
            @{ Value = "$('{0:N0}' -f $istats.UnattachedDisks) ($('{0:N0}' -f $istats.UnattachedDiskGb) GB)"; Caption = 'unattached disks'; Color = $(if ($istats.UnattachedDisks) { 'orange1' } else { 'green3' }) }
            @{ Value = '{0:N0}' -f ($istats.UnusedPublicIps + $istats.UnusedNics); Caption = 'unused public IPs and NICs'; Color = $(if ($istats.UnusedPublicIps + $istats.UnusedNics) { 'orange1' } else { 'green3' }) }
            if ($null -ne $istats.WasteCost) { @{ Value = (& $money $istats.WasteCost $istats.WasteCurrency); Caption = 'their cost this month'; Color = 'red1' } }
            if ($istats.Classic) { @{ Value = '{0:N0}' -f $istats.Classic; Caption = 'classic resources (retired)'; Color = 'red1' } }
        )
        [Spectre.Console.AnsiConsole]::WriteLine()
        # The mix: each breakdown as one proportion bar (the top 8, the rest as 'other').
        $titles = [ordered]@{ VmSizes = 'VM sizes'; OsVersions = 'Operating systems'; PowerStates = 'VM power states'; Hybrid = 'Azure VMs and Azure Arc servers'; StorageReplication = 'Storage account replication'; DatabaseTiers = 'Database tiers' }
        foreach ($key in $titles.Keys) {
            $items = @($insight.Breakdowns[$key])
            if (-not $items.Count) { continue }
            $slices = [ordered]@{}
            foreach ($item in @($items | Select-Object -First 8)) { $slices[[string]$item.Label] = $item.Value }
            $rest = 0; foreach ($item in @($items | Select-Object -Skip 8)) { $rest += $item.Value }
            if ($rest) { $slices['other'] = $rest }
            $colors = @{}
            if ($key -eq 'PowerStates') { $colors = @{ 'Running' = 'green3'; 'Deallocated' = 'grey50'; 'Stopped (still billed)' = 'orange1' } }
            Show-AACBreakdownChart -Data $slices -Title $titles[$key] -Color $colors -Width 120
            [Spectre.Console.AnsiConsole]::WriteLine()
        }
        $coverage = @($insight.Breakdowns.TagCoverage)
        if ($coverage.Count) {
            $required = @($istats.RequiredTags).Count
            $bars = @($coverage | ForEach-Object { @{ Label = $_.Label; Value = $_.Value; Color = $(if ($_.Value -ge 90) { 'green3' } elseif ($_.Value -ge 60) { 'orange1' } else { 'red1' }) } })
            Show-AACBarChart -Item $bars -Title $(if ($required) { "Required tags: resources with each (%) $($glyph.Dot) $($istats.TagCompliant) of $($istats.Resources) have them all" } else { 'Tag coverage: resources with each of the most used tags (%)' })
            [Spectre.Console.AnsiConsole]::WriteLine()
        }
        # What needs attention.
        $attention = @($insight.Findings)
        if ($attention.Count) {
            $table = [Spectre.Console.Table]::new()
            $table.Border = [Spectre.Console.TableBorder]::Rounded
            $table.BorderStyle = [Spectre.Console.Style]::Parse('orange1')
            $table.Expand = $true
            $table.Title = [Spectre.Console.TableTitle]::new("[bold orange1]$($glyph.Bullet) Needs attention[/] [grey58]$($glyph.Dot) $($attention.Count) item(s)[/]")
            foreach ($header in 'Severity', 'Finding', 'Resource', 'Where', 'Detail', 'Cost this month') {
                $column = [Spectre.Console.TableColumn]::new("[grey62]$header[/]")
                if ($header -eq 'Cost this month') { $column.Alignment = [Spectre.Console.Justify]::Right; $column.NoWrap = $true }
                $table.AddColumn($column) | Out-Null
            }
            $severityColor = @{ High = 'red1'; Medium = 'orange1'; Low = 'deepskyblue1' }
            foreach ($finding in $attention) {
                $cells = @(
                    "[$($severityColor[$finding.Severity])]$(& $escape $finding.Severity)[/]"
                    "[bold]$(& $escape $finding.Finding)[/]"
                    "[white]$(& $escape $finding.Resource)[/]"
                    "[grey58]$(& $escape (@($finding.ResourceGroup, $finding.SubscriptionName) | Where-Object { $_ }) -join ' / ')[/]"
                    "[grey70]$(& $escape $finding.Detail)[/]"
                    $(if ($null -ne $finding.CostMonthToDate) { "[orange1]$(& $escape (& $money $finding.CostMonthToDate $finding.Currency))[/]" } else { '' })
                )
                [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()
        }
        else {
            Write-AACMarkup "[green3]$($glyph.Bullet)[/] [grey70]Nothing needs attention: no unattached disks, unused public IPs or NICs, billed stopped VMs, classic resources, full subnets or connections down.[/]"
            [Spectre.Console.AnsiConsole]::WriteLine()
        }
        $links = @($insight.Connections)
        if ($links.Count) {
            Write-AACMarkup "[bold]$($glyph.Bullet) VPN and ExpressRoute[/]"
            foreach ($link in $links) {
                $ok = $link.Status -in 'Connected', 'Provisioned'
                Write-AACMarkup " $(if ($ok) { "[green3]$($glyph.Bullet)[/]" } else { "[red1]$($glyph.Bullet)[/]" }) [white]$(& $escape $link.Name)[/] [grey58]$(& $escape $link.Kind) $($glyph.Dot) $(& $escape $link.Status) $($glyph.Dot) $(& $escape $link.Detail)[/]"
            }
            [Spectre.Console.AnsiConsole]::WriteLine()
        }
    }

    # Security: the controls with the most to gain, and the High findings.
    $controls = @($Inventory.Controls | Where-Object { $_.PotentialIncrease -gt 0 } | Sort-Object -Property @{ Expression = 'PotentialIncrease'; Descending = $true }, Control | Select-Object -First 8)
    if ($controls.Count) {
        $table = [Spectre.Console.Table]::new()
        $table.Border = [Spectre.Console.TableBorder]::Rounded
        $table.BorderStyle = [Spectre.Console.Style]::Parse('grey42')
        $table.Title = [Spectre.Console.TableTitle]::new("[bold]$($glyph.Bullet) Security controls with the most to gain[/] [grey58]$($glyph.Dot) potential secure score increase[/]")
        foreach ($header in 'Control', 'Subscription', 'Score', 'Unhealthy', 'Potential increase') {
            $column = [Spectre.Console.TableColumn]::new("[grey62]$header[/]")
            if ($header -in 'Score', 'Unhealthy', 'Potential increase') { $column.Alignment = [Spectre.Console.Justify]::Right }
            $table.AddColumn($column) | Out-Null
        }
        foreach ($control in $controls) {
            $rating = if ($null -eq $control.Score) { '' } elseif ($control.Score -ge 70) { 'Good' } elseif ($control.Score -ge 40) { 'Fair' } else { 'Poor' }
            $cells = @(
                [Spectre.Console.Markup]::new("[white]$(& $escape $control.Control)[/]")
                [Spectre.Console.Markup]::new("[grey70]$(& $escape $control.SubscriptionName)[/]")
                [Spectre.Console.Markup]::new($(if ($rating) { "[$($ratingColor[$rating])]$($control.Score)%[/]" } else { '' }))
                [Spectre.Console.Markup]::new("[grey70]$($control.UnhealthyResources)[/]")
                [Spectre.Console.Markup]::new("[bold $(if ($control.PotentialIncrease -ge 5) { 'red1' } elseif ($control.PotentialIncrease -ge 2) { 'orange1' } else { 'deepskyblue1' })]+$($control.PotentialIncrease)%[/]")
            )
            [Spectre.Console.TableExtensions]::AddRow($table, [Spectre.Console.Rendering.IRenderable[]]$cells) | Out-Null
        }
        [Spectre.Console.AnsiConsole]::Write($table)
        [Spectre.Console.AnsiConsole]::WriteLine()
    }
    $high = @($Inventory.Recommendations | Where-Object Severity -EQ 'High')
    if ($high.Count) {
        Write-AACMarkup "[bold red1]$($glyph.Bullet) High-severity findings ($($high.Count))[/]"
        foreach ($finding in $high) {
            Write-AACMarkup " [red1]H[/] [white]$(& $escape $finding.Resource)[/] [grey50]$(& $escape $finding.ResourceGroup)[/] [grey70]$(& $escape $finding.Recommendation)[/]"
        }
        [Spectre.Console.AnsiConsole]::WriteLine()
    }
    if ($stats.HasSecurity) {
        Write-AACMarkup "[grey42]Secure score: Defender for Cloud's for subscriptions (added up for management groups and the tenant); for resource groups and resources, the share of their assessed recommendations that are healthy. Good 70%+, Fair 40-69%, Poor under 40%.[/]"
    }
    if ($stats.HasCost) {
        Write-AACMarkup "[grey42]Cost: Cost Management's actual cost, month to date (MTD) and last month, in each subscription's billing currency - never converted. 'Deleted resources' are costs of resources no longer in Azure, and charges not tied to a resource.[/]"
    }
    if ($stats.EmptyGroups) {
        Write-AACMarkup "[orange1]![/] [grey58]$($stats.EmptyGroups) resource group(s) have no resources.[/]"
    }
    foreach ($notice in @($Inventory.Notice | Where-Object { $_ })) {
        Write-AACMarkup "[grey58]$(& $escape $notice)[/]"
    }
    Write-AACMarkup "[grey42]-Depth Resource lists every resource; add -PassThru (or pipe the command) for the objects; -CsvPath, -PdfPath or -HtmlPath for a report.[/]"
}