Private/Show-AACDashboardView.ps1

function Show-AACDashboardView {
    <#
    .SYNOPSIS
        Draws Show-AACDashboard's view: the headline status, tiles and
        scores, the session and health side by side, security and
        governance, cost, the subscriptions, regions and resource types,
        Advisor, Service Health events, unhealthy resources, the change
        timeline and recent changes - and what to do next. -Section draws
        only those sections; a section whose data wasn't read is left out.
    .DESCRIPTION
        Every state is drawn with Get-AACStatus's symbols and colours (with
        an ASCII fallback), so it reads the same as the rest of the module
        and in any console.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object] $Dashboard,

        [System.Collections.IDictionary] $Scope = @{},

        [string[]] $Section = @('Health', 'Security', 'Policy', 'Governance', 'Advisor', 'Estate', 'Changes', 'Cost')
    )

    $d = $Dashboard
    $escape = { param($Text) [Spectre.Console.Markup]::Escape([string]$Text) }
    $glyph = Get-AACGlyph
    $mark = { param([string] $Status) (Get-AACStatus $Status).Markup }
    $when = { param($Time) if ($Time -is [datetime]) { if ($Time.Date -eq [datetime]::Today) { $Time.ToString('HH:mm') } else { $Time.ToString('d MMM HH:mm') } } else { '' } }
    $newTable = {
        param([string] $Title, [string[]] $Column)
        $table = [Spectre.Console.Table]::new()
        $table.Border = [Spectre.Console.TableBorder]::Rounded
        $table.BorderStyle = [Spectre.Console.Style]::Parse('grey35')
        $table.Title = [Spectre.Console.TableTitle]::new("[bold]$(& $escape $Title)[/]")
        foreach ($name in $Column) { $null = $table.AddColumn([Spectre.Console.TableColumn]::new("[grey70]$(& $escape $name)[/]")) }
        $table
    }
    $addRow = { param($Table, [string[]] $Cell) $null = [Spectre.Console.TableExtensions]::AddRow($Table, [Spectre.Console.Rendering.IRenderable[]]@($Cell | ForEach-Object { [Spectre.Console.Markup]::new($_) })) }
    $want = { param([string] $Name) $Section -contains $Name }
    $has = { param($Object, [string] $Name) $null -ne $Object -and $null -ne $Object.PSObject.Properties[$Name] -and $null -ne $Object.$Name }
    # A sparkline: block characters in a Unicode console, ASCII steps otherwise.
    $spark = {
        param([double[]] $Values)
        $steps = if ([Spectre.Console.AnsiConsole]::Profile.Capabilities.Unicode) { @([char]0x2581, [char]0x2582, [char]0x2583, [char]0x2584, [char]0x2585, [char]0x2586, [char]0x2587, [char]0x2588) } else { @('.', ':', '-', '=', '+', '*', '#', '@') }
        $clean = @($Values | ForEach-Object { if ([double]::IsNaN($_) -or [double]::IsInfinity($_) -or $_ -lt 0) { 0.0 } else { [double]$_ } })
        $max = 0.0; foreach ($v in $clean) { if ($v -gt $max) { $max = $v } }
        -join @(foreach ($v in $clean) { if ($max -le 0) { $steps[0] } else { $steps[[int][Math]::Min([double]7, [Math]::Floor($v / $max * 7.999))] } })
    }
    $scoreColor = { param($Value, [double] $Good = 80, [double] $Warn = 60) if ($null -eq $Value) { 'grey50' } elseif ($Value -ge $Good) { 'green3' } elseif ($Value -ge $Warn) { 'orange1' } else { 'red1' } }

    # --- Who, what, when -------------------------------------------------------------------------------------
    $facts = [System.Collections.Generic.List[string]]::new()
    if ($d.Account) { $facts.Add("[white]$(& $escape $d.Account)[/]") }
    if ($d.TenantId) { $facts.Add("tenant $(& $escape $d.TenantId)") }
    foreach ($key in $Scope.Keys) { $facts.Add("$(& $escape $key): $(& $escape $Scope[$key])") }
    $facts.Add($d.GeneratedAt.ToString('d MMM yyyy HH:mm'))
    Write-AACMarkup "[grey58]$($facts -join " $($glyph.Dot) ")[/]"
    [Spectre.Console.AnsiConsole]::WriteLine()
    $headline = @{ Success = 'Healthy'; Warning = 'Needs attention'; Failed = 'Action needed' }[$d.Status]
    Write-AACStatusLine $d.Status "[bold]$headline[/] [grey70]$(& $escape $d.Headline)[/]" -Markup
    [Spectre.Console.AnsiConsole]::WriteLine()

    $changes = $d.Changes.Created + $d.Changes.Updated + $d.Changes.Deleted
    Show-AACTileRow -Tile @(
        @{ Value = '{0:N0}' -f @($d.Subscriptions).Count; Caption = 'subscriptions'; Color = 'springgreen2' }
        @{ Value = '{0:N0}' -f $d.Resources; Caption = 'resources'; Color = 'deepskyblue1' }
        @{ Value = '{0:N0}' -f $d.ResourceGroups; Caption = 'resource groups'; Color = 'gold1' }
        @{ Value = '{0:N0}' -f $d.Regions; Caption = 'regions'; Color = 'hotpink' }
        @{ Value = '{0:N0}' -f ($d.Health.Unavailable + $d.Health.Degraded); Caption = 'unhealthy'; Color = $(if ($d.Health.Unavailable) { 'red1' } else { 'orange1' }) }
        @{ Value = '{0:N0}' -f $d.AdvisorHigh; Caption = 'Advisor High'; Color = 'orange1' }
        @{ Value = '{0:N0}' -f $changes; Caption = "changes in $($d.Hours)h"; Color = 'mediumpurple2' }
    )
    [Spectre.Console.AnsiConsole]::WriteLine()
    $scoreTiles = [System.Collections.Generic.List[object]]::new()
    if ((& $has $d 'Score') -and $null -ne $d.Score.Value) { $scoreTiles.Add(@{ Value = "$($d.Score.Value)"; Caption = 'estate score (of 100)'; Color = (& $scoreColor $d.Score.Value) }) }
    if ((& $want 'Security') -and (& $has $d 'Security') -and $null -ne $d.Security.SecureScore) { $scoreTiles.Add(@{ Value = "$($d.Security.SecureScore)%"; Caption = 'secure score'; Color = (& $scoreColor $d.Security.SecureScore) }) }
    if ((& $want 'Policy') -and (& $has $d 'Policy') -and $null -ne $d.Policy.Compliance) { $scoreTiles.Add(@{ Value = "$($d.Policy.Compliance)%"; Caption = 'policy compliance'; Color = (& $scoreColor $d.Policy.Compliance) }) }
    if ((& $want 'Advisor') -and (& $has $d 'AdvisorScore')) { $scoreTiles.Add(@{ Value = "$($d.AdvisorScore)"; Caption = 'Advisor score'; Color = (& $scoreColor $d.AdvisorScore) }) }
    if ((& $want 'Governance') -and (& $has $d 'Governance') -and $d.Governance.ClosestLimit) { $scoreTiles.Add(@{ Value = "$($d.Governance.ClosestLimit.Percent)%"; Caption = 'closest limit'; Color = $(if ($d.Governance.ClosestLimit.Percent -ge 95) { 'red1' } elseif ($d.Governance.ClosestLimit.Percent -ge 80) { 'orange1' } else { 'green3' }) }) }
    if ((& $want 'Cost') -and (& $has $d 'Cost')) { $scoreTiles.Add(@{ Value = ('{0:N0}' -f $d.Cost.MonthToDate); Caption = "$($d.Cost.Currency) month to date"; Color = $(if ($null -ne $d.Cost.ChangePercent -and $d.Cost.ChangePercent -gt 10) { 'orange1' } else { 'deepskyblue1' }) }) }
    if ($scoreTiles.Count) {
        Show-AACTileRow -Tile $scoreTiles.ToArray()
        [Spectre.Console.AnsiConsole]::WriteLine()
    }

    # --- Session and health, side by side ------------------------------------------------------------------------
    $sessionLines = [System.Collections.Generic.List[string]]::new()
    $sessionLines.Add("[grey58]Account[/] $(& $escape $(if ($d.Account) { $d.Account } else { '-' }))")
    $sessionLines.Add("[grey58]Tenant[/] $(& $escape $(if ($d.TenantId) { $d.TenantId } else { '-' }))")
    if ($d.SignIn) { $sessionLines.Add("[grey58]Sign-in[/] $(& $escape $d.SignIn)") }
    if ($d.TokenExpiresOn -is [datetime]) {
        $minutes = [int]($d.TokenExpiresOn - $d.GeneratedAt).TotalMinutes
        $sessionLines.Add("[grey58]Token[/] $(& $mark $(if ($minutes -gt 5) { 'Success' } else { 'Warning' })) valid until $($d.TokenExpiresOn.ToString('HH:mm')) [grey58](renewed when needed)[/]")
    }
    $healthLines = [System.Collections.Generic.List[string]]::new()
    $healthLines.Add("$(& $mark 'Success') Available [white]$('{0:N0}' -f $d.Health.Available)[/]")
    $healthLines.Add("$(& $mark $(if ($d.Health.Unavailable) { 'Failed' } else { 'Success' })) Unavailable [white]$('{0:N0}' -f $d.Health.Unavailable)[/]")
    $healthLines.Add("$(& $mark $(if ($d.Health.Degraded) { 'Warning' } else { 'Success' })) Degraded [white]$('{0:N0}' -f $d.Health.Degraded)[/]")
    $healthLines.Add("$(& $mark 'Info') Unknown [white]$('{0:N0}' -f $d.Health.Unknown)[/]")
    $serviceIssues = @($d.ServiceEvents | Where-Object Type -EQ 'Service issue').Count
    $healthLines.Add("$(& $mark $(if ($serviceIssues) { 'Failed' } else { 'Success' })) Service issues [white]$serviceIssues[/] [grey58]active[/]")
    $panel = {
        param([string] $Header, [string[]] $Lines, [string] $Border)
        $p = [Spectre.Console.Panel]::new([Spectre.Console.Markup]::new($Lines -join "`n"))
        $p.Border = [Spectre.Console.BoxBorder]::Rounded
        $p.BorderStyle = [Spectre.Console.Style]::Parse($Border)
        $p.Header = [Spectre.Console.PanelHeader]::new(" [bold]$(& $escape $Header)[/] ")
        $p.Expand = $true
        $p
    }
    $healthBorder = (Get-AACStatus $(if ($d.Health.Unavailable -or $serviceIssues) { 'Failed' } elseif ($d.Health.Degraded) { 'Warning' } else { 'Success' })).Color
    $columns = [Spectre.Console.Columns]::new([Spectre.Console.Rendering.IRenderable[]]@(@((& $panel 'Session' $sessionLines.ToArray() 'grey50')) + @(if (& $want 'Health') { & $panel 'Resource Health' $healthLines.ToArray() $healthBorder })))
    $columns.Expand = $true
    [Spectre.Console.AnsiConsole]::Write($columns)
    [Spectre.Console.AnsiConsole]::WriteLine()

    # --- Security, governance and cost ----------------------------------------------------------------------------
    $more = [System.Collections.Generic.List[object]]::new()
    if ((& $want 'Security') -and (& $has $d 'Security') -and $d.Security.Read) {
        $sec = $d.Security
        $lines = @(
            "[grey58]Secure score[/] $(if ($null -ne $sec.SecureScore) { "[$(& $scoreColor $sec.SecureScore)]$($sec.SecureScore)%[/]" } else { '[grey50]-[/]' })"
            "[grey58]Active alerts[/] [red1]$($sec.Alerts.High) high[/] [grey58]$($glyph.Dot)[/] [orange1]$($sec.Alerts.Medium) medium[/] [grey58]$($glyph.Dot)[/] [deepskyblue1]$($sec.Alerts.Low) low[/]"
            "[grey58]Recommendations[/] [red1]$($sec.Recommendations.High) high[/] [grey58]$($glyph.Dot)[/] [orange1]$($sec.Recommendations.Medium) medium[/] [grey58]unresolved[/]"
            $(if ($sec.DefenderPlans) { "[grey58]Defender plans[/] $(& $escape $sec.DefenderPlans) on" })
        ) | Where-Object { $_ }
        $more.Add((& $panel 'Security' @($lines) $(if ($sec.Alerts.High) { 'red1' } elseif ($null -ne $sec.SecureScore -and $sec.SecureScore -lt 60) { 'orange1' } else { 'grey50' })))
    }
    if ((& $want 'Governance') -and (& $has $d 'Governance')) {
        $g = $d.Governance
        $lines = @(
            $(if ((& $want 'Policy') -and (& $has $d 'Policy') -and $null -ne $d.Policy.Compliance) { "[grey58]Policy[/] [$(& $scoreColor $d.Policy.Compliance)]$($d.Policy.Compliance)%[/] compliant [grey58]($($d.Policy.NonCompliant) non-compliant)[/]" })
            $(if ($g.ClosestLimit) { "[grey58]Closest limit[/] [$(if ($g.ClosestLimit.Percent -ge 95) { 'red1' } elseif ($g.ClosestLimit.Percent -ge 80) { 'orange1' } else { 'green3' })]$($g.ClosestLimit.Percent)%[/] $(& $escape "$($g.ClosestLimit.Limit) on $($g.ClosestLimit.Scope)")" })
            "[grey58]Privileged[/] $($g.PrivilegedAssignments) [grey58]role assignment(s) at subscription or above[/]"
            "[grey58]Unused[/] $(if ($g.Unused) { "[orange1]$($g.Unused)[/]" } else { '0' }) [grey58]disk(s), public IP(s), NIC(s)[/]"
            $(if ($null -ne $g.TagCoverage) { "[grey58]Tagged[/] $($g.TagCoverage)% [grey58]of resources[/]" })
        ) | Where-Object { $_ }
        $more.Add((& $panel 'Governance' @($lines) 'grey50'))
    }
    if ((& $want 'Cost') -and (& $has $d 'Cost')) {
        $c = $d.Cost
        $lines = @(
            "[white]$('{0:N0}' -f $c.MonthToDate) $(& $escape $c.Currency)[/] [grey58]month to date $($glyph.Dot) forecast $('{0:N0}' -f $c.Forecast)[/]$(if ($null -ne $c.ChangePercent) { " [$(if ($c.ChangePercent -gt 10) { 'orange1' } else { 'green3' })]$('{0:+0.#;-0.#;0}' -f $c.ChangePercent)%[/] [grey58]vs last month[/]" })"
            "[deepskyblue1]$(& $escape (& $spark @($c.Daily | ForEach-Object { [double]$_.Cost })))[/] [grey58]30 days[/]"
            "[grey58]$(& $escape ((@($c.TopServices | Select-Object -First 3 | ForEach-Object { "$($_.Service) $('{0:N0}' -f $_.Cost)" })) -join ' · '))[/]"
        )
        $more.Add((& $panel 'Cost' @($lines) $(if ($null -ne $c.ChangePercent -and $c.ChangePercent -gt 10) { 'orange1' } else { 'grey50' })))
    }
    if ($more.Count) {
        $columns = [Spectre.Console.Columns]::new([Spectre.Console.Rendering.IRenderable[]]$more.ToArray())
        $columns.Expand = $true
        [Spectre.Console.AnsiConsole]::Write($columns)
        [Spectre.Console.AnsiConsole]::WriteLine()
    }

    # --- Subscriptions -------------------------------------------------------------------------------------------
    if ((& $want 'Estate') -and @($d.Subscriptions).Count) {
        $table = & $newTable 'Subscriptions' @('Subscription', 'State', 'ID')
        foreach ($s in @($d.Subscriptions)) {
            $state = switch ($s.State) { 'Enabled' { 'Success' } { $_ -in 'Warned', 'PastDue' } { 'Warning' } default { if ($s.State) { 'Failed' } else { 'Info' } } }
            & $addRow $table @("[white]$(& $escape $s.Name)[/]", "$(& $mark $state) $(& $escape $s.State)", "[grey50]$(& $escape $s.SubscriptionId)[/]")
        }
        [Spectre.Console.AnsiConsole]::Write($table)
        [Spectre.Console.AnsiConsole]::WriteLine()
    }

    # --- Where the resources are ---------------------------------------------------------------------------------
    $regions = @($d.TopRegions)
    if ((& $want 'Estate') -and $regions.Count) {
        $bars = @($regions | Select-Object -First 8 | ForEach-Object { @{ Label = $_.Region; Value = $_.Resources } })
        $rest = @($regions | Select-Object -Skip 8)
        if ($rest.Count) { $bars += @{ Label = "$($rest.Count) other region(s)"; Value = [int](@($rest.Resources) | Measure-Object -Sum).Sum; Color = 'grey50' } }
        Show-AACBarChart -Item $bars -Title 'Resources by region'
        [Spectre.Console.AnsiConsole]::WriteLine()
    }
    if ((& $want 'Estate') -and (& $has $d 'TopTypes') -and @($d.TopTypes).Count) {
        Show-AACBarChart -Item @($d.TopTypes | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Name; Value = $_.Resources } }) -Title 'Resources by type'
        [Spectre.Console.AnsiConsole]::WriteLine()
    }

    # --- Service Health, unhealthy resources ------------------------------------------------------------------------
    if ((& $want 'Health') -and @($d.ServiceEvents).Count) {
        $table = & $newTable 'Azure Service Health - active events' @('', 'Type', 'Title', 'Tracking ID', 'Since', 'Subscriptions')
        foreach ($e in @($d.ServiceEvents)) {
            $state = if ($e.Type -eq 'Service issue') { 'Failed' } elseif ($e.Type -eq 'Security advisory') { 'Warning' } else { 'Info' }
            & $addRow $table @((& $mark $state), (& $escape $e.Type), "[white]$(& $escape $e.Title)[/]", "[grey58]$(& $escape $e.TrackingId)[/]", (& $when $e.Started), "[grey58]$(& $escape $e.Subscription)[/]")
        }
        [Spectre.Console.AnsiConsole]::Write($table)
        [Spectre.Console.AnsiConsole]::WriteLine()
    }
    if ((& $want 'Health') -and @($d.UnhealthyResources).Count) {
        $table = & $newTable 'Unhealthy resources (Resource Health)' @('', 'Resource', 'Resource group', 'State', 'Why', 'Since')
        foreach ($r in @($d.UnhealthyResources)) {
            & $addRow $table @((& $mark $(if ($r.State -eq 'Unavailable') { 'Failed' } else { 'Warning' })), "[white]$(& $escape $r.Resource)[/]", (& $escape $r.ResourceGroup), (& $escape $r.State), "[grey70]$(& $escape $r.Summary)[/]", (& $when $r.Since))
        }
        [Spectre.Console.AnsiConsole]::Write($table)
        [Spectre.Console.AnsiConsole]::WriteLine()
    }

    # --- Advisor ----------------------------------------------------------------------------------------------------
    if ((& $want 'Advisor') -and @($d.Advisor).Count) {
        $table = & $newTable 'Azure Advisor recommendations' @('Category', 'High', 'Medium', 'Low')
        $count = { param([int] $N, [string] $Color) if ($N) { "[$Color]$N[/]" } else { '[grey42]0[/]' } }
        foreach ($a in @($d.Advisor)) { & $addRow $table @((& $escape $a.Category), (& $count $a.High 'red1'), (& $count $a.Medium 'orange1'), (& $count $a.Low 'deepskyblue1')) }
        [Spectre.Console.AnsiConsole]::Write($table)
        if ((& $has $d 'AdvisorSavings')) { Write-AACMarkup "[grey58]Potential savings: [green3]$('{0:N0}' -f $d.AdvisorSavings.Annual) $(& $escape $d.AdvisorSavings.Currency)[/] a year in $($d.AdvisorSavings.Recommendations) cost recommendation(s)$(if (& $has $d 'AdvisorScore') { " $($glyph.Dot) Advisor score $($d.AdvisorScore)" })[/]" }
        [Spectre.Console.AnsiConsole]::WriteLine()
    }

    # --- Recent changes --------------------------------------------------------------------------------------------
    $changeTitle = "Recent changes - last $($d.Hours)h: $($d.Changes.Created) created, $($d.Changes.Updated) updated, $($d.Changes.Deleted) deleted"
    $timeline = @(if (& $has $d 'ChangeTimeline') { $d.ChangeTimeline })
    if ((& $want 'Changes') -and @($timeline | Where-Object { $_.Created + $_.Updated + $_.Deleted }).Count) {
        $created = 0; $updated = 0; $deleted = 0; foreach ($t in $timeline) { $created += $t.Created; $updated += $t.Updated; $deleted += $t.Deleted }
        Write-AACMarkup "[bold]Changes, 14 days[/] [mediumpurple2]$(& $escape (& $spark @($timeline | ForEach-Object { [double]($_.Created + $_.Updated + $_.Deleted) })))[/] [green3]+$created created[/] [deepskyblue1]~$updated updated[/] [red1]-$deleted deleted[/]"
        if ((& $has $d 'TopChangers') -and @($d.TopChangers).Count) { Write-AACMarkup "[grey58]Most changes in $($d.Hours)h: $(& $escape ((@($d.TopChangers | Select-Object -First 3 | ForEach-Object { "$($_.ChangedBy) $($_.Changes)$(if ($_.Deletes) { " ($($_.Deletes) deletes)" })" })) -join ' · '))[/]" }
        [Spectre.Console.AnsiConsole]::WriteLine()
    }
    if (& $want 'Changes') {
        if (@($d.RecentChanges).Count) {
            $table = & $newTable $changeTitle @('When', 'Change', 'Resource', 'Type', 'Resource group', 'By')
            $kinds = @{ Create = @('green3', '+'); Update = @('deepskyblue1', '~'); Delete = @('red1', '-') }
            foreach ($c in @($d.RecentChanges)) {
                $kind = if ($kinds.Contains($c.Change)) { $kinds[$c.Change] } else { @('grey70', ' ') }
                & $addRow $table @((& $when $c.Time), "[$($kind[0])]$($kind[1]) $(& $escape $c.Change)[/]", "[white]$(& $escape $c.Resource)[/]", "[grey58]$(& $escape ($c.ResourceType -replace '^microsoft\.', ''))[/]", (& $escape $c.ResourceGroup), "[grey70]$(& $escape $c.ChangedBy)[/]")
            }
            [Spectre.Console.AnsiConsole]::Write($table)
        }
        else { Write-AACStatusLine Info "No resource changes in the last $($d.Hours)h." }
        [Spectre.Console.AnsiConsole]::WriteLine()
    }

    # --- What to do next ---------------------------------------------------------------------------------------------
    $actions = @(if (& $has $d 'Actions') { $d.Actions })
    if ($actions.Count) {
        $table = & $newTable 'What to do next' @('', 'Area', 'What', 'Go deeper')
        $sev = @{ High = 'Failed'; Medium = 'Warning'; Low = 'Info' }
        foreach ($a in $actions) { & $addRow $table @((& $mark $sev[$a.Severity]), (& $escape $a.Area), "[white]$(& $escape $a.Action)[/]", "[deepskyblue1]$(& $escape $a.Command)[/]") }
        [Spectre.Console.AnsiConsole]::Write($table)
        [Spectre.Console.AnsiConsole]::WriteLine()
    }

    # --- What couldn't be read ----------------------------------------------------------------------------------------
    foreach ($property in @($d.Unread.PSObject.Properties)) { Write-AACStatusLine Warning "Couldn't read $($property.Name)" -Detail $property.Value }
    Write-AACMarkup '[grey42]-SubscriptionId or -Select narrows the scope; -Section picks what to show; -IncludeCost adds the spend; -HtmlPath or -PdfPath for a report; -Watch refreshes; -PassThru returns the dashboard object.[/]'
}