Public/Show-AACDashboard.ps1

function Show-AACDashboard {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        A dashboard of your Azure estate: health, security, policy,
        governance, Advisor, the estate itself, the latest changes and (with
        -IncludeCost) the spend - one status and an Estate score at the top,
        and what to do next at the bottom.
    .DESCRIPTION
        Reads it all in one Azure Resource Graph batch (no Az modules), with
        the Connect-AAC sign-in:
 
          ── Azure Admin Console :: Dashboard ─────────────────────────────
          admin@contoso.com · tenant ... · all subscriptions · 9 Oct 2026 10:42
          ✗ Action needed 2 resource(s) unavailable; 1 active Azure service issue(s)
 
          [ subscriptions ] [ resources ] [ resource groups ] [ regions ]
          [ unhealthy ] [ Advisor High ] [ changes in 24h ]
 
          ╭─ Session ─────────────────╮ ╭─ Resource Health ──────────╮
          │ Account admin@... │ │ ✓ Available 1,204 │
          │ Tenant ... │ │ ✗ Unavailable 2 │
          │ Token ✓ valid until … │ │ ⚠ Degraded 1 │
          ╰───────────────────────────╯ ╰────────────────────────────╯
          Subscriptions (state) · Resources by region · Azure Service
          Health - active events · Unhealthy resources · Azure Advisor
          recommendations · Recent changes (create, update, delete - what,
          where, who)
 
        The status at the top:
          ✗ Action needed resources are unavailable, or an Azure
                              service issue is active in these subscriptions
          ⚠ Needs attention resources are degraded, Advisor has
                              High-impact recommendations, planned
                              maintenance or an advisory is active, or
                              something couldn't be read
          ✓ Healthy none of these
 
        Beyond that one screen, from the same Resource Graph batch:
          Scores the Estate score (0-100: the average of the share of
                       resources available, the secure score, the policy
                       compliance and the Advisor score that could be read,
                       less a little when a subscription nears the role
                       assignment limit or spend jumps)
          Security secure score, active alerts and unresolved
                       recommendations by severity, Defender plans on
          Governance policy compliance and the assignments with the most
                       non-compliant resources, the closest role assignment
                       limit, privileged role assignments at subscription or
                       above, unused disks, public IPs and NICs, tag coverage
          Advisor the Advisor score and the potential annual savings
          Estate resources by type and by subscription
          Changes the last 14 days, day by day, and who made the most
          Cost with -IncludeCost (Cost Management, one query per
                       subscription): month to date against the same days
                       last month, a forecast, the last 30 days, the services
                       that cost most
          Next what to do, ranked across every section, with the
                       command that goes deeper
        -Section shows only some sections. -HtmlPath and -PdfPath write it
        as a report - an Overview, then a tab per section; -CsvPath writes
        what to do next. -Watch refreshes it every so many seconds, for a
        screen on the wall (Ctrl+C stops it).
 
        Symbols and colours are the module's (Unicode, or ASCII in a console
        that isn't UTF-8), so the state never depends on colour alone. What
        couldn't be read (Resource Health, Service Health, Advisor or the
        change history need Reader on the subscriptions) is listed at the
        end, and the rest is still shown.
 
        Scope: every subscription the account can see, those in
        -SubscriptionId, or - with -Select - those you tick in a list at the
        console.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER Select
        Pick the subscriptions from a list at the console (arrow keys, space
        to tick, Enter to accept). Needs an interactive console; in a script
        use -SubscriptionId.
    .PARAMETER Hours
        How far back the recent changes go, in hours (1 to 336 - Resource
        Graph keeps 14 days). Defaults to 24.
    .PARAMETER Section
        Only these sections: Health, Security, Policy, Governance, Advisor,
        Estate, Changes, Cost. The status, scores and what to do next always
        show.
    .PARAMETER IncludeCost
        Read the spend from Cost Management (slower: Cost Management allows
        a few queries a minute).
    .PARAMETER HtmlPath
        Write the dashboard as an interactive HTML report.
    .PARAMETER PdfPath
        Write the dashboard as a PDF report.
    .PARAMETER CsvPath
        Write what to do next to this CSV file.
    .PARAMETER Title
        The reports' title.
    .PARAMETER Watch
        Refresh the dashboard every this many seconds (30 or more) until
        Ctrl+C.
    .PARAMETER PassThru
        Show the dashboard and also return it as an AAC.Dashboard object.
    .PARAMETER NoDisplay
        Return the AAC.Dashboard object without showing the dashboard.
    .PARAMETER NoPaging
        Show the whole dashboard at once instead of a page at a time.
    .EXAMPLE
        Connect-AAC
        Show-AACDashboard
        The dashboard for every subscription you can see.
    .EXAMPLE
        Show-AACDashboard -Select -Hours 72
        Pick the subscriptions from a list, with the changes of the last three days.
    .EXAMPLE
        Show-AACDashboard -IncludeCost -HtmlPath .\Dashboard.html
        Everything, with the spend, as a report with a tab per section.
    .EXAMPLE
        Show-AACDashboard -Section Health, Security -Watch 300
        Health and security, refreshed every five minutes.
    .EXAMPLE
        (Show-AACDashboard -NoDisplay).Actions | Format-Table
        What to do next, as objects.
    .EXAMPLE
        (Show-AACDashboard -NoDisplay).UnhealthyResources | Format-Table
        The unavailable and degraded resources, as objects.
    .EXAMPLE
        if ((Show-AACDashboard -SubscriptionId $prod -NoDisplay).Status -eq 'Failed') { Send-MyAlert }
        Act on the dashboard's status in a script.
    .OUTPUTS
        AAC.Dashboard (with -PassThru or -NoDisplay, or piped onward)
    #>

    [CmdletBinding(DefaultParameterSetName = 'All')]
    [OutputType('AAC.Dashboard')]
    param(
        [Parameter(ParameterSetName = 'Subscription')]
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [Parameter(ParameterSetName = 'Select')]
        [switch] $Select,

        [ValidateRange(1, 336)]
        [int] $Hours = 24,

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

        [switch] $IncludeCost,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $CsvPath,

        [string] $Title = 'Azure dashboard',

        [ValidateRange(30, 86400)]
        [int] $Watch,

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    # A failure anywhere below ends as a Spectre.Console error panel and this
    # command's own terminating error, not a line inside the module. A stopped
    # pipeline (Select-Object -First, Ctrl+C) is no failure: just return - a
    # rethrow would stop the caller's whole script, not only this command.
    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $showView = -not $NoDisplay -and -not $pipedOnward
    $returnObject = $PassThru -or $NoDisplay -or $pipedOnward

    # Signed in? (Get-AACAccessToken says what to do when not.)
    $null = Get-AACAccessToken
    if ($showView) { Write-AACRule -Title 'Azure Admin Console :: Dashboard' -Color 'deepskyblue3_1' }

    # --- The subscriptions, and which are in scope ------------------------------------------------------------
    $subscriptions = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'subscriptions' -Indeterminate -Description 'Finding the subscriptions'
        $read = Invoke-AACGraphBatch -Query ([ordered]@{ subscriptions = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project id, subscriptionId, name, state = tostring(properties.state)" })
        $rows = @($read.Rows['subscriptions'] | Where-Object { $null -ne $_ })
        Update-AACProgress -Id 'subscriptions' -Complete -Description ('Found {0:N0} subscription(s)' -f $rows.Count)
        , $rows
    }
    $subscriptions = @($subscriptions)
    if (-not $subscriptions.Count) { throw 'The account can see no subscriptions. It needs Reader (or any role) on at least one.' }
    if ($SubscriptionId) {
        $wanted = @($SubscriptionId | ForEach-Object { $_.ToLowerInvariant() })
        foreach ($id in $wanted) { if (-not @($subscriptions | Where-Object { ([string]$_['subscriptionId']).ToLowerInvariant() -eq $id }).Count) { Write-Warning "Subscription $id isn't visible to this account; it's left out." } }
        $subscriptions = @($subscriptions | Where-Object { $wanted -contains ([string]$_['subscriptionId']).ToLowerInvariant() })
        if (-not $subscriptions.Count) { throw "None of the subscriptions asked for ($($SubscriptionId -join ', ')) is visible to this account." }
    }
    elseif ($Select) {
        $subscriptions = @(Read-AACSelection -Title 'Which subscriptions should the dashboard cover?' -Item ($subscriptions | Sort-Object -Property { [string]$_['name'] }) -Label { param($S) "$($S['name']) ($($S['subscriptionId']))" } -Multiple -Hint 'Use -SubscriptionId to name the subscriptions.')
    }
    $scopeIds = @(if ($SubscriptionId -or $Select) { $subscriptions | ForEach-Object { [string]$_['subscriptionId'] } })

    # --- Everything else, in one batch - again and again with -Watch ---------------------------------------------------
    $sections = @(if ($Section) { $Section } else { 'Health', 'Security', 'Policy', 'Governance', 'Advisor', 'Estate', 'Changes'; if ($IncludeCost) { 'Cost' } })
    if ($IncludeCost -and $sections -notcontains 'Cost') { $sections += 'Cost' }
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    $exporting = [bool]($CsvPath -or $HtmlPath -or $PdfPath)
    $queries = Get-AACDashboardQuery -Hours $Hours
    $scope = [ordered]@{ Scope = $(if ($scopeIds.Count) { "$($scopeIds.Count) subscription(s)" } else { 'all subscriptions' }) }
    $costIds = @($subscriptions | Where-Object { -not $_['state'] -or [string]$_['state'] -eq 'Enabled' } | ForEach-Object { [string]$_['subscriptionId'] })
    $names = @{}; foreach ($sub in $subscriptions) { $names[([string]$sub['subscriptionId']).ToLowerInvariant()] = [string]$sub['name'] }
    $pass = 0
    do {
        $pass++
        if ($pass -gt 1 -and $showView) {
            # No real console (redirected, a CI log) can't be cleared: keep scrolling instead.
            try { [Spectre.Console.AnsiConsole]::Clear() } catch { [Spectre.Console.AnsiConsole]::WriteLine() }
            Write-AACRule -Title 'Azure Admin Console :: Dashboard' -Color 'deepskyblue3_1'
        }
        $read = Invoke-AACProgress -ScriptBlock {
            Update-AACProgress -Id 'dashboard' -Total $queries.Count -Description 'Reading the estate, health, security, policy, governance, Advisor and the changes'
            $batch = Invoke-AACGraphBatch -Query $queries -SubscriptionId $scopeIds -AllowFailure @($queries.Keys | Where-Object { $_ -ne 'totals' }) -OnProgress {
                param($Name, $Done, $Total)
                Update-AACProgress -Id 'dashboard' -Increment 1 -Description "Read $Name ($Done of $Total)"
            }
            $unread = @($batch.Errors.Keys | Where-Object { $batch.Errors[$_] }).Count
            Update-AACProgress -Id 'dashboard' -Complete -Description ('Read the dashboard for {0:N0} subscription(s){1}' -f $subscriptions.Count, $(if ($unread) { "; $unread source(s) couldn't be read" }))
            $batch
        }
        $cost = $null
        if ($sections -contains 'Cost') { $cost = Invoke-AACProgress -ScriptBlock { Read-AACDashboardCost -SubscriptionId $costIds -SubscriptionName $names } }
        $dashboard = ConvertTo-AACDashboard -Read $read -Subscription $subscriptions -Hours $Hours -Session $script:AACSession -Cost $cost

        if ($exporting) {
            Invoke-AACReportOutput -Report (ConvertTo-AACDashboardReport -Dashboard $dashboard -Section $sections -Scope $scope) -Title $Title -CsvObject @($dashboard.Actions) -Noun 'action' `
                -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath)
        }
        elseif ($showView) {
            Invoke-AACPagedOutput -NoPaging:($NoPaging -or [bool]$Watch) -ScriptBlock { Show-AACDashboardView -Dashboard $dashboard -Scope $scope -Section $sections }
        }
        if ($returnObject -and -not $Watch) { $dashboard }
        if ($Watch) {
            Write-AACMarkup "[grey42]Refreshing every $Watch second(s) - Ctrl+C to stop. Read at $((Get-Date).ToString('HH:mm:ss')).[/]"
            Start-Sleep -Seconds $Watch
        }
    } while ($Watch)
}