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, security,
        policy, governance, Advisor, the recent changes and (with -Cost) the
        spend - with an overall status, an Estate score and what to do next.
    .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
        Warning also when there are high-severity security alerts, policy
        compliance is below 80%, a subscription is at 80% of the role
        assignment limit, or spend is more than 10% above last month.
 
        Score (0-100, or none when nothing it is made of was read): the
        average of the parts that were read - Health (the share of
        resources available), Security (the secure score), Policy (the share
        of resources compliant) and Advisor (the Advisor score) - less 5
        when a subscription is at 95% of the role assignment limit (2 at
        80%), and less 3 when spend is more than 20% above last month.
 
        Actions: what to do, ranked High, Medium, Low across every section,
        each with the command that goes deeper.
        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,

        # Read-AACDashboardCost's result (-IncludeCost), or $null.
        [hashtable] $Cost,

        [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'
        types = 'the resource types'; bySubscription = 'the resources per subscription'; tagged = 'the tags'; secureScore = 'the secure score'; alerts = 'the security alerts'; recommendations = 'the security recommendations'; defenderPlans = 'the Defender plans'
        policy = 'Azure Policy compliance'; policyTop = 'the non-compliant assignments'; advisorScore = 'the Advisor score'; advisorSavings = 'the Advisor savings'; unused = 'the unused resources'; roleAssignments = 'the role assignments'; privileged = 'the privileged role assignments'; changeDays = 'the change timeline'; changers = 'who made the 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] } }

    # --- Estate: types, subscriptions, tags ----------------------------------------------------------------------
    $readOk = { param([string] $Name) $Read.Rows -and $Read.Rows.Contains($Name) -and -not ($Read.Errors -and $Read.Errors.Contains($Name) -and $Read.Errors[$Name]) }
    # NaN or infinity (Resource Graph's avg() over no rows is NaN) counts as nothing, never as a number.
    $finite = { param($Row, [string] $Name) $v = & $value $Row $Name; $d = 0.0; if ($null -ne $v -and '' -ne $v -and [double]::TryParse([string]$v, [System.Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref]$d) -and -not [double]::IsNaN($d) -and -not [double]::IsInfinity($d)) { $d } else { $null } }
    $number = { param($Row, [string] $Name) $n = & $finite $Row $Name; if ($null -eq $n) { 0 } else { $n } }
    $typeLabel = { param([string] $Type) (($Type -split '/')[-1] -creplace '([a-z])([A-Z])', '$1 $2') }
    $topTypes = @(& $rows 'types' | ForEach-Object { [pscustomobject]@{ Type = [string](& $value $_ 'type'); Name = (& $typeLabel ([string](& $value $_ 'type'))); Resources = [int](& $number $_ 'resources') } })
    $bySubscription = @(& $rows 'bySubscription' | ForEach-Object { [pscustomobject]@{ Subscription = (& $subscriptionLabel (& $value $_ 'subscriptionId')); Resources = [int](& $number $_ 'resources') } } | Sort-Object Resources -Descending)
    $taggedRow = @(& $rows 'tagged') | Select-Object -First 1
    $tagCoverage = if ($taggedRow -and (& $number $taggedRow 'total') -gt 0) { [Math]::Round(100.0 * (& $number $taggedRow 'tagged') / (& $number $taggedRow 'total'), 1) } else { $null }

    # --- Security --------------------------------------------------------------------------------------------------
    $scoreRow = @(& $rows 'secureScore') | Select-Object -First 1
    $secureScore = if ($scoreRow -and (& $number $scoreRow 'max') -gt 0) { [Math]::Round(100.0 * (& $number $scoreRow 'current') / (& $number $scoreRow 'max'), 0) } else { $null }
    $bySeverity = {
        param([string] $Name, [string] $Column)
        $o = [ordered]@{ High = 0; Medium = 0; Low = 0 }
        foreach ($row in (& $rows $Name)) { $sev = [string](& $value $row 'severity'); $key = @($o.Keys | Where-Object { $_ -eq $sev })[0]; if ($key) { $o[$key] += [int](& $number $row $Column) } }
        [pscustomobject]$o
    }
    $plansRow = @(& $rows 'defenderPlans') | Select-Object -First 1
    $security = [pscustomobject][ordered]@{
        SecureScore     = $secureScore
        Alerts          = (& $bySeverity 'alerts' 'alerts')
        Recommendations = (& $bySeverity 'recommendations' 'recommendations')
        DefenderPlans   = $(if ($plansRow -and (& $number $plansRow 'total') -gt 0) { "$([int](& $number $plansRow 'on')) of $([int](& $number $plansRow 'total'))" } else { '' })
        Read            = (& $readOk 'secureScore') -or (& $readOk 'alerts')
    }

    # --- Policy ------------------------------------------------------------------------------------------------------
    $policyRow = @(& $rows 'policy') | Select-Object -First 1
    $nonCompliant = [int](& $number $policyRow 'nonCompliant')
    $compliant = [int](& $number $policyRow 'compliant')
    $policy = [pscustomobject][ordered]@{
        Compliance   = $(if ($policyRow -and ($nonCompliant + $compliant) -gt 0) { [Math]::Round(100.0 * $compliant / ($nonCompliant + $compliant), 1) } else { $null })
        NonCompliant = $nonCompliant
        Compliant    = $compliant
        Top          = @(& $rows 'policyTop' | ForEach-Object { [pscustomobject]@{ Assignment = [string](& $value $_ 'assignment'); Scope = ([string](& $value $_ 'scope') -split '/')[-1]; Resources = [int](& $number $_ 'resources') } })
    }

    # --- Governance: limits, privileged access, unused resources --------------------------------------------------------
    $inScope = @{}; foreach ($s in $Subscription) { $inScope[([string](& $value $s 'subscriptionId')).ToLowerInvariant()] = $true }
    $closest = @(& $rows 'roleAssignments' | Where-Object { $inScope.Contains(([string](& $value $_ 'subscriptionId')).ToLowerInvariant()) } | Sort-Object { & $number $_ 'used' } -Descending) | Select-Object -First 1
    $limit = if ($closest) { $used = [int](& $number $closest 'used'); [pscustomobject][ordered]@{ Limit = 'Role assignments'; Scope = (& $subscriptionLabel (& $value $closest 'subscriptionId')); Used = $used; Maximum = 4000; Percent = [Math]::Round(100.0 * $used / 4000, 1) } } else { $null }
    $privilegedRows = @(& $rows 'privileged' | Where-Object { $sub = ([string](& $value $_ 'subscriptionId')).ToLowerInvariant(); -not $sub -or $inScope.Contains($sub) })
    $privilegedBy = [ordered]@{}
    foreach ($row in $privilegedRows) { $t = [string](& $value $row 'principalType'); if (-not $t) { $t = 'Unknown' }; $privilegedBy[$t] = [int]$(if ($privilegedBy.Contains($t)) { $privilegedBy[$t] } else { 0 }) + [int](& $number $row 'assignments') }
    $privilegedTotal = 0; foreach ($k in $privilegedBy.Keys) { $privilegedTotal += $privilegedBy[$k] }
    $unusedRows = @(& $rows 'unused')
    $unusedTotal = 0; foreach ($row in $unusedRows) { $unusedTotal += [int](& $number $row 'resources') }
    $governance = [pscustomobject][ordered]@{
        ClosestLimit          = $limit
        PrivilegedAssignments = $privilegedTotal
        PrivilegedByType      = [pscustomobject]$privilegedBy
        Unused                = $unusedTotal
        UnusedByType          = @($unusedRows | ForEach-Object { [pscustomobject]@{ Type = (& $typeLabel ([string](& $value $_ 'type'))); Resources = [int](& $number $_ 'resources') } })
        TagCoverage           = $tagCoverage
    }

    # --- Advisor score and savings --------------------------------------------------------------------------------------
    $advisorScoreRow = @(& $rows 'advisorScore') | Select-Object -First 1
    $advisorScoreValue = if ($advisorScoreRow) { & $finite $advisorScoreRow 'score' } else { $null }
    $advisorScore = if ($null -ne $advisorScoreValue) { [Math]::Round($advisorScoreValue, 0) } else { $null }
    $savingsRow = @(& $rows 'advisorSavings' | Sort-Object { & $number $_ 'savings' } -Descending) | Select-Object -First 1
    $advisorSavings = if ($savingsRow -and (& $number $savingsRow 'savings') -gt 0) { [pscustomobject]@{ Annual = [Math]::Round((& $number $savingsRow 'savings'), 0); Currency = [string](& $value $savingsRow 'currency'); Recommendations = [int](& $number $savingsRow 'recommendations') } } else { $null }

    # --- The change timeline and who made the changes ---------------------------------------------------------------------
    $timelineDays = [ordered]@{}
    for ($i = 13; $i -ge 0; $i--) { $timelineDays[$Now.Date.AddDays(-$i).ToString('yyyy-MM-dd', [cultureinfo]::InvariantCulture)] = @{ Create = 0; Update = 0; Delete = 0 } }
    foreach ($row in (& $rows 'changeDays')) {
        $day = [string](& $value $row 'day'); $kind = [string](& $value $row 'changeType')
        if ($timelineDays.Contains($day) -and $timelineDays[$day].Contains($kind)) { $timelineDays[$day][$kind] += [int](& $number $row 'changes') }
    }
    $timeline = @(foreach ($day in $timelineDays.Keys) { [pscustomobject][ordered]@{ Day = [datetime]::ParseExact($day, 'yyyy-MM-dd', [cultureinfo]::InvariantCulture); Created = $timelineDays[$day].Create; Updated = $timelineDays[$day].Update; Deleted = $timelineDays[$day].Delete } })
    $changers = @(& $rows 'changers' | ForEach-Object { [pscustomobject]@{ ChangedBy = $(if ((& $value $_ 'changedBy')) { [string](& $value $_ 'changedBy') } else { '(unknown)' }); Changes = [int](& $number $_ 'changes'); Deletes = [int](& $number $_ 'deletes') } })

    # --- Cost (-IncludeCost) ------------------------------------------------------------------------------------------------
    $costCard = $null
    if ($Cost) {
        $costCard = [pscustomobject][ordered]@{
            MonthToDate     = $Cost.MonthToDate
            LastMonthToDate = $Cost.LastMonthToDate
            LastMonth       = $Cost.LastMonth
            Forecast        = $Cost.Forecast
            ChangePercent   = $Cost.ChangePercent
            Currency        = $Cost.Currency
            Daily           = @($Cost.Daily)
            TopServices     = @($Cost.TopServices)
        }
        foreach ($k in @($Cost.Unread.Keys)) { $unread["the cost of $k"] = [string]$Cost.Unread[$k] }
    }

    # 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 ($security.Alerts.High) { $reasons.Add("$($security.Alerts.High) high-severity security alert(s)") }
    if ($null -ne $policy.Compliance -and $policy.Compliance -lt 80) { $reasons.Add("policy compliance $($policy.Compliance)%") }
    if ($limit -and $limit.Percent -ge 80) { $reasons.Add("$($limit.Scope) at $($limit.Percent)% of the role assignment limit") }
    if ($costCard -and $null -ne $costCard.ChangePercent -and $costCard.ChangePercent -gt 10) { $reasons.Add("spend $($costCard.ChangePercent)% above last month") }
    if ($unread.Count) { $reasons.Add("$($unread.Count) source(s) not read") }
    $status = if ($health.Unavailable -or $activeIssues) { 'Failed' } elseif ($reasons.Count) { 'Warning' } else { 'Success' }

    # --- The Estate score ---------------------------------------------------------------------------------------------------
    $healthTotal = $health.Available + $health.Unavailable + $health.Degraded
    $parts = [ordered]@{
        Health   = $(if ($healthTotal) { [Math]::Round(100.0 * $health.Available / $healthTotal, 1) } else { $null })
        Security = $secureScore
        Policy   = $policy.Compliance
        Advisor  = $advisorScore
    }
    $known = @($parts.Values | Where-Object { $null -ne $_ -and -not [double]::IsNaN([double]$_) })
    $penalty = 0
    if ($limit) { if ($limit.Percent -ge 95) { $penalty += 5 } elseif ($limit.Percent -ge 80) { $penalty += 2 } }
    if ($costCard -and $null -ne $costCard.ChangePercent -and $costCard.ChangePercent -gt 20) { $penalty += 3 }
    $average = 0.0; foreach ($k in $known) { $average += [double]$k }
    $score = [pscustomobject][ordered]@{
        Value    = $(if ($known.Count) { [int][Math]::Max([double]0, [Math]::Min([double]100, [Math]::Round($average / $known.Count) - $penalty)) } else { $null })
        Health   = $parts.Health
        Security = $parts.Security
        Policy   = $parts.Policy
        Advisor  = $parts.Advisor
        Penalty  = $penalty
    }

    # --- What to do next ---------------------------------------------------------------------------------------------------
    $actions = [System.Collections.Generic.List[object]]::new()
    $act = { param([string] $Severity, [string] $Area, [string] $Text, [string] $Command) $actions.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.DashboardAction'; Severity = $Severity; Area = $Area; Action = $Text; Command = $Command }) }
    if ($health.Unavailable) { & $act 'High' 'Health' "$($health.Unavailable) resource(s) unavailable$(if (@($unhealthy).Count) { ': ' + ((@($unhealthy | Where-Object State -EQ 'Unavailable' | Select-Object -First 3).Resource) -join ', ') })" 'Get-AACResourceHealth' }
    if ($activeIssues) { & $act 'High' 'Health' "$activeIssues active Azure service issue(s) in these subscriptions" 'Get-AACResourceHealth -IncludeHistory' }
    if ($security.Alerts.High) { & $act 'High' 'Security' "$($security.Alerts.High) high-severity security alert(s) active" 'Get-AACSecurityAlert' }
    if ($limit -and $limit.Percent -ge 95) { & $act 'High' 'Limits' "$($limit.Scope) at $($limit.Used) of $($limit.Maximum) role assignments" 'Test-AACAzureLimit' }
    elseif ($limit -and $limit.Percent -ge 80) { & $act 'Medium' 'Limits' "$($limit.Scope) at $($limit.Percent)% of the role assignment limit" 'Test-AACAzureLimit' }
    if ($health.Degraded) { & $act 'Medium' 'Health' "$($health.Degraded) resource(s) degraded" 'Get-AACResourceHealth' }
    if ($security.Recommendations.High) { & $act 'Medium' 'Security' "$($security.Recommendations.High) high-severity security recommendation(s) unresolved" 'Invoke-AACDefenderAssessment' }
    if ($null -ne $secureScore -and $secureScore -lt 60) { & $act 'Medium' 'Security' "Secure score $secureScore% - below 60%" 'Get-AACSecurityPosture' }
    if ($null -ne $policy.Compliance -and $policy.Compliance -lt 80) { & $act 'Medium' 'Policy' "Policy compliance $($policy.Compliance)% ($($policy.NonCompliant) non-compliant resource(s))" 'Invoke-AACPolicyAssessment' }
    if ($costCard -and $null -ne $costCard.ChangePercent -and $costCard.ChangePercent -gt 10) { & $act 'Medium' 'Cost' "Spend $($costCard.ChangePercent)% above the same days last month" 'Get-AACCostAnomaly' }
    if ($advisorHigh) { & $act 'Medium' 'Advisor' "$advisorHigh High-impact Advisor recommendation(s)$(if ($advisorSavings) { " - $('{0:N0}' -f $advisorSavings.Annual) $($advisorSavings.Currency) a year to save" })" 'Get-AACAdvisorRecommendation' }
    if ($unusedTotal) { & $act 'Medium' 'Governance' "$unusedTotal unused disk(s), public IP(s) and network interface(s)" 'Get-AACUnusedResource -IncludeCost' }
    $bigDeleter = @($changers | Where-Object { $_.Deletes -ge 10 }) | Select-Object -First 1
    if ($bigDeleter) { & $act 'Medium' 'Changes' "$($bigDeleter.Deletes) deletes by $($bigDeleter.ChangedBy) in $($Hours)h" 'Get-AACChangeHistory' }
    if ($privilegedTotal) { & $act 'Low' 'Access' "$privilegedTotal privileged role assignment(s) at subscription or above - review who needs them" 'Get-AACRoleAssignment -Privileged' }
    if ($null -ne $tagCoverage -and $tagCoverage -lt 50) { & $act 'Low' 'Governance' "Only $tagCoverage% of resources are tagged" 'Get-AACTagUsage' }
    $order = @{ High = 0; Medium = 1; Low = 2 }
    $actions = @($actions | Sort-Object { $order[$_.Severity] })

    $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
        Score              = $score
        Security           = $security
        Policy             = $policy
        Governance         = $governance
        AdvisorScore       = $advisorScore
        AdvisorSavings     = $advisorSavings
        TopTypes           = $topTypes
        BySubscription     = $bySubscription
        ChangeTimeline     = $timeline
        TopChangers        = $changers
        Cost               = $costCard
        Actions            = $actions
    }
}