Private/ConvertTo-AACDashboard.ps1

function ConvertTo-AACDashboard {
    <#
    .SYNOPSIS
        Turns Show-AACDashboard's Resource Graph reads into one AAC.Dashboard
        object: the session, the scope, the estate, its health, Advisor and
        the recent changes - with an overall status.
    .DESCRIPTION
        Status, the dashboard's headline:
          Failed resources are unavailable, or an Azure service issue is
                   active in these subscriptions
          Warning resources are degraded, Advisor has High-impact
                   recommendations, planned maintenance or an advisory is
                   active, or something couldn't be read
          Success none of these
        Unread names what couldn't be read, and why.
    #>

    [CmdletBinding()]
    [OutputType('AAC.Dashboard')]
    param(
        # Invoke-AACGraphBatch's result: @{ Rows; Errors }.
        [Parameter(Mandatory)]
        [hashtable] $Read,

        # The subscriptions in scope (Resource Graph rows: subscriptionId, name, state).
        [object[]] $Subscription = @(),

        [int] $Hours = 24,

        $Session,

        [datetime] $Now = [datetime]::Now
    )

    $value = { param($Row, [string] $Name) if ($Row -is [System.Collections.IDictionary]) { $Row[$Name] } elseif ($null -ne $Row -and $Row.PSObject.Properties[$Name]) { $Row.PSObject.Properties[$Name].Value } }
    $rows = { param([string] $Name) @(if ($Read.Rows -and $Read.Rows.Contains($Name)) { $Read.Rows[$Name] | Where-Object { $null -ne $_ } }) }
    $leaf = { param($Id) ([string]$Id).TrimEnd('/') -replace '^.*/', '' }
    $groupOf = { param($Id) if ([string]$Id -match '(?i)/resourceGroups/([^/]+)') { $Matches[1] } else { '' } }
    $nameOf = @{}
    foreach ($s in $Subscription) { $nameOf[([string](& $value $s 'subscriptionId')).ToLowerInvariant()] = [string](& $value $s 'name') }
    $subscriptionLabel = { param($Id) $key = ([string]$Id).ToLowerInvariant(); if ($nameOf.Contains($key)) { $nameOf[$key] } else { [string]$Id } }
    $time = {
        # Resource Graph dates come as ISO text, or (Service Health) as Unix seconds.
        param($Text)
        $t = [string]$Text
        $parsed = [datetime]::MinValue
        if ($t -match '^\d{9,11}$') { [DateTimeOffset]::FromUnixTimeSeconds([long]$t).LocalDateTime }
        elseif ($t -and [datetime]::TryParse($t, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind, [ref]$parsed)) { $parsed.ToLocalTime() }
        else { $null }
    }

    $totals = @(& $rows 'totals') | Select-Object -First 1
    $health = [ordered]@{ Available = 0; Unavailable = 0; Degraded = 0; Unknown = 0 }
    foreach ($row in (& $rows 'health')) {
        $state = [string](& $value $row 'state')
        $key = @($health.Keys | Where-Object { $_ -eq $state })[0]
        if (-not $key) { $key = 'Unknown' }
        $health[$key] += [int](& $value $row 'resources')
    }
    $eventTypes = @{ ServiceIssue = 'Service issue'; PlannedMaintenance = 'Planned maintenance'; HealthAdvisory = 'Health advisory'; SecurityAdvisory = 'Security advisory' }
    $issues = @(foreach ($row in (& $rows 'serviceIssues')) {
            $type = [string](& $value $row 'eventType')
            [pscustomobject][ordered]@{
                PSTypeName   = 'AAC.DashboardServiceEvent'
                Type         = $(if ($eventTypes.Contains($type)) { $eventTypes[$type] } else { $type })
                Title        = [string](& $value $row 'title')
                TrackingId   = [string](& $value $row 'trackingId')
                Level        = [string](& $value $row 'level')
                Started      = & $time (& $value $row 'started')
                Subscription = & $subscriptionLabel (& $value $row 'subscriptionId')
            }
        })
    # The same event in several subscriptions: once, with each subscription.
    $issues = @($issues | Group-Object -Property TrackingId | ForEach-Object {
            $first = $_.Group[0]
            $first.Subscription = (@($_.Group.Subscription) | Select-Object -Unique) -join ', '
            $first
        } | Sort-Object -Property @{ Expression = { @{ 'Service issue' = 0; 'Security advisory' = 1; 'Planned maintenance' = 2 }[$_.Type] } }, Started)
    $unhealthy = @(@(foreach ($row in (& $rows 'unhealthy')) {
            $id = [string](& $value $row 'resourceId')
            [pscustomobject][ordered]@{
                PSTypeName    = 'AAC.DashboardUnhealthyResource'
                Resource      = & $leaf $id
                State         = [string](& $value $row 'state')
                Summary       = [string](& $value $row 'summary')
                Reason        = [string](& $value $row 'reason')
                Since         = & $time (& $value $row 'since')
                ResourceGroup = & $groupOf $id
                ResourceId    = $id
            }
        }) | Sort-Object -Property @{ Expression = { if ($_.State -eq 'Unavailable') { 0 } else { 1 } } }, Resource)
    $advisor = @(@(foreach ($group in @(& $rows 'advisor') | Group-Object -Property { [string](& $value $_ 'category') }) {
            $count = { param([string] $Impact) [int](@($group.Group | Where-Object { [string](& $value $_ 'impact') -eq $Impact } | ForEach-Object { [int](& $value $_ 'recommendations') }) | Measure-Object -Sum).Sum }
            [pscustomobject][ordered]@{
                PSTypeName = 'AAC.DashboardAdvisor'
                Category   = @{ HighAvailability = 'Reliability'; OperationalExcellence = 'Operational excellence' }[$group.Name] ?? $group.Name
                High       = & $count 'High'
                Medium     = & $count 'Medium'
                Low        = & $count 'Low'
            }
        }) | Sort-Object -Property @{ Expression = 'High'; Descending = $true }, Category)
    $changeCount = @{ Create = 0; Update = 0; Delete = 0 }
    foreach ($row in (& $rows 'changes')) { $kind = [string](& $value $row 'changeType'); if ($changeCount.Contains($kind)) { $changeCount[$kind] += [int](& $value $row 'changes') } }
    $recent = @(foreach ($row in (& $rows 'recentChanges')) {
            $id = [string](& $value $row 'resourceId')
            [pscustomobject][ordered]@{
                PSTypeName    = 'AAC.DashboardChange'
                Time          = & $time (& $value $row 'at')
                Change        = [string](& $value $row 'changeType')
                Resource      = & $leaf $id
                ResourceType  = ([string](& $value $row 'resourceType')).ToLowerInvariant()
                ResourceGroup = & $groupOf $id
                ChangedBy     = [string](& $value $row 'changedBy')
                Operation     = [string](& $value $row 'operation')
                ResourceId    = $id
            }
        })
    $labels = @{ totals = 'the resource totals'; regions = 'the regions'; health = 'Resource Health'; unhealthy = 'the unhealthy resources'; serviceIssues = 'Service Health'; advisor = 'Azure Advisor'; changes = 'the resource changes'; recentChanges = 'the recent changes' }
    $unread = [ordered]@{}
    foreach ($key in @($Read.Errors.Keys | Sort-Object)) { if ($Read.Errors[$key]) { $unread[$(if ($labels.Contains($key)) { $labels[$key] } else { $key })] = [string]$Read.Errors[$key] } }

    # The headline.
    $activeIssues = @($issues | Where-Object Type -EQ 'Service issue').Count
    $advisorHigh = [int](@($advisor | ForEach-Object { $_.High }) | Measure-Object -Sum).Sum
    $reasons = [System.Collections.Generic.List[string]]::new()
    if ($health.Unavailable) { $reasons.Add("$($health.Unavailable) resource(s) unavailable") }
    if ($activeIssues) { $reasons.Add("$activeIssues active Azure service issue(s)") }
    if ($health.Degraded) { $reasons.Add("$($health.Degraded) resource(s) degraded") }
    $otherEvents = @($issues | Where-Object Type -NE 'Service issue').Count
    if ($otherEvents) { $reasons.Add("$otherEvents planned maintenance or advisory event(s)") }
    if ($advisorHigh) { $reasons.Add("$advisorHigh High-impact Advisor recommendation(s)") }
    if ($unread.Count) { $reasons.Add("$($unread.Count) source(s) not read") }
    $status = if ($health.Unavailable -or $activeIssues) { 'Failed' } elseif ($reasons.Count) { 'Warning' } else { 'Success' }

    $subscriptions = @(foreach ($s in $Subscription | Sort-Object -Property { [string](& $value $_ 'name') }) {
            [pscustomobject][ordered]@{ PSTypeName = 'AAC.DashboardSubscription'; Name = [string](& $value $s 'name'); State = [string](& $value $s 'state'); SubscriptionId = [string](& $value $s 'subscriptionId') }
        })
    [pscustomobject][ordered]@{
        PSTypeName         = 'AAC.Dashboard'
        Status             = $status
        Headline           = $(if ($reasons.Count) { $reasons -join '; ' } else { 'Every resource Resource Health reports on is available, and no Azure service issue is active.' })
        Account            = $(if ($Session) { [string]$Session.Account } else { '' })
        TenantId           = $(if ($Session) { [string]$Session.TenantId } else { '' })
        SignIn             = $(if ($Session -and $Session.PSObject.Properties['Flow']) { [string]$Session.Flow } else { '' })
        TokenExpiresOn     = $(if ($Session -and $Session.PSObject.Properties['ExpiresOn']) { $Session.ExpiresOn } else { $null })
        Subscriptions      = $subscriptions
        Resources          = [int](& $value $totals 'resources')
        ResourceTypes      = [int](& $value $totals 'types')
        ResourceGroups     = [int](& $value $totals 'groups')
        Regions            = [int](& $value $totals 'regions')
        TopRegions         = @(& $rows 'regions' | ForEach-Object { [pscustomobject]@{ Region = $(if ((& $value $_ 'location')) { [string](& $value $_ 'location') } else { '(global)' }); Resources = [int](& $value $_ 'resources') } })
        Health             = [pscustomobject]$health
        UnhealthyResources = $unhealthy
        ServiceEvents      = $issues
        Advisor            = $advisor
        AdvisorHigh        = $advisorHigh
        Hours              = $Hours
        Changes            = [pscustomobject][ordered]@{ Created = $changeCount.Create; Updated = $changeCount.Update; Deleted = $changeCount.Delete }
        RecentChanges      = $recent
        Unread             = [pscustomobject]$unread
        GeneratedAt        = $Now
    }
}